1#[cfg(test)]
12use std::collections::VecDeque;
13use std::collections::{BTreeMap, BTreeSet};
14use std::ffi::OsStr;
15use std::fmt;
16use std::fs;
17use std::io::Write;
18use std::num::NonZeroU16;
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, Mutex};
21use std::time::Duration;
22
23use async_trait::async_trait;
24use runner_manager_domain::attempt::{
25 AttemptOutcome, AttemptState, FailureReason, GithubRunnerObservation, 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::{RoutingLabels, 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::{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,
264 pub materialize: Duration,
265 pub register: Duration,
266 pub spawn: Duration,
267}
268
269impl LaunchTimings {
270 #[must_use]
271 pub fn total(&self) -> Duration {
272 self.lock_wait + self.materialize + self.register + self.spawn
273 }
274}
275
276#[derive(Debug, Clone, Copy)]
277enum LaunchStep {
278 Materialize,
279 Register,
280 Spawn,
281}
282
283#[derive(Debug)]
287struct LaunchStopwatch {
288 timings: LaunchTimings,
289 running: Option<(LaunchStep, std::time::Instant)>,
290}
291
292impl LaunchStopwatch {
293 fn new(lock_wait: Duration) -> Self {
294 Self {
295 timings: LaunchTimings {
296 lock_wait,
297 ..LaunchTimings::default()
298 },
299 running: None,
300 }
301 }
302
303 fn begin(&mut self, step: LaunchStep) {
304 self.finish();
305 self.running = Some((step, std::time::Instant::now()));
306 }
307
308 fn finish(&mut self) {
309 if let Some((step, since)) = self.running.take() {
310 let slot = match step {
311 LaunchStep::Materialize => &mut self.timings.materialize,
312 LaunchStep::Register => &mut self.timings.register,
313 LaunchStep::Spawn => &mut self.timings.spawn,
314 };
315 *slot = since.elapsed();
316 }
317 }
318}
319
320const SLOW_LAUNCH: Duration = Duration::from_secs(60);
325
326#[derive(Debug, Clone, Copy, Default)]
333pub struct TracingAttemptEvents;
334
335impl AttemptEventSink for TracingAttemptEvents {
336 fn emit(&self, event: AttemptEvent) {
337 match event {
338 AttemptEvent::Retry {
339 attempt,
340 operation,
341 delay,
342 } => tracing::warn!(
343 event = "attempt_retry",
344 attempt_id = %attempt,
345 reason = operation,
346 retry_in_ms = millis(delay),
347 ),
348 AttemptEvent::Launched {
349 attempt,
350 started,
351 timings,
352 } => {
353 macro_rules! launched {
354 ($level:ident, $name:literal) => {
355 tracing::$level!(
356 event = $name,
357 attempt_id = %attempt,
358 duration_ms = millis(timings.total()),
359 lock_wait_ms = millis(timings.lock_wait),
360 materialize_ms = millis(timings.materialize),
361 register_ms = millis(timings.register),
362 spawn_ms = millis(timings.spawn),
363 )
364 };
365 }
366 if !started {
367 launched!(warn, "attempt_launch_failed");
368 } else if timings.total() >= SLOW_LAUNCH {
369 launched!(warn, "attempt_launch_slow");
370 } else {
371 launched!(info, "attempt_launched");
372 }
373 }
374 _ => {}
375 }
376 }
377}
378
379fn millis(duration: Duration) -> u64 {
380 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
381}
382
383#[async_trait]
385pub trait DemandPersistence: fmt::Debug + Send + Sync {
386 async fn persists(&self, policy: PolicyId) -> bool;
387}
388
389#[derive(Debug, Clone, Copy, Default)]
390pub struct PersistentDemand;
391
392#[async_trait]
393impl DemandPersistence for PersistentDemand {
394 async fn persists(&self, _policy: PolicyId) -> bool {
395 true
396 }
397}
398
399#[async_trait]
400pub trait RetryDelay: fmt::Debug + Send + Sync {
401 async fn wait(&self, duration: Duration);
402}
403
404#[derive(Debug, Clone, Copy, Default)]
405pub struct TokioRetryDelay;
406
407#[async_trait]
408impl RetryDelay for TokioRetryDelay {
409 async fn wait(&self, duration: Duration) {
410 tokio::time::sleep(duration).await;
411 }
412}
413
414#[derive(Debug, Clone, PartialEq, Eq)]
415pub struct JitRequestFailure {
416 pub terminal: bool,
417 pub reason: FailureReason,
418 pub retry_after: Option<Duration>,
419}
420
421#[derive(Debug, Clone, Copy, PartialEq, Eq)]
425pub struct LifecycleGithubObservation {
426 pub status: GithubRunnerObservation,
427 pub runner_id: Option<u64>,
428}
429
430impl LifecycleGithubObservation {
431 #[must_use]
432 pub const fn unreachable() -> Self {
433 Self {
434 status: GithubRunnerObservation::Unreachable,
435 runner_id: None,
436 }
437 }
438
439 #[must_use]
440 pub const fn not_registered() -> Self {
441 Self {
442 status: GithubRunnerObservation::NotRegistered,
443 runner_id: None,
444 }
445 }
446
447 #[must_use]
448 pub const fn registered(runner_id: u64, busy: bool) -> Self {
449 Self {
450 status: GithubRunnerObservation::Registered { busy },
451 runner_id: Some(runner_id),
452 }
453 }
454}
455
456#[async_trait]
459pub trait LifecycleGithub: fmt::Debug + Send + Sync {
460 async fn register(
461 &self,
462 target: &ScaleTarget,
463 request: &JitRunnerRequest,
464 cancel: &CancelToken,
465 ) -> Result<JitRegistration, JitRequestFailure>;
466
467 async fn observe(
468 &self,
469 target: &ScaleTarget,
470 attempt: AttemptId,
471 cancel: &CancelToken,
472 ) -> LifecycleGithubObservation;
473
474 async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool;
481}
482
483#[async_trait]
484impl<T> LifecycleGithub for T
485where
486 T: JitGateway + InventoryGateway + fmt::Debug + Send + Sync,
487{
488 async fn register(
489 &self,
490 target: &ScaleTarget,
491 request: &JitRunnerRequest,
492 cancel: &CancelToken,
493 ) -> Result<JitRegistration, JitRequestFailure> {
494 self.generate_jit_config(target, request, cancel)
495 .await
496 .map_err(|error| {
497 let reason = if matches!(&error, JitError::Forbidden { .. }) {
498 FailureReason::Other(
499 "GitHub refused JIT registration with 403; check the App's runner permission and runner-group access"
500 .into(),
501 )
502 } else {
503 FailureReason::JitRequestFailed
504 };
505 JitRequestFailure {
506 terminal: error.is_terminal(),
507 reason,
508 retry_after: error
509 .rate_limited()
510 .map(|limit| limit.delay_from(self.now())),
511 }
512 })
513 }
514
515 async fn observe(
516 &self,
517 target: &ScaleTarget,
518 attempt: AttemptId,
519 cancel: &CancelToken,
520 ) -> LifecycleGithubObservation {
521 let expected_name = runner_name(attempt);
522 match self.list_runners(target, cancel).await {
523 Ok(inventory) => inventory
524 .runners()
525 .iter()
526 .find(|runner| runner.name == expected_name)
527 .map_or(LifecycleGithubObservation::not_registered(), |runner| {
528 LifecycleGithubObservation::registered(runner.id, runner.busy)
529 }),
530 Err(_) => LifecycleGithubObservation::unreachable(),
531 }
532 }
533
534 async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool {
535 self.remove_runner(target, runner_id, cancel).await.is_ok()
536 }
537}
538
539#[async_trait]
541pub trait RuntimePackages: fmt::Debug + Send + Sync {
542 async fn materialize(&self, attempt: &RunnerAttempt) -> Result<RunnerVersion, FailureReason>;
543 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason>;
544 fn prune_obsolete_guarded(
545 &self,
546 authority: PruneAuthority<'_>,
547 current: &RunnerVersion,
548 attempts: &[RunnerAttempt],
549 ) -> Result<(), FailureReason>;
550}
551
552pub struct PruneAuthority<'a> {
557 _guard: &'a AllocationGuard,
558}
559
560impl fmt::Debug for PruneAuthority<'_> {
561 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
562 f.write_str("PruneAuthority")
563 }
564}
565
566impl<'a> PruneAuthority<'a> {
567 fn from_launch_request(guard: &'a AllocationGuard) -> Self {
568 Self { _guard: guard }
569 }
570}
571
572#[derive(Debug)]
575pub struct CachedRuntimePackages {
576 cache: Arc<PackageCache>,
577 trusted: Arc<TrustedTrees>,
578}
579
580impl CachedRuntimePackages {
581 async fn blocking<T: Send + 'static>(
584 &self,
585 root: &Path,
586 work: impl FnOnce(&TrustedTrees, &Path) -> T + Send + 'static,
587 ) -> Result<T, FailureReason> {
588 let trusted = Arc::clone(&self.trusted);
589 let root = root.to_path_buf();
590 tokio::task::spawn_blocking(move || work(&trusted, &root))
591 .await
592 .map_err(|_| FailureReason::ProcessStartFailed)
593 }
594
595 #[must_use]
596 pub fn new(cache: Arc<PackageCache>) -> Self {
597 Self {
598 cache,
599 trusted: Arc::default(),
600 }
601 }
602}
603
604#[async_trait]
605impl RuntimePackages for CachedRuntimePackages {
606 async fn materialize(&self, attempt: &RunnerAttempt) -> Result<RunnerVersion, FailureReason> {
607 let (mut installed, downloaded) = self
608 .cache
609 .ensure_installed_reporting()
610 .await
611 .map_err(package_failure)?;
612 let fresh = if downloaded {
617 true
618 } else if self.blocking(installed.root(), TrustedTrees::holds).await? {
619 false
620 } else {
621 installed = self.cache.reinstall().await.map_err(package_failure)?;
622 true
623 };
624 if fresh {
625 self.blocking(installed.root(), TrustedTrees::record)
626 .await?
627 .map_err(|_| FailureReason::ProcessStartFailed)?;
628 }
629 let destination = attempt.runtime_path().to_path_buf();
632 let copied = self
633 .blocking(installed.root(), move |trusted, source| {
634 copy_package_tree(source, &destination, trusted)?;
635 if trusted.holds(source) {
638 Ok(())
639 } else {
640 Err(std::io::Error::other(
641 "the runner package changed while copied",
642 ))
643 }
644 })
645 .await?;
646 if copied.is_err() {
647 let _ = remove_materialized_package(attempt);
650 return Err(FailureReason::ProcessStartFailed);
651 }
652 if let Err(error) = self.cache.lease(attempt, installed.version()) {
653 let _ = remove_materialized_package(attempt);
657 return Err(package_failure(error));
658 }
659 Ok(installed.version().clone())
660 }
661
662 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
663 self.cache.release(attempt).map_err(package_failure)
664 }
665
666 fn prune_obsolete_guarded(
667 &self,
668 _authority: PruneAuthority<'_>,
669 current: &RunnerVersion,
670 attempts: &[RunnerAttempt],
671 ) -> Result<(), FailureReason> {
672 for installed in self.cache.installed().map_err(package_failure)? {
673 if installed.version() != current {
674 match self.cache.prune(installed.version(), attempts) {
675 Ok(()) | Err(PackageError::VersionInUse { .. }) => {}
676 Err(error) => return Err(package_failure(error)),
677 }
678 }
679 }
680 Ok(())
681 }
682}
683
684fn package_failure(error: PackageError) -> FailureReason {
685 error.failure_reason().unwrap_or(FailureReason::Other(
686 "runner package cache operation failed".into(),
687 ))
688}
689
690fn package_failure_is_terminal(reason: &FailureReason) -> bool {
691 matches!(
692 reason,
693 FailureReason::RunnerPackageUnverified | FailureReason::RunnerVersionRejected
694 )
695}
696
697const WORKSPACE_NAME_LEN: usize = 12;
720
721fn workspace_name(id: AttemptId) -> String {
723 let full = id.to_string();
724 full.chars()
725 .filter(|c| *c != '-')
726 .take(WORKSPACE_NAME_LEN)
727 .collect()
728}
729
730#[derive(Debug, Clone)]
737struct Placement {
738 runtime: PathBuf,
739 workspace: AttemptWorkspace,
740}
741
742fn root_failure(error: RunnerRootError) -> LifecycleError {
749 tracing::warn!(
766 error_kind = error.kind(),
767 "the runner root refused this launch, so no attempt was created; the host will \
768 retry every poll until the cause is resolved. Re-running `host set-runtime-root` \
769 with the same path re-runs this check and prints the directory and the \
770 remediation in full"
771 );
772 LifecycleError::Failed(FailureReason::Other(error.to_string()))
773}
774
775fn lowest_free_slot(leases: &[RunnerAttempt], ceiling: NonZeroU16) -> Option<NonZeroU16> {
784 let held: BTreeSet<u16> = leases
785 .iter()
786 .filter_map(|attempt| attempt.workspace().slot_number())
787 .collect();
788 (1..=ceiling.get())
789 .find(|slot| !held.contains(slot))
790 .and_then(NonZeroU16::new)
791}
792
793fn create_or_validate_slot(slot: &Path) -> Result<(), LifecycleError> {
801 match fs::symlink_metadata(slot) {
802 Ok(metadata) if is_link_like(&metadata) => Err(slot_refusal(
808 slot,
809 "is a symbolic link, junction or other reparse point, which could place runner \
810 files outside the configured root",
811 )),
812 Ok(metadata) if !metadata.is_dir() => Err(slot_refusal(slot, "is not a directory")),
813 Ok(_) => Ok(()),
814 Err(error) if error.kind() == std::io::ErrorKind::NotFound => fs::create_dir(slot)
815 .map_err(|source| slot_refusal(slot, format!("could not be created: {source}"))),
816 Err(source) => Err(slot_refusal(
817 slot,
818 format!("could not be inspected: {source}"),
819 )),
820 }
821}
822
823fn accept_reusable_slot(slot: &Path) -> Result<(), LifecycleError> {
835 let unreadable =
836 |source: std::io::Error| slot_refusal(slot, format!("could not be read: {source}"));
837 let entries = fs::read_dir(slot).map_err(unreadable)?;
838 let mut refused: Vec<String> = Vec::new();
839 for entry in entries {
840 let entry = entry.map_err(unreadable)?;
841 let name = entry.file_name();
842 let metadata = fs::symlink_metadata(entry.path()).map_err(|source| {
843 slot_refusal(
844 slot,
845 format!("entry {name:?} could not be inspected: {source}"),
846 )
847 })?;
848 if is_retainable_work_folder(&name, &metadata) {
853 continue;
854 }
855 refused.push(name.to_string_lossy().into_owned());
856 }
857 if refused.is_empty() {
858 return Ok(());
859 }
860 refused.sort();
861 Err(slot_refusal(
862 slot,
863 format!(
864 "holds {} that this attempt may not reuse: [{}]. A reusable slot is empty or holds \
865 one real `{DEFAULT_WORK_FOLDER}` directory and nothing else; remove or move the \
866 entries listed, or let cleanup and recovery resolve them",
867 if refused.len() == 1 {
868 "an entry"
869 } else {
870 "entries"
871 },
872 refused.join(", ")
873 ),
874 ))
875}
876
877fn slot_refusal(slot: &Path, detail: impl fmt::Display) -> LifecycleError {
878 LifecycleError::Failed(FailureReason::Other(format!(
879 "the persistent slot {} {detail}",
880 slot.display()
881 )))
882}
883
884fn is_work_folder(name: &OsStr) -> bool {
894 if cfg!(windows) {
895 name.eq_ignore_ascii_case(DEFAULT_WORK_FOLDER)
896 } else {
897 name == OsStr::new(DEFAULT_WORK_FOLDER)
898 }
899}
900
901fn is_link_like(metadata: &fs::Metadata) -> bool {
910 if metadata.file_type().is_symlink() {
911 return true;
912 }
913 #[cfg(windows)]
914 {
915 use std::os::windows::fs::MetadataExt;
916
917 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
918 metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
919 }
920 #[cfg(not(windows))]
921 false
922}
923
924fn is_retainable_work_folder(name: &OsStr, metadata: &fs::Metadata) -> bool {
932 is_work_folder(name) && metadata.is_dir() && !is_link_like(metadata)
933}
934
935fn remove_materialized_package(attempt: &RunnerAttempt) -> std::io::Result<()> {
942 match attempt.workspace() {
943 AttemptWorkspace::Ephemeral => remove_runtime_tree(attempt.runtime_path()),
944 AttemptWorkspace::PersistentSlot { .. } => scrub_slot_entries(attempt.runtime_path())
945 .map_err(|quarantine| std::io::Error::other(quarantine.to_string())),
946 }
947}
948
949fn remove_runtime_tree(path: &Path) -> std::io::Result<()> {
959 #[cfg(windows)]
960 {
961 match remove_dir_all::remove_dir_all(path) {
962 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
963 unlink_reparse_points(path);
964 remove_dir_all::remove_dir_all(path)
965 }
966 other => other,
967 }
968 }
969 #[cfg(not(windows))]
970 {
971 fs::remove_dir_all(path)
972 }
973}
974
975#[cfg(windows)]
995fn unlink_reparse_points(dir: &Path) {
996 let Ok(entries) = fs::read_dir(dir) else {
997 return;
998 };
999 for entry in entries.flatten() {
1000 let path = entry.path();
1001 let Ok(metadata) = entry.metadata() else {
1002 continue;
1003 };
1004 if is_link_like(&metadata) {
1005 let _ = fs::remove_dir(&path).or_else(|_| fs::remove_file(&path));
1006 } else if metadata.is_dir() {
1007 unlink_reparse_points(&path);
1008 }
1009 }
1010}
1011
1012#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1027enum SlotRefusal {
1028 NotTheJournalledSlot,
1030 PolicyRootDisagrees,
1032 Containment,
1034 SlotNotADirectory,
1036 Enumeration,
1038 WorkNotADirectory,
1040 Deletion,
1042 Residue,
1044}
1045
1046impl SlotRefusal {
1047 const fn class(self) -> &'static str {
1049 match self {
1050 Self::NotTheJournalledSlot => "slot_path_is_not_the_journalled_slot",
1051 Self::PolicyRootDisagrees => "slot_root_disagrees_with_policy",
1052 Self::Containment => "slot_escapes_its_root",
1053 Self::SlotNotADirectory => "slot_is_not_a_directory",
1054 Self::Enumeration => "slot_could_not_be_enumerated",
1055 Self::WorkNotADirectory => "retained_work_is_not_a_directory",
1056 Self::Deletion => "slot_entry_could_not_be_removed",
1057 Self::Residue => "slot_still_holds_runner_state",
1058 }
1059 }
1060
1061 const fn remediation(self) -> &'static str {
1063 match self {
1064 Self::NotTheJournalledSlot | Self::PolicyRootDisagrees | Self::Containment => {
1065 "the attempt keeps its slot lease and nothing was removed; correct the \
1066 repository's persistent workspace path, or remove the slot directory by hand \
1067 once you have confirmed what is in it"
1068 }
1069 Self::SlotNotADirectory | Self::WorkNotADirectory => {
1070 "the attempt keeps its slot lease and nothing was removed; a job replaced the \
1071 slot or its `_work` with a link, so inspect it before deleting anything and \
1072 treat the retained workspace as untrusted"
1073 }
1074 Self::Enumeration | Self::Deletion | Self::Residue => {
1075 "the attempt keeps its slot lease and will be cleaned again on the next pass; \
1076 release whatever is holding the files open, or remove the slot's contents by \
1077 hand leaving only `_work`"
1078 }
1079 }
1080 }
1081}
1082
1083#[derive(Debug, Clone, PartialEq, Eq)]
1090struct SlotQuarantine {
1091 refusal: SlotRefusal,
1092 detail: String,
1093}
1094
1095impl SlotQuarantine {
1096 fn new(refusal: SlotRefusal, detail: impl Into<String>) -> Self {
1097 Self {
1098 refusal,
1099 detail: detail.into(),
1100 }
1101 }
1102}
1103
1104impl fmt::Display for SlotQuarantine {
1105 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1106 write!(f, "{}; {}", self.detail, self.refusal.remediation())
1107 }
1108}
1109
1110fn verify_journalled_slot(
1124 runtime: &Path,
1125 slot: NonZeroU16,
1126 configured: Option<&LocalAbsolutePath>,
1127) -> Result<(), SlotQuarantine> {
1128 let mislaid = || {
1129 SlotQuarantine::new(
1130 SlotRefusal::NotTheJournalledSlot,
1131 format!(
1132 "the journalled runtime {} is not the slot s{slot} this attempt was allocated as",
1133 runtime.display()
1134 ),
1135 )
1136 };
1137 let local = |path: &Path| {
1138 path.to_str()
1139 .and_then(|raw| LocalAbsolutePath::new(raw).ok())
1140 .ok_or_else(mislaid)
1141 };
1142
1143 let runtime_path = local(runtime)?;
1144 let root = local(runtime.parent().ok_or_else(mislaid)?)?;
1145 let name = AttemptWorkspace::persistent_slot(slot)
1152 .slot_directory_name()
1153 .expect("a persistent workspace names its slot directory");
1154 let derived = runner_root::derive_child(&root, &name).map_err(|_| mislaid())?;
1155 if derived != runtime_path {
1156 return Err(mislaid());
1157 }
1158 if let Some(configured) = configured
1159 && configured != &root
1160 {
1161 return Err(SlotQuarantine::new(
1162 SlotRefusal::PolicyRootDisagrees,
1163 format!(
1164 "the journalled slot {} is not under the repository's configured persistent root \
1165 {}",
1166 runtime.display(),
1167 configured.as_str()
1168 ),
1169 ));
1170 }
1171 runner_root::verify_containment(&root, &derived).map_err(|source| {
1174 SlotQuarantine::new(
1175 SlotRefusal::Containment,
1176 format!("the journalled slot is not inside the root it was allocated from: {source}"),
1177 )
1178 })
1179}
1180
1181fn slot_is_present(slot: &Path) -> Result<bool, SlotQuarantine> {
1192 match fs::symlink_metadata(slot) {
1193 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1194 Err(source) => Err(SlotQuarantine::new(
1195 SlotRefusal::SlotNotADirectory,
1196 format!(
1197 "the slot {} could not be inspected: {:?}",
1198 slot.display(),
1199 source.kind()
1200 ),
1201 )),
1202 Ok(metadata) if !metadata.is_dir() || is_link_like(&metadata) => Err(SlotQuarantine::new(
1203 SlotRefusal::SlotNotADirectory,
1204 format!(
1205 "the slot {} is a link or a file rather than a real directory",
1206 slot.display()
1207 ),
1208 )),
1209 Ok(_) => Ok(true),
1210 }
1211}
1212
1213fn scrub_slot_entries(slot: &Path) -> Result<(), SlotQuarantine> {
1228 let unreadable = |source: std::io::Error| {
1229 SlotQuarantine::new(
1230 SlotRefusal::Enumeration,
1231 format!(
1232 "the entries of {} could not be listed: {:?}",
1233 slot.display(),
1234 source.kind()
1235 ),
1236 )
1237 };
1238 for entry in fs::read_dir(slot).map_err(unreadable)? {
1239 let name = entry.map_err(unreadable)?.file_name();
1240 let path = slot.join(&name);
1241 let Some(metadata) = listed_entry_metadata(&path).map_err(unreadable)? else {
1244 continue;
1245 };
1246 if is_work_folder(&name) {
1247 if is_retainable_work_folder(&name, &metadata) {
1248 continue;
1249 }
1250 return Err(SlotQuarantine::new(
1251 SlotRefusal::WorkNotADirectory,
1252 format!(
1253 "the retained `{DEFAULT_WORK_FOLDER}` in {} is a link or a file rather than a \
1254 real directory",
1255 slot.display()
1256 ),
1257 ));
1258 }
1259 remove_slot_entry(&path, &metadata).map_err(|source| {
1260 SlotQuarantine::new(
1261 SlotRefusal::Deletion,
1262 format!(
1263 "an entry of {} could not be removed: {:?}",
1264 slot.display(),
1265 source.kind()
1266 ),
1267 )
1268 })?;
1269 }
1270 Ok(())
1271}
1272
1273fn listed_entry_metadata(path: &Path) -> std::io::Result<Option<fs::Metadata>> {
1279 match fs::symlink_metadata(path) {
1280 Ok(metadata) => Ok(Some(metadata)),
1281 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1282 Err(source) => Err(source),
1283 }
1284}
1285
1286fn remove_slot_entry(path: &Path, metadata: &fs::Metadata) -> std::io::Result<()> {
1291 let removed = if is_link_like(metadata) {
1292 fs::remove_file(path).or_else(|_| fs::remove_dir(path))
1295 } else if metadata.is_dir() {
1296 remove_runtime_tree(path)
1297 } else {
1298 fs::remove_file(path)
1299 };
1300 match removed {
1301 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1302 other => other,
1303 }
1304}
1305
1306fn verify_slot_scrubbed(slot: &Path) -> Result<(), SlotQuarantine> {
1323 let unreadable = |source: std::io::Error| {
1324 SlotQuarantine::new(
1325 SlotRefusal::Enumeration,
1326 format!(
1327 "the entries of {} could not be listed to verify the scrub: {:?}",
1328 slot.display(),
1329 source.kind()
1330 ),
1331 )
1332 };
1333 let mut residue = 0_usize;
1334 let mut named: Vec<String> = Vec::new();
1335 for entry in fs::read_dir(slot).map_err(unreadable)? {
1336 let name = entry.map_err(unreadable)?.file_name();
1337 let Some(metadata) = listed_entry_metadata(&slot.join(&name)).map_err(unreadable)? else {
1341 continue;
1342 };
1343 if is_retainable_work_folder(&name, &metadata) {
1344 continue;
1345 }
1346 residue = residue.saturating_add(1);
1347 if name
1348 .to_string_lossy()
1349 .starts_with(RestrictiveHandoff::NAME_PREFIX)
1350 {
1351 named.push("an encoded JIT handoff".to_owned());
1352 }
1353 }
1354 named.extend(
1355 SENSITIVE_SLOT_ENTRIES
1356 .iter()
1357 .filter(|entry| fs::symlink_metadata(slot.join(entry)).is_ok())
1358 .map(|entry| format!("`{entry}`")),
1359 );
1360 if residue == 0 && named.is_empty() {
1361 return Ok(());
1362 }
1363 named.sort_unstable();
1364 named.dedup();
1365 Err(SlotQuarantine::new(
1366 SlotRefusal::Residue,
1367 residue_detail(slot, residue, &named),
1368 ))
1369}
1370
1371fn residue_detail(slot: &Path, residue: usize, named: &[String]) -> String {
1381 if residue == 0 {
1382 format!(
1386 "the listing of {} reported nothing but `{DEFAULT_WORK_FOLDER}`, yet {} survived \
1387 cleanup",
1388 slot.display(),
1389 named.join(", ")
1390 )
1391 } else {
1392 format!(
1393 "{residue} entr{} other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}{}",
1394 if residue == 1 { "y" } else { "ies" },
1395 slot.display(),
1396 if named.is_empty() {
1397 String::new()
1398 } else {
1399 format!(", including {}", named.join(", "))
1400 }
1401 )
1402 }
1403}
1404
1405fn replacement_operation(outcome: &AttemptOutcome) -> Option<&'static str> {
1406 match outcome {
1407 AttemptOutcome::Failed {
1408 reason: FailureReason::JitExpired,
1409 } => Some("jit_expired_replacement"),
1410 AttemptOutcome::Failed {
1411 reason: FailureReason::ProcessExitedUnexpectedly,
1412 } => Some("exit_before_acceptance_replacement"),
1413 _ => None,
1414 }
1415}
1416
1417fn copy_package_tree(
1438 source: &Path,
1439 destination: &Path,
1440 seeds: &TrustedTrees,
1441) -> std::io::Result<()> {
1442 refuse_work_folder(source)?;
1443
1444 #[cfg(unix)]
1445 {
1446 let origin = package_seed(source, destination, seeds)
1450 .ok()
1451 .filter(|seed| refuse_work_folder(seed).is_ok())
1452 .unwrap_or_else(|| source.to_path_buf());
1453 clone_tree(&origin, destination)?;
1454 if origin != source && !seeds.holds(&origin) {
1457 return Err(std::io::Error::other(
1458 "the package seed changed while cloned",
1459 ));
1460 }
1461 Ok(())
1462 }
1463 #[cfg(not(unix))]
1464 {
1465 let _ = seeds;
1466 copy_package_entries(source, destination, true)
1467 }
1468}
1469
1470fn refuse_work_folder(source: &Path) -> std::io::Result<()> {
1471 if source.join(DEFAULT_WORK_FOLDER).exists() {
1472 return Err(std::io::Error::new(
1473 std::io::ErrorKind::InvalidData,
1474 "a cached runner package contains a _work folder, which means it was used \
1475 to run a job before it was archived; the cache must only contain clean \
1476 extracts to prevent data leakage",
1477 ));
1478 }
1479 Ok(())
1480}
1481
1482#[cfg(unix)]
1484const PACKAGE_SEED_DIR: &str = ".runner-package";
1485
1486#[cfg(unix)]
1505fn package_seed(
1506 source: &Path,
1507 destination: &Path,
1508 seeds: &TrustedTrees,
1509) -> std::io::Result<PathBuf> {
1510 use std::os::unix::fs::MetadataExt as _;
1511
1512 if !cfg!(target_os = "macos") {
1513 return Ok(source.to_path_buf());
1514 }
1515 let root = destination
1516 .parent()
1517 .ok_or_else(|| std::io::Error::other("a runtime directory has no parent"))?;
1518 if fs::metadata(source)?.dev() == fs::metadata(root)?.dev() {
1519 return Ok(source.to_path_buf());
1520 }
1521 refresh_package_seed(source, &root.join(PACKAGE_SEED_DIR), seeds)
1522}
1523
1524#[derive(Debug, Default)]
1539pub(crate) struct TrustedTrees {
1540 #[cfg_attr(not(unix), allow(dead_code))]
1541 prints: Mutex<std::collections::HashMap<PathBuf, [u8; 32]>>,
1542}
1543
1544impl TrustedTrees {
1545 fn holds(&self, root: &Path) -> bool {
1547 #[cfg(unix)]
1548 {
1549 let recorded = self
1550 .prints
1551 .lock()
1552 .unwrap_or_else(std::sync::PoisonError::into_inner)
1553 .get(root)
1554 .copied();
1555 recorded.is_some_and(|recorded| {
1556 tree_fingerprint(root).is_ok_and(|current| current == recorded)
1557 })
1558 }
1559 #[cfg(not(unix))]
1560 {
1561 let _ = root;
1562 true
1563 }
1564 }
1565
1566 fn record(&self, root: &Path) -> std::io::Result<()> {
1568 #[cfg(unix)]
1569 {
1570 let print = tree_fingerprint(root)?;
1571 self.prints
1572 .lock()
1573 .unwrap_or_else(std::sync::PoisonError::into_inner)
1574 .insert(root.to_path_buf(), print);
1575 }
1576 #[cfg(not(unix))]
1577 let _ = root;
1578 Ok(())
1579 }
1580}
1581
1582#[cfg(unix)]
1585fn tree_fingerprint(root: &Path) -> std::io::Result<[u8; 32]> {
1586 use sha2::{Digest as _, Sha256};
1587 use std::os::unix::fs::MetadataExt as _;
1588
1589 fn walk(root: &Path, path: &Path, hasher: &mut Sha256) -> std::io::Result<()> {
1590 let metadata = fs::symlink_metadata(path)?;
1591 let relative = path.strip_prefix(root).unwrap_or(path);
1592 hasher.update(relative.as_os_str().as_encoded_bytes());
1593 hasher.update([0]);
1594 for field in [
1595 metadata.ino(),
1596 metadata.size(),
1597 u64::from(metadata.mode()),
1598 u64::from(metadata.uid()),
1599 u64::from(metadata.gid()),
1600 ] {
1601 hasher.update(field.to_le_bytes());
1602 }
1603 hasher.update(metadata.ctime().to_le_bytes());
1604 hasher.update(metadata.ctime_nsec().to_le_bytes());
1605 if metadata.is_dir() {
1606 let mut children = fs::read_dir(path)?
1607 .map(|entry| entry.map(|entry| entry.path()))
1608 .collect::<std::io::Result<Vec<_>>>()?;
1609 children.sort();
1610 for child in children {
1611 walk(root, &child, hasher)?;
1612 }
1613 }
1614 Ok(())
1615 }
1616
1617 let mut hasher = Sha256::new();
1618 walk(root, root, &mut hasher)?;
1619 Ok(hasher.finalize().into())
1620}
1621
1622#[cfg(unix)]
1625fn refresh_package_seed(
1626 source: &Path,
1627 seeds: &Path,
1628 trusted: &TrustedTrees,
1629) -> std::io::Result<PathBuf> {
1630 use crate::package::MANIFEST_FILE;
1631
1632 let version = source
1633 .file_name()
1634 .ok_or_else(|| std::io::Error::other("a cache entry has no name"))?;
1635 let seed = seeds.join(version);
1636 let manifest = fs::read(source.join(MANIFEST_FILE))?;
1637 if fs::read(seed.join(MANIFEST_FILE)).is_ok_and(|held| held == manifest) && trusted.holds(&seed)
1638 {
1639 return Ok(seed);
1640 }
1641
1642 match remove_runtime_tree(seeds) {
1644 Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error),
1645 _ => {}
1646 }
1647 fs::create_dir_all(seeds)?;
1648 let staging = seeds.join(format!(".staging-{}", uuid::Uuid::new_v4()));
1649 fs::create_dir(&staging)?;
1650 if let Err(error) = clone_tree(source, &staging).and_then(|()| fs::rename(&staging, &seed)) {
1652 let _ = remove_runtime_tree(&staging);
1653 return Err(error);
1654 }
1655 trusted.record(&seed)?;
1657 Ok(seed)
1658}
1659
1660#[cfg(unix)]
1665fn clone_tree(source: &Path, destination: &Path) -> std::io::Result<()> {
1666 #[cfg(target_os = "macos")]
1667 const CLONE: &[&str] = &["-c"];
1668 #[cfg(target_os = "linux")]
1669 const CLONE: &[&str] = &["--reflink=auto"];
1670 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
1671 const CLONE: &[&str] = &[];
1672
1673 let from = format!("{}/.", source.display());
1674 let cp = |extra: &[&str]| {
1675 std::process::Command::new("cp")
1676 .arg("-a")
1677 .args(extra)
1678 .arg(&from)
1679 .arg(destination)
1680 .status()
1681 };
1682 if cp(CLONE)?.success() || cp(&[])?.success() {
1683 Ok(())
1684 } else {
1685 Err(std::io::Error::other("cp failed"))
1686 }
1687}
1688
1689#[cfg(not(unix))]
1690fn copy_package_entries(source: &Path, destination: &Path, top_level: bool) -> std::io::Result<()> {
1691 fs::create_dir_all(destination)?;
1692 for entry in fs::read_dir(source)? {
1693 let entry = entry?;
1694 if top_level && is_work_folder(&entry.file_name()) {
1695 return Err(std::io::Error::new(
1696 std::io::ErrorKind::InvalidData,
1697 format!(
1698 "the runner package holds a top-level `{DEFAULT_WORK_FOLDER}`; copying \
1699 it would overwrite the job workspace a persistent slot retains"
1700 ),
1701 ));
1702 }
1703 let target = destination.join(entry.file_name());
1704 if entry.file_type()?.is_dir() {
1705 copy_package_entries(&entry.path(), &target, false)?;
1706 } else {
1707 fs::copy(entry.path(), target)?;
1708 }
1709 }
1710 Ok(())
1711}
1712
1713#[derive(Debug, Clone, PartialEq, Eq)]
1716pub struct ProcessStartFailure {
1717 pub reason: FailureReason,
1718 pub retryable: bool,
1721 pub live_pid: Option<u32>,
1724}
1725
1726impl ProcessStartFailure {
1727 pub(crate) fn before_spawn(reason: FailureReason) -> Self {
1728 Self {
1729 reason,
1730 retryable: true,
1731 live_pid: None,
1732 }
1733 }
1734
1735 fn after_spawn_stopped() -> Self {
1736 Self {
1737 reason: FailureReason::ProcessStartFailed,
1738 retryable: false,
1739 live_pid: None,
1740 }
1741 }
1742
1743 fn after_spawn_live(pid: u32) -> Self {
1744 Self::after_spawn_live_with_reason(pid, FailureReason::ProcessStartFailed)
1745 }
1746
1747 fn after_spawn_live_with_reason(pid: u32, reason: FailureReason) -> Self {
1748 Self {
1749 reason,
1750 retryable: false,
1751 live_pid: Some(pid),
1752 }
1753 }
1754}
1755
1756#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1759pub enum ProviderCapability {
1760 Ready,
1761 Unsupported,
1762 NotInstalled,
1763 PermissionDenied,
1764 ImageUnavailableOrIncompatible,
1765 DiskQuotaUnavailable,
1766 Degraded,
1767}
1768
1769impl ProviderCapability {
1770 #[must_use]
1771 pub const fn display_name(self) -> &'static str {
1772 match self {
1773 Self::Ready => "ready",
1774 Self::Unsupported => "unsupported",
1775 Self::NotInstalled => "not installed",
1776 Self::PermissionDenied => "permission denied",
1777 Self::ImageUnavailableOrIncompatible => "image unavailable or incompatible",
1778 Self::DiskQuotaUnavailable => "disk quota unavailable",
1779 Self::Degraded => "degraded",
1780 }
1781 }
1782
1783 pub(crate) fn refusal(self) -> FailureReason {
1784 FailureReason::IsolationProvider(match self {
1785 Self::Ready | Self::Degraded => IsolationProviderFailure::Degraded,
1786 Self::Unsupported => IsolationProviderFailure::Unsupported,
1787 Self::NotInstalled => IsolationProviderFailure::NotInstalled,
1788 Self::PermissionDenied => IsolationProviderFailure::PermissionDenied,
1789 Self::ImageUnavailableOrIncompatible => {
1790 IsolationProviderFailure::ImageUnavailableOrIncompatible
1791 }
1792 Self::DiskQuotaUnavailable => IsolationProviderFailure::DiskQuotaUnavailable,
1793 })
1794 }
1795}
1796
1797fn safe_isolation_reason(
1800 reason: FailureReason,
1801 fallback: IsolationProviderFailure,
1802) -> FailureReason {
1803 match reason {
1804 FailureReason::IsolationProvider(_) => reason,
1805 _ => FailureReason::IsolationProvider(fallback),
1806 }
1807}
1808
1809#[derive(Debug, Clone, PartialEq, Eq)]
1810pub struct PreparedEnvironment {
1811 attempt: AttemptId,
1812 identity: Option<EnvironmentIdentity>,
1813 cache_target: Option<runner_manager_domain::model::ScaleTarget>,
1816}
1817
1818impl PreparedEnvironment {
1819 #[must_use]
1821 pub fn isolated(attempt: AttemptId, identity: EnvironmentIdentity) -> Self {
1822 Self {
1823 attempt,
1824 identity: Some(identity),
1825 cache_target: None,
1826 }
1827 }
1828
1829 #[must_use]
1830 pub fn identity(&self) -> Option<&EnvironmentIdentity> {
1831 self.identity.as_ref()
1832 }
1833}
1834
1835#[derive(Debug, Clone, PartialEq, Eq)]
1836pub struct ResolvedEnvironment {
1837 pub provider_kind: Backend,
1838 pub image: ImageReference,
1839}
1840
1841#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1843pub enum ProviderDiagnostic {
1844 CapabilityUnavailable,
1845 ImageRejected,
1846 PrepareFailed,
1847 StartFailed,
1848 OwnershipMismatch,
1849 CleanupDeferred,
1850}
1851
1852#[derive(Debug, Clone, PartialEq, Eq)]
1853pub enum EnvironmentIdentity {
1854 NativeProcess(u32),
1855 Isolated {
1856 host: HostId,
1857 attempt: AttemptId,
1858 provider_kind: Backend,
1859 environment_id: String,
1860 resolved_image: ImageReference,
1861 generation: String,
1862 },
1863}
1864
1865#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1866pub enum EnvironmentState {
1867 Starting,
1868 Running,
1869 Exited,
1870 Missing,
1871}
1872
1873#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1874pub enum ProviderStop {
1875 Stopped,
1876 StillRunning,
1877}
1878
1879#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1880pub enum ProviderDestroy {
1881 Destroyed,
1882 Deferred(&'static str),
1883}
1884
1885fn identity_matches_intent(
1886 host: HostId,
1887 attempt: &RunnerAttempt,
1888 identity: &EnvironmentIdentity,
1889) -> bool {
1890 identity_same_allocation(host, attempt, identity)
1891 && match (attempt.execution(), identity) {
1892 (
1893 AttemptExecution::Isolated { environment_id, .. },
1894 EnvironmentIdentity::Isolated {
1895 environment_id: id, ..
1896 },
1897 ) => environment_id
1898 .as_ref()
1899 .is_none_or(|journal_id| journal_id == id),
1900 _ => false,
1901 }
1902}
1903
1904fn identity_same_allocation(
1905 host: HostId,
1906 attempt: &RunnerAttempt,
1907 identity: &EnvironmentIdentity,
1908) -> bool {
1909 match (attempt.execution(), identity) {
1910 (
1911 AttemptExecution::Isolated {
1912 provider_kind,
1913 environment_id: _,
1914 resolved_image,
1915 generation,
1916 },
1917 EnvironmentIdentity::Isolated {
1918 host: owner,
1919 attempt: owner_attempt,
1920 provider_kind: kind,
1921 environment_id: _,
1922 resolved_image: image,
1923 generation: resource_generation,
1924 },
1925 ) => {
1926 *owner == host
1927 && *owner_attempt == attempt.id
1928 && provider_kind == kind
1929 && resolved_image == image
1930 && generation == resource_generation
1931 }
1932 _ => false,
1933 }
1934}
1935
1936pub struct OneTimeJitHandoff<'a> {
1939 config: &'a EncodedJitConfig,
1940}
1941
1942impl fmt::Debug for OneTimeJitHandoff<'_> {
1943 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1944 f.write_str("OneTimeJitHandoff([redacted])")
1945 }
1946}
1947
1948impl<'a> OneTimeJitHandoff<'a> {
1949 pub(crate) fn new(config: &'a EncodedJitConfig) -> Self {
1950 Self { config }
1951 }
1952
1953 pub fn consume(self) -> &'a EncodedJitConfig {
1955 self.config
1956 }
1957}
1958
1959pub trait ExecutionProvider: fmt::Debug + Send + Sync {
1964 fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
1965 if policy.execution_policy().is_native() {
1966 ProviderCapability::Ready
1967 } else {
1968 ProviderCapability::Unsupported
1969 }
1970 }
1971
1972 fn resolve(&self, policy: &ScalePolicy) -> Result<Option<ResolvedEnvironment>, FailureReason> {
1974 if policy.execution_policy().is_native() {
1975 Ok(None)
1976 } else {
1977 Err(FailureReason::Other(
1978 "execution provider unavailable".into(),
1979 ))
1980 }
1981 }
1982
1983 fn prepare(
1984 &self,
1985 attempt: &RunnerAttempt,
1986 policy: &ScalePolicy,
1987 ) -> Result<PreparedEnvironment, FailureReason> {
1988 if !attempt.execution().is_native() || !policy.execution_policy().is_native() {
1989 return Err(FailureReason::Other(
1990 "execution provider unavailable".into(),
1991 ));
1992 }
1993 Ok(PreparedEnvironment {
1994 attempt: attempt.id,
1995 identity: None,
1996 cache_target: None,
1997 })
1998 }
1999
2000 fn start(
2001 &self,
2002 prepared: PreparedEnvironment,
2003 attempt: &RunnerAttempt,
2004 handoff: OneTimeJitHandoff<'_>,
2005 ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2006 if prepared.attempt != attempt.id || !attempt.execution().is_native() {
2007 return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2008 "execution provider identity mismatch".into(),
2009 )));
2010 }
2011 self.spawn(attempt, handoff.consume())
2012 .map(EnvironmentIdentity::NativeProcess)
2013 }
2014
2015 fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2016 if !attempt.execution().is_native() {
2017 return Err(FailureReason::Other(
2018 "execution provider unavailable".into(),
2019 ));
2020 }
2021 self.is_alive(attempt).map(|alive| {
2022 if alive {
2023 EnvironmentState::Running
2024 } else {
2025 EnvironmentState::Missing
2026 }
2027 })
2028 }
2029
2030 fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
2031 if !attempt.execution().is_native() {
2032 return Err(FailureReason::Other(
2033 "execution provider unavailable".into(),
2034 ));
2035 }
2036 self.terminate(attempt).map(|()| ProviderStop::Stopped)
2037 }
2038
2039 fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
2040 if !attempt.execution().is_native() {
2041 return Err(FailureReason::Other(
2042 "execution provider unavailable".into(),
2043 ));
2044 }
2045 Ok(ProviderDestroy::Destroyed)
2050 }
2051
2052 fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2053 self.inspect(attempt)
2054 }
2055
2056 fn enumerate_owned(&self, _host_id: HostId) -> Vec<EnvironmentIdentity> {
2057 Vec::new() }
2059
2060 fn diagnostics(&self, _attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
2061 Vec::new() }
2063
2064 fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2066 Ok(attempt.execution().is_native())
2067 }
2068
2069 fn spawn(
2070 &self,
2071 attempt: &RunnerAttempt,
2072 config: &EncodedJitConfig,
2073 ) -> Result<u32, ProcessStartFailure>;
2074 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason>;
2075 fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason>;
2078 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool;
2081 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
2082 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool;
2083 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
2084}
2085
2086pub use ExecutionProvider as ProcessSupervisor;
2088
2089#[derive(Debug, Default)]
2092pub struct NativeProcesses {
2093 children: Mutex<BTreeMap<AttemptId, ChildProcess>>,
2094 successful_exits: Mutex<BTreeMap<AttemptId, bool>>,
2095 runner_env_file: Option<PathBuf>,
2098 dependency_caches: Option<DependencyCaches>,
2100 #[cfg(test)]
2101 post_spawn_faults: Mutex<VecDeque<PostSpawnBoundary>>,
2102 #[cfg(test)]
2103 post_spawn_reaps: std::sync::atomic::AtomicUsize,
2104 #[cfg(test)]
2105 post_spawn_stop_failures: std::sync::atomic::AtomicUsize,
2106 #[cfg(test)]
2107 use_long_lived_test_listener: std::sync::atomic::AtomicBool,
2108}
2109
2110#[derive(Debug)]
2114pub struct PlatformExecutionProvider {
2115 native: NativeProcesses,
2116 oci: OciProcesses,
2117 windows_hyper_v: WindowsHyperVContainers,
2118}
2119
2120impl PlatformExecutionProvider {
2121 #[must_use]
2122 pub fn new(host_id: HostId) -> Self {
2123 Self {
2124 native: NativeProcesses::new(),
2125 oci: OciProcesses::new(host_id),
2126 windows_hyper_v: WindowsHyperVContainers::new(host_id),
2127 }
2128 }
2129
2130 #[must_use]
2133 pub fn with_runner_env_file(mut self, path: PathBuf) -> Self {
2134 self.native = self.native.with_runner_env_file(path);
2135 self
2136 }
2137
2138 #[must_use]
2141 pub fn with_dependency_caches(mut self, caches: DependencyCaches) -> Self {
2142 self.native = self.native.with_dependency_caches(caches);
2143 self
2144 }
2145
2146 fn isolated_provider(&self, backend: Backend) -> Option<&dyn ExecutionProvider> {
2147 match backend {
2148 Backend::Auto => {
2149 #[cfg(target_os = "windows")]
2150 {
2151 Some(&self.windows_hyper_v)
2152 }
2153 #[cfg(not(target_os = "windows"))]
2154 {
2155 Some(&self.oci)
2156 }
2157 }
2158 Backend::Oci => Some(&self.oci),
2159 Backend::WindowsHyperVContainer => Some(&self.windows_hyper_v),
2160 Backend::VirtualMachine => None,
2161 }
2162 }
2163
2164 fn provider_for_attempt(&self, attempt: &RunnerAttempt) -> Option<&dyn ExecutionProvider> {
2165 match attempt.execution() {
2166 AttemptExecution::Native { .. } => Some(&self.native),
2167 AttemptExecution::Isolated { provider_kind, .. } => {
2168 self.isolated_provider(*provider_kind)
2169 }
2170 }
2171 }
2172
2173 fn required_provider_for_attempt(
2174 &self,
2175 attempt: &RunnerAttempt,
2176 ) -> Result<&dyn ExecutionProvider, FailureReason> {
2177 self.provider_for_attempt(attempt)
2178 .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))
2179 }
2180}
2181
2182impl ExecutionProvider for PlatformExecutionProvider {
2183 fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
2184 match policy.execution_policy() {
2185 runner_manager_domain::execution::ExecutionPolicy::Native => self.native.probe(policy),
2186 runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2187 .isolated_provider(*backend)
2188 .map_or(ProviderCapability::Unsupported, |provider| {
2189 provider.probe(policy)
2190 }),
2191 }
2192 }
2193
2194 fn resolve(&self, policy: &ScalePolicy) -> Result<Option<ResolvedEnvironment>, FailureReason> {
2195 match policy.execution_policy() {
2196 runner_manager_domain::execution::ExecutionPolicy::Native => {
2197 self.native.resolve(policy)
2198 }
2199 runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2200 .isolated_provider(*backend)
2201 .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))?
2202 .resolve(policy),
2203 }
2204 }
2205
2206 fn prepare(
2207 &self,
2208 attempt: &RunnerAttempt,
2209 policy: &ScalePolicy,
2210 ) -> Result<PreparedEnvironment, FailureReason> {
2211 match policy.execution_policy() {
2212 runner_manager_domain::execution::ExecutionPolicy::Native => {
2213 self.native.prepare(attempt, policy)
2214 }
2215 runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2216 .isolated_provider(*backend)
2217 .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))?
2218 .prepare(attempt, policy),
2219 }
2220 }
2221
2222 fn start(
2223 &self,
2224 prepared: PreparedEnvironment,
2225 attempt: &RunnerAttempt,
2226 handoff: OneTimeJitHandoff<'_>,
2227 ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2228 self.provider_for_attempt(attempt)
2229 .ok_or_else(|| {
2230 ProcessStartFailure::before_spawn(FailureReason::Other(
2231 "execution provider unavailable".into(),
2232 ))
2233 })?
2234 .start(prepared, attempt, handoff)
2235 }
2236
2237 fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2238 self.required_provider_for_attempt(attempt)?
2239 .inspect(attempt)
2240 }
2241
2242 fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
2243 self.required_provider_for_attempt(attempt)?.stop(attempt)
2244 }
2245
2246 fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
2247 self.required_provider_for_attempt(attempt)?
2248 .destroy(attempt)
2249 }
2250
2251 fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2252 self.required_provider_for_attempt(attempt)?
2253 .recover(attempt)
2254 }
2255
2256 fn enumerate_owned(&self, host_id: HostId) -> Vec<EnvironmentIdentity> {
2257 let mut owned = self.oci.enumerate_owned(host_id);
2258 owned.extend(self.windows_hyper_v.enumerate_owned(host_id));
2259 owned
2260 }
2261
2262 fn diagnostics(&self, attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
2263 self.provider_for_attempt(attempt)
2264 .map_or_else(Vec::new, |provider| provider.diagnostics(attempt))
2265 }
2266
2267 fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2268 self.required_provider_for_attempt(attempt)?.owns(attempt)
2269 }
2270
2271 fn spawn(
2272 &self,
2273 attempt: &RunnerAttempt,
2274 config: &EncodedJitConfig,
2275 ) -> Result<u32, ProcessStartFailure> {
2276 if !attempt.execution().is_native() {
2277 return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2278 "native process launch refused for an isolated allocation".into(),
2279 )));
2280 }
2281 self.native.spawn(attempt, config)
2282 }
2283
2284 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2285 self.required_provider_for_attempt(attempt)?
2286 .is_alive(attempt)
2287 }
2288
2289 fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
2290 self.required_provider_for_attempt(attempt)?
2291 .recovered_pid(attempt)
2292 }
2293
2294 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
2295 self.provider_for_attempt(attempt)
2296 .is_some_and(|provider| provider.completed_successfully(attempt))
2297 }
2298
2299 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2300 self.required_provider_for_attempt(attempt)?
2301 .record_terminate_intent(attempt)
2302 }
2303
2304 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
2305 self.provider_for_attempt(attempt)
2306 .is_some_and(|provider| provider.has_terminate_intent(attempt))
2307 }
2308
2309 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2310 self.required_provider_for_attempt(attempt)?
2311 .terminate(attempt)
2312 }
2313}
2314
2315#[cfg(test)]
2316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2317enum PostSpawnBoundary {
2318 HandoffDelete,
2319 IdentitySerialize,
2320 IdentityWrite,
2321 ChildMapInsert,
2322}
2323
2324impl NativeProcesses {
2325 #[must_use]
2326 pub fn new() -> Self {
2327 Self::default()
2328 }
2329
2330 #[must_use]
2332 pub fn with_runner_env_file(mut self, path: PathBuf) -> Self {
2333 self.runner_env_file = Some(path);
2334 self
2335 }
2336
2337 #[must_use]
2339 pub fn with_dependency_caches(mut self, caches: DependencyCaches) -> Self {
2340 self.dependency_caches = Some(caches);
2341 self
2342 }
2343
2344 fn cache_env(
2346 &self,
2347 attempt: &RunnerAttempt,
2348 target: Option<&runner_manager_domain::model::ScaleTarget>,
2349 host_env: &RunnerEnv,
2350 ) -> Vec<(&'static str, std::ffi::OsString)> {
2351 match (&self.dependency_caches, target) {
2352 (Some(caches), Some(target)) => {
2353 caches.for_launch(target, attempt.runtime_path(), host_env)
2354 }
2355 _ => Vec::new(),
2356 }
2357 }
2358
2359 fn spawn_listener(
2362 &self,
2363 attempt: &RunnerAttempt,
2364 config: &EncodedJitConfig,
2365 cache_target: Option<&runner_manager_domain::model::ScaleTarget>,
2366 ) -> Result<u32, ProcessStartFailure> {
2367 let handoff = RestrictiveHandoff::create(
2368 attempt.runtime_path(),
2369 SecretString::from(config.expose().to_owned()),
2370 )
2371 .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
2372 #[cfg(windows)]
2373 let program = attempt
2374 .runtime_path()
2375 .join("bin")
2376 .join("Runner.Listener.exe");
2377 #[cfg(not(windows))]
2378 let program = attempt.runtime_path().join("bin").join("Runner.Listener");
2379 if !program.is_file() {
2382 return Err(ProcessStartFailure::before_spawn(
2383 FailureReason::ProcessStartFailed,
2384 ));
2385 }
2386 let host_env = self.host_env().map_err(ProcessStartFailure::before_spawn)?;
2387 let caches = self.cache_env(attempt, cache_target, &host_env);
2388 #[cfg(test)]
2389 let spec = if self
2390 .use_long_lived_test_listener
2391 .load(std::sync::atomic::Ordering::SeqCst)
2392 {
2393 SpawnSpec::new(program)
2394 .args([
2395 "--ignored",
2396 "--exact",
2397 "lifecycle::tests::long_lived_native_listener_helper",
2398 "--nocapture",
2399 ])
2400 .env(
2401 "RUNNER_MANAGER_TEST_LISTENER_READY",
2402 attempt.runtime_path().join(TEST_LISTENER_READY),
2403 )
2404 .working_dir(attempt.runtime_path())
2405 } else {
2406 runner_listener_spec(program, attempt.runtime_path(), caches, &host_env)
2407 };
2408 #[cfg(not(test))]
2409 let spec = runner_listener_spec(program, attempt.runtime_path(), caches, &host_env);
2410 let child = spec
2411 .spawn_runner_with_handoff(&handoff)
2412 .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
2413 #[cfg(test)]
2415 if self.faults_at(PostSpawnBoundary::HandoffDelete) {
2416 drop(handoff);
2417 return Err(self.abort_spawned_child(child, attempt, false));
2418 }
2419 if handoff.delete().is_err() {
2420 return Err(self.abort_spawned_child(child, attempt, false));
2421 }
2422 #[cfg(test)]
2423 if self.faults_at(PostSpawnBoundary::IdentitySerialize) {
2424 return Err(self.abort_spawned_child(child, attempt, false));
2425 }
2426 let identity = match serde_json::to_vec(child.identity()) {
2427 Ok(identity) => identity,
2428 Err(_) => {
2429 return Err(self.abort_spawned_child(child, attempt, false));
2430 }
2431 };
2432 if self.persist_identity(attempt, &identity).is_err() {
2433 return Err(self.abort_spawned_child(child, attempt, true));
2434 }
2435 let pid = child.pid();
2436 #[cfg(test)]
2437 if self.faults_at(PostSpawnBoundary::ChildMapInsert) {
2438 return Err(self.abort_spawned_child(child, attempt, true));
2439 }
2440 let mut children = self
2441 .children
2442 .lock()
2443 .unwrap_or_else(std::sync::PoisonError::into_inner);
2444 children.insert(attempt.id, child);
2445 Ok(pid)
2446 }
2447
2448 fn host_env(&self) -> Result<RunnerEnv, FailureReason> {
2456 let Some(path) = &self.runner_env_file else {
2457 return Ok(RunnerEnv::default());
2458 };
2459 RunnerEnv::load(path).map_err(|error| {
2460 tracing::warn!(
2461 reason = "runner_env_file_invalid",
2462 "runner.env could not be applied, so no runner starts until it is fixed: {error}"
2463 );
2464 FailureReason::Other(format!(
2465 "runner.env cannot be applied ({error}); fix it with `runner-manager host env`"
2466 ))
2467 })
2468 }
2469
2470 #[cfg(test)]
2471 fn fail_post_spawn_at(&self, boundary: PostSpawnBoundary) {
2472 self.post_spawn_faults.lock().unwrap().push_back(boundary);
2473 }
2474
2475 #[cfg(test)]
2476 fn faults_at(&self, boundary: PostSpawnBoundary) -> bool {
2477 let mut faults = self.post_spawn_faults.lock().unwrap();
2478 if faults.front() == Some(&boundary) {
2479 faults.pop_front();
2480 true
2481 } else {
2482 false
2483 }
2484 }
2485
2486 #[cfg(test)]
2487 fn fail_post_spawn_stops(&self, count: usize) {
2488 self.post_spawn_stop_failures
2489 .fetch_add(count, std::sync::atomic::Ordering::SeqCst);
2490 }
2491
2492 #[cfg(test)]
2493 fn fail_next_post_spawn_stop(&self) {
2494 self.fail_post_spawn_stops(1);
2495 }
2496
2497 #[cfg(test)]
2498 fn use_long_lived_test_listener(&self) {
2499 self.use_long_lived_test_listener
2500 .store(true, std::sync::atomic::Ordering::SeqCst);
2501 }
2502
2503 fn stop_spawned_child(&self, child: &mut ChildProcess) -> Result<(), FailureReason> {
2504 #[cfg(test)]
2505 if self
2506 .post_spawn_stop_failures
2507 .fetch_update(
2508 std::sync::atomic::Ordering::SeqCst,
2509 std::sync::atomic::Ordering::SeqCst,
2510 |left| if left > 0 { Some(left - 1) } else { None },
2511 )
2512 .is_ok()
2513 {
2514 return Err(FailureReason::Other("injected runner stop failure".into()));
2515 }
2516 child
2517 .stop(Duration::from_secs(1))
2518 .map(|_| ())
2519 .map_err(|_| FailureReason::Other("spawned runner process could not be stopped".into()))
2520 }
2521
2522 fn abort_spawned_child(
2523 &self,
2524 mut child: ChildProcess,
2525 attempt: &RunnerAttempt,
2526 remove_identity: bool,
2527 ) -> ProcessStartFailure {
2528 let mut reaped = self.stop_spawned_child(&mut child).is_ok();
2529 if reaped {
2530 if remove_identity {
2531 Self::remove_identity_files(attempt);
2532 }
2533 } else {
2534 let identity_durable =
2537 serde_json::to_vec(child.identity())
2538 .ok()
2539 .is_some_and(|identity| {
2540 self.persist_identity(attempt, &identity).is_ok()
2541 || self.persist_fallback_identity(attempt, &identity).is_ok()
2542 });
2543 if !identity_durable {
2544 for _ in 1..MAX_POST_SPAWN_STOP_ATTEMPTS {
2549 if self.stop_spawned_child(&mut child).is_ok() {
2550 reaped = true;
2551 break;
2552 }
2553 }
2554 if !reaped {
2555 let pid = child.pid();
2560 let marker = write_durable_file(
2561 &Self::unresolved_process_path(attempt),
2562 pid.to_string().as_bytes(),
2563 );
2564 self.children
2565 .lock()
2566 .unwrap_or_else(std::sync::PoisonError::into_inner)
2567 .insert(attempt.id, child);
2568 let reason = if marker.is_ok() {
2569 FailureReason::Other(
2570 "spawn cleanup exhausted its bounded stop attempts; the live process remains under durable unresolved supervision"
2571 .into(),
2572 )
2573 } else {
2574 FailureReason::Other(
2575 "spawn cleanup exhausted its bounded stop attempts and the unresolved-process marker could not be journalled"
2576 .into(),
2577 )
2578 };
2579 return ProcessStartFailure::after_spawn_live_with_reason(pid, reason);
2580 }
2581 } else {
2582 let pid = child.pid();
2583 self.children
2584 .lock()
2585 .unwrap_or_else(std::sync::PoisonError::into_inner)
2586 .insert(attempt.id, child);
2587 return ProcessStartFailure::after_spawn_live(pid);
2588 }
2589 }
2590 #[cfg(test)]
2591 if reaped {
2592 self.post_spawn_reaps
2593 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2594 }
2595 #[cfg(not(test))]
2596 let _ = reaped;
2597 ProcessStartFailure::after_spawn_stopped()
2598 }
2599
2600 fn identity_path(attempt: &RunnerAttempt) -> PathBuf {
2601 attempt.runtime_path().join(IDENTITY_FILE)
2602 }
2603
2604 fn fallback_identity_path(attempt: &RunnerAttempt) -> PathBuf {
2605 attempt.runtime_path().join(FALLBACK_IDENTITY_FILE)
2606 }
2607
2608 fn unresolved_process_path(attempt: &RunnerAttempt) -> PathBuf {
2609 attempt.runtime_path().join(UNRESOLVED_PROCESS_FILE)
2610 }
2611
2612 fn remove_identity_files(attempt: &RunnerAttempt) {
2613 let _ = fs::remove_file(Self::identity_path(attempt));
2614 let _ = fs::remove_file(Self::fallback_identity_path(attempt));
2615 let _ = fs::remove_file(Self::unresolved_process_path(attempt));
2616 }
2617
2618 fn persist_identity(&self, attempt: &RunnerAttempt, bytes: &[u8]) -> std::io::Result<()> {
2619 self.persist_identity_at(&Self::identity_path(attempt), bytes)
2620 }
2621
2622 fn persist_fallback_identity(
2623 &self,
2624 attempt: &RunnerAttempt,
2625 bytes: &[u8],
2626 ) -> std::io::Result<()> {
2627 self.persist_identity_at(&Self::fallback_identity_path(attempt), bytes)
2628 }
2629
2630 fn persist_identity_at(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
2631 #[cfg(test)]
2632 if self.faults_at(PostSpawnBoundary::IdentityWrite) {
2633 return Err(std::io::Error::other("injected identity write failure"));
2634 }
2635 write_durable_file(path, bytes)
2636 }
2637
2638 fn intent_path(attempt: &RunnerAttempt) -> PathBuf {
2639 attempt.runtime_path().join(TERMINATE_INTENT_FILE)
2640 }
2641
2642 fn read_identity(attempt: &RunnerAttempt) -> Result<Option<ProcessIdentity>, FailureReason> {
2643 match Self::read_identity_at(&Self::identity_path(attempt))? {
2644 Some(identity) => Ok(Some(identity)),
2645 None => Self::read_identity_at(&Self::fallback_identity_path(attempt)),
2646 }
2647 }
2648
2649 fn read_identity_at(path: &Path) -> Result<Option<ProcessIdentity>, FailureReason> {
2650 match fs::read(path) {
2651 Ok(bytes) => serde_json::from_slice(&bytes)
2652 .map(Some)
2653 .map_err(|_| FailureReason::Other("process identity journal is unreadable".into())),
2654 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2655 Err(_) => Err(FailureReason::Other(
2656 "process identity journal could not be read".into(),
2657 )),
2658 }
2659 }
2660}
2661
2662impl ProcessSupervisor for NativeProcesses {
2663 fn prepare(
2664 &self,
2665 attempt: &RunnerAttempt,
2666 policy: &ScalePolicy,
2667 ) -> Result<PreparedEnvironment, FailureReason> {
2668 if !attempt.execution().is_native() || !policy.execution_policy().is_native() {
2669 return Err(FailureReason::Other(
2670 "execution provider unavailable".into(),
2671 ));
2672 }
2673 self.host_env()?;
2674 let cache_target = match &self.dependency_caches {
2677 Some(caches) => {
2678 caches.config()?;
2679 Some(policy.target.clone())
2680 }
2681 None => None,
2682 };
2683 Ok(PreparedEnvironment {
2684 attempt: attempt.id,
2685 identity: None,
2686 cache_target,
2687 })
2688 }
2689
2690 fn start(
2691 &self,
2692 prepared: PreparedEnvironment,
2693 attempt: &RunnerAttempt,
2694 handoff: OneTimeJitHandoff<'_>,
2695 ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2696 if prepared.attempt != attempt.id || !attempt.execution().is_native() {
2697 return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2698 "execution provider identity mismatch".into(),
2699 )));
2700 }
2701 self.spawn_listener(attempt, handoff.consume(), prepared.cache_target.as_ref())
2702 .map(EnvironmentIdentity::NativeProcess)
2703 }
2704
2705 fn spawn(
2706 &self,
2707 attempt: &RunnerAttempt,
2708 config: &EncodedJitConfig,
2709 ) -> Result<u32, ProcessStartFailure> {
2710 self.spawn_listener(attempt, config, None)
2711 }
2712
2713 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2714 let mut children = self
2715 .children
2716 .lock()
2717 .unwrap_or_else(std::sync::PoisonError::into_inner);
2718 if let Some(child) = children.get_mut(&attempt.id) {
2719 return match child
2720 .try_exit_status()
2721 .map_err(|_| FailureReason::Other("runner process could not be observed".into()))?
2722 {
2723 None => Ok(true),
2724 Some(status) => {
2725 if let Ok(mut exits) = self.successful_exits.lock() {
2726 exits.insert(attempt.id, status.success());
2727 }
2728 Ok(false)
2729 }
2730 };
2731 }
2732 let Some(identity) = Self::read_identity(attempt)? else {
2733 if attempt.process_id().is_some() || Self::unresolved_process_path(attempt).is_file() {
2734 return Err(FailureReason::Other(
2735 "runner process identity is missing; refusing recovery until the process is resolved"
2736 .into(),
2737 ));
2738 }
2739 return Ok(false);
2740 };
2741 match identity.recheck() {
2742 Ok(Adoption::Live) => Ok(true),
2743 Ok(Adoption::Gone | Adoption::PidRecycled { .. }) => Ok(false),
2744 Err(_) => Ok(false),
2745 }
2746 }
2747
2748 fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
2749 Ok(Self::read_identity(attempt)?.map(|identity| identity.pid()))
2750 }
2751
2752 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
2753 self.successful_exits
2754 .lock()
2755 .ok()
2756 .and_then(|exits| exits.get(&attempt.id).copied())
2757 .unwrap_or(false)
2758 }
2759
2760 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2761 let path = Self::intent_path(attempt);
2762 write_durable_file(&path, b"registration-timeout\n")
2763 .map_err(|_| FailureReason::Other("terminate intent could not be journalled".into()))
2764 }
2765
2766 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
2767 Self::intent_path(attempt).is_file()
2768 }
2769
2770 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2771 let mut children = self
2772 .children
2773 .lock()
2774 .unwrap_or_else(std::sync::PoisonError::into_inner);
2775 if let Some(child) = children.get_mut(&attempt.id) {
2776 child
2777 .stop(Duration::from_secs(10))
2778 .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?;
2779 return Ok(());
2780 }
2781 let Some(identity) = Self::read_identity(attempt)? else {
2782 return Ok(());
2783 };
2784 match identity
2785 .terminate(Duration::from_secs(10))
2786 .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?
2787 {
2788 Termination::Terminated | Termination::AlreadyGone => Ok(()),
2789 Termination::RefusedPidRecycled { .. } => Err(FailureReason::Other(
2790 "runner PID was recycled; refusing to signal it".into(),
2791 )),
2792 }
2793 }
2794}
2795
2796pub struct LifecyclePorts {
2797 pub store: Arc<dyn Store>,
2798 pub github: Arc<dyn LifecycleGithub>,
2799 pub packages: Arc<dyn RuntimePackages>,
2800 pub processes: Arc<dyn ExecutionProvider>,
2801 pub clock: Arc<dyn Clock>,
2802 pub demand: Arc<dyn DemandPersistence>,
2803 pub delay: Arc<dyn RetryDelay>,
2804 pub events: Arc<dyn AttemptEventSink>,
2805 pub reconcile_events: Arc<dyn EventSink>,
2806}
2807
2808impl fmt::Debug for LifecyclePorts {
2809 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2810 f.debug_struct("LifecyclePorts")
2811 .field("store", &self.store)
2812 .field("github", &self.github)
2813 .field("packages", &self.packages)
2814 .field("processes", &self.processes)
2815 .finish_non_exhaustive()
2816 }
2817}
2818
2819#[derive(Debug, thiserror::Error)]
2820pub enum LifecycleError {
2821 #[error("attempt journal operation failed")]
2822 Journal,
2823 #[error("attempt {0} is not in the journal")]
2824 Missing(AttemptId),
2825 #[error("attempt lifecycle transition was refused")]
2826 Transition,
2827 #[error("startup recovery has not completed")]
2828 RecoveryIncomplete,
2829 #[error("the persistent slot was not cleaned: {detail}")]
2839 SlotQuarantined {
2840 class: &'static str,
2842 detail: String,
2843 },
2844 #[error("{}", FailureReason::WorkspaceCleanupDeferred)]
2848 WorkspaceCleanupDeferred,
2849 #[error("isolated environment cleanup is deferred")]
2850 EnvironmentCleanupDeferred,
2851 #[error("runner lifecycle failed: {0}")]
2852 Failed(FailureReason),
2853}
2854
2855impl LifecycleError {
2856 fn reason(&self) -> FailureReason {
2857 match self {
2858 Self::Failed(reason) => reason.clone(),
2859 Self::RecoveryIncomplete => FailureReason::Other("startup recovery incomplete".into()),
2860 Self::Journal => FailureReason::Other("attempt journal operation failed".into()),
2861 Self::Missing(_) => FailureReason::Other("attempt disappeared from the journal".into()),
2862 Self::Transition => FailureReason::Other("attempt transition was refused".into()),
2863 Self::SlotQuarantined { .. } => FailureReason::Other(self.to_string()),
2866 Self::WorkspaceCleanupDeferred => FailureReason::WorkspaceCleanupDeferred,
2867 Self::EnvironmentCleanupDeferred => FailureReason::Other(self.to_string()),
2868 }
2869 }
2870}
2871
2872#[derive(Debug)]
2874pub struct LifecycleLauncher {
2875 host_id: HostId,
2876 app_paths: runner_manager_platform::paths::AppPaths,
2877 diagnostics_root: PathBuf,
2878 runner_group_id: u64,
2879 timeouts: RecoveryTimeouts,
2880 retry: RetryPolicy,
2881 cancel: CancelToken,
2882 ports: LifecyclePorts,
2883 recovery_complete: Mutex<bool>,
2884 versions: Mutex<BTreeMap<AttemptId, RunnerVersion>>,
2885 pending_replacements: Mutex<BTreeMap<AttemptId, ReplacementIntent>>,
2886 cleanup_retries: Mutex<CleanBackoff>,
2890}
2891
2892#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2893enum ReconcileProgress {
2894 Reconciled,
2895 Deferred,
2896 Replacement {
2897 attempt: AttemptId,
2898 operation: &'static str,
2899 },
2900}
2901
2902impl LifecycleLauncher {
2903 #[must_use]
2904 pub fn new(
2905 host_id: HostId,
2906 app_paths: runner_manager_platform::paths::AppPaths,
2907 diagnostics_root: impl Into<PathBuf>,
2908 runner_group_id: u64,
2909 timeouts: RecoveryTimeouts,
2910 retry: RetryPolicy,
2911 ports: LifecyclePorts,
2912 ) -> Self {
2913 Self {
2914 host_id,
2915 app_paths,
2916 diagnostics_root: diagnostics_root.into(),
2917 runner_group_id,
2918 timeouts,
2919 retry,
2920 cancel: CancelToken::new(),
2921 ports,
2922 recovery_complete: Mutex::new(false),
2923 versions: Mutex::new(BTreeMap::new()),
2924 pending_replacements: Mutex::new(BTreeMap::new()),
2925 cleanup_retries: Mutex::new(CleanBackoff::default()),
2926 }
2927 }
2928
2929 pub async fn recover_startup(
2933 &self,
2934 policies: &[ScalePolicy],
2935 ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
2936 *self
2937 .recovery_complete
2938 .lock()
2939 .map_err(|_| LifecycleError::Journal)? = false;
2940 let by_id: BTreeMap<_, _> = policies.iter().map(|policy| (policy.id, policy)).collect();
2941 let attempts = self
2942 .ports
2943 .store
2944 .attempts()
2945 .map_err(|_| LifecycleError::Journal)?;
2946 let owned = self.ports.processes.enumerate_owned(self.host_id);
2947 for identity in &owned {
2948 if let EnvironmentIdentity::Isolated { attempt: id, .. } = identity
2949 && !attempts.iter().any(|attempt| {
2950 attempt.state() != AttemptState::Cleaned
2951 && identity_matches_intent(self.host_id, attempt, identity)
2952 })
2953 {
2954 self.ports
2956 .events
2957 .emit(AttemptEvent::QuarantinedOwnedResource { attempt: *id });
2958 tracing::warn!(host_id = %self.host_id, attempt_id = %id, reason = "owned_isolated_resource_without_matching_journal", "provider resource quarantined for operator review");
2959 }
2960 }
2961 let mut unresolved = false;
2962 for mut attempt in attempts {
2963 let Some(policy) = by_id.get(&attempt.policy_id) else {
2964 if attempt.counts_against_capacity() {
2965 unresolved = true;
2966 }
2967 continue;
2968 };
2969 authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
2970 let allocation_resources = owned
2971 .iter()
2972 .filter(|identity| identity_same_allocation(self.host_id, &attempt, identity))
2973 .collect::<Vec<_>>();
2974 if allocation_resources.len() > 1 {
2975 self.ports
2976 .events
2977 .emit(AttemptEvent::QuarantinedOwnedResource {
2978 attempt: attempt.id,
2979 });
2980 tracing::warn!(attempt_id = %attempt.id, reason = "duplicate_isolated_resource_ownership", "provider resources quarantined for operator review");
2981 }
2982 if attempt.state() != AttemptState::Cleaned
2983 && matches!(
2984 attempt.execution(),
2985 AttemptExecution::Isolated {
2986 environment_id: None,
2987 ..
2988 }
2989 )
2990 && allocation_resources.len() == 1
2991 && let Some(EnvironmentIdentity::Isolated { environment_id, .. }) =
2992 allocation_resources.first().copied()
2993 {
2994 attempt
2995 .prepared_environment(environment_id.clone())
2996 .map_err(|_| LifecycleError::Transition)?;
2997 if attempt.state() == AttemptState::Preparing {
2998 attempt
2999 .mark_prepared(self.ports.clock.now())
3000 .map_err(|_| LifecycleError::Transition)?;
3001 }
3002 self.record(&attempt)?;
3003 }
3004 match self.reconcile_one(policy, attempt).await? {
3005 ReconcileProgress::Deferred => {
3006 if policy.execution_policy().is_native() {
3007 unresolved = true;
3008 }
3009 }
3010 ReconcileProgress::Replacement { attempt, operation } => {
3011 self.pending_replacements
3012 .lock()
3013 .map_err(|_| LifecycleError::Journal)?
3014 .insert(
3015 attempt,
3016 ReplacementIntent {
3017 policy: policy.id,
3018 previous_attempt: attempt,
3019 operation,
3020 },
3021 );
3022 }
3023 ReconcileProgress::Reconciled => {}
3024 }
3025 }
3026 if unresolved {
3027 return Err(LifecycleError::RecoveryIncomplete);
3028 }
3029 *self
3030 .recovery_complete
3031 .lock()
3032 .map_err(|_| LifecycleError::Journal)? = true;
3033 Ok(self
3034 .pending_replacements
3035 .lock()
3036 .map_err(|_| LifecycleError::Journal)?
3037 .values()
3038 .copied()
3039 .collect())
3040 }
3041
3042 pub async fn supervise(
3044 &self,
3045 policy: &ScalePolicy,
3046 ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
3047 let mut replacements = Vec::new();
3048 self.pending_replacements
3049 .lock()
3050 .map_err(|_| LifecycleError::Journal)?
3051 .retain(|_, intent| {
3052 if intent.policy == policy.id {
3053 replacements.push(*intent);
3054 false
3055 } else {
3056 true
3057 }
3058 });
3059 let attempts = self
3060 .ports
3061 .store
3062 .attempts_for_policy(policy.id)
3063 .map_err(|_| LifecycleError::Journal)?;
3064 for attempt in attempts {
3065 authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
3066 if let ReconcileProgress::Replacement { attempt, operation } =
3067 self.reconcile_one(policy, attempt).await?
3068 {
3069 replacements.push(ReplacementIntent {
3070 policy: policy.id,
3071 previous_attempt: attempt,
3072 operation,
3073 });
3074 }
3075 }
3076 Ok(replacements)
3077 }
3078
3079 async fn reconcile_one(
3080 &self,
3081 policy: &ScalePolicy,
3082 mut attempt: RunnerAttempt,
3083 ) -> Result<ReconcileProgress, LifecycleError> {
3084 if !attempt.execution().is_native() {
3085 return self.reconcile_isolated_one(policy, attempt).await;
3086 }
3087 if attempt.state() == AttemptState::Cleaned {
3088 if matches!(attempt.workspace(), AttemptWorkspace::Ephemeral)
3097 && attempt.runtime_path().exists()
3098 {
3099 let now = self.ports.clock.now();
3107 if !self.cleanup_retries().is_due(attempt.id, now) {
3108 return Ok(ReconcileProgress::Reconciled);
3109 }
3110 match self.scrub_workspace(&attempt) {
3111 Ok(()) => self.cleanup_retries().succeeded(attempt.id),
3112 Err(LifecycleError::WorkspaceCleanupDeferred) => {
3113 self.cleanup_retries().failed(attempt.id, now);
3114 self.ports
3115 .reconcile_events
3116 .emit(LifecycleEvent::AttemptCleanFailed {
3117 policy: attempt.policy_id,
3118 attempt: attempt.id,
3119 reason: "late_ephemeral_workspace_could_not_be_removed",
3120 });
3121 }
3122 Err(error) => return Err(error),
3123 }
3124 }
3125 return Ok(ReconcileProgress::Reconciled);
3126 }
3127 if attempt.is_terminal() {
3128 self.clean_or_quarantine(&mut attempt)?;
3129 return Ok(ReconcileProgress::Reconciled);
3130 }
3131 let process_alive = self
3132 .ports
3133 .processes
3134 .inspect(&attempt)
3135 .map_err(LifecycleError::Failed)?
3136 == EnvironmentState::Running;
3137 let github = self
3138 .ports
3139 .github
3140 .observe(&policy.target, attempt.id, &self.cancel)
3141 .await;
3142
3143 if let Some(runner_id) = github.runner_id
3148 && read_runner_id(attempt.runtime_path()).is_none()
3149 {
3150 write_runner_id(attempt.runtime_path(), runner_id)?;
3151 self.ports
3152 .events
3153 .emit(AttemptEvent::RemoteIdentityRecovered {
3154 attempt: attempt.id,
3155 runner_id,
3156 });
3157 }
3158
3159 if attempt.state() == AttemptState::JitReceived
3164 && process_alive
3165 && let Some(pid) = self
3166 .ports
3167 .processes
3168 .recovered_pid(&attempt)
3169 .map_err(LifecycleError::Failed)?
3170 {
3171 attempt
3172 .started(pid, self.ports.clock.now())
3173 .map_err(|_| LifecycleError::Transition)?;
3174 self.record(&attempt)?;
3175 }
3176
3177 if !process_alive
3190 && github.status != GithubRunnerObservation::Unreachable
3191 && matches!(
3192 attempt.state(),
3193 AttemptState::Starting | AttemptState::Idle | AttemptState::Busy
3194 )
3195 && self.ports.processes.completed_successfully(&attempt)
3196 {
3197 let state = attempt.state();
3198 let outcome = match (state, JobEvidence::of(attempt.runtime_path())) {
3199 (AttemptState::Starting | AttemptState::Idle, JobEvidence::RanAJob) => {
3202 known_runner_id(&attempt, github.runner_id)
3203 .map(|runner_id| (Some(runner_id), AttemptOutcome::CompletedJob))
3204 }
3205 (AttemptState::Busy, JobEvidence::RanAJob) => {
3206 Some((None, AttemptOutcome::CompletedJob))
3207 }
3208 (AttemptState::Busy, JobEvidence::NoJob) => {
3209 Some((None, AttemptOutcome::ExitedIdleWithoutWork))
3210 }
3211 (AttemptState::Busy, JobEvidence::Unknown)
3212 if github.status == GithubRunnerObservation::NotRegistered =>
3213 {
3214 Some((None, AttemptOutcome::CompletedJob))
3215 }
3216 _ => None,
3217 };
3218 if let Some((walk_to_busy, outcome)) = outcome {
3219 if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3226 self.deregister_runner(policy, &attempt).await;
3227 }
3228 let now = self.ports.clock.now();
3229 match (walk_to_busy, &outcome) {
3230 (Some(runner_id), _) => {
3231 self.transition(&mut attempt, |a| a.assigned_job(runner_id, now))?;
3232 }
3233 (None, AttemptOutcome::ExitedIdleWithoutWork) => {
3234 self.transition(&mut attempt, |a| a.assignment_not_taken(now))?;
3235 }
3236 (None, _) => {}
3237 }
3238 self.conclude(&mut attempt, outcome)?;
3239 self.clean_or_quarantine(&mut attempt)?;
3240 return Ok(ReconcileProgress::Reconciled);
3241 }
3242 }
3243
3244 if process_alive
3249 && attempt.state() == AttemptState::Busy
3250 && github.status == (GithubRunnerObservation::Registered { busy: false })
3251 && JobEvidence::of(attempt.runtime_path()) == JobEvidence::NoJob
3252 {
3253 let now = self.ports.clock.now();
3254 self.transition(&mut attempt, |a| a.assignment_not_taken(now))?;
3255 return Ok(ReconcileProgress::Reconciled);
3256 }
3257
3258 if self.ports.processes.has_terminate_intent(&attempt) && !process_alive {
3261 self.deregister_runner(policy, &attempt).await;
3262 self.conclude(
3263 &mut attempt,
3264 AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout),
3265 )?;
3266 self.clean_or_quarantine(&mut attempt)?;
3267 return Ok(ReconcileProgress::Replacement {
3268 attempt: attempt.id,
3269 operation: "registration_timeout_replacement",
3270 });
3271 }
3272
3273 if matches!(
3279 attempt.state(),
3280 AttemptState::Allocated | AttemptState::JitReceived
3281 ) && !process_alive
3282 && matches!(github.status, GithubRunnerObservation::Registered { .. })
3283 {
3284 if attempt.state() == AttemptState::Allocated {
3285 attempt
3286 .jit_received(self.ports.clock.now())
3287 .map_err(|_| LifecycleError::Transition)?;
3288 self.record(&attempt)?;
3289 }
3290 self.deregister_runner(policy, &attempt).await;
3291 self.conclude(
3292 &mut attempt,
3293 AttemptOutcome::failed(FailureReason::JitExpired),
3294 )?;
3295 self.clean_or_quarantine(&mut attempt)?;
3296 return Ok(ReconcileProgress::Replacement {
3297 attempt: attempt.id,
3298 operation: "jit_expired_replacement",
3299 });
3300 }
3301
3302 match recovery_decision(
3303 &attempt,
3304 RecoveryObservation {
3305 process_alive,
3306 github: github.status,
3307 },
3308 self.timeouts,
3309 self.ports.clock.as_ref(),
3310 ) {
3311 RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
3312 RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
3313 RecoveryDecision::Adopt => {
3314 self.ports.events.emit(AttemptEvent::Adopted {
3315 attempt: attempt.id,
3316 });
3317 Ok(ReconcileProgress::Reconciled)
3318 }
3319 RecoveryDecision::Clean => {
3320 self.clean_or_quarantine(&mut attempt)?;
3321 Ok(ReconcileProgress::Reconciled)
3322 }
3323 RecoveryDecision::Observe(state) => {
3324 let runner_id = known_runner_id(&attempt, github.runner_id)
3325 .ok_or(LifecycleError::Transition)?;
3326 match state {
3327 AttemptState::JitReceived => attempt
3328 .jit_received(self.ports.clock.now())
3329 .map_err(|_| LifecycleError::Transition)?,
3330 AttemptState::Starting => {
3331 let pid = attempt.process_id().ok_or(LifecycleError::Transition)?;
3332 attempt
3333 .started(pid, self.ports.clock.now())
3334 .map_err(|_| LifecycleError::Transition)?;
3335 }
3336 AttemptState::Idle => attempt
3337 .registered_idle(runner_id, self.ports.clock.now())
3338 .map_err(|_| LifecycleError::Transition)?,
3339 AttemptState::Busy => attempt
3340 .assigned_job(runner_id, self.ports.clock.now())
3341 .map_err(|_| LifecycleError::Transition)?,
3342 _ => return Err(LifecycleError::Transition),
3343 }
3344 self.record(&attempt)?;
3345 Ok(ReconcileProgress::Reconciled)
3346 }
3347 RecoveryDecision::Conclude(outcome) => {
3348 let replacement = replacement_operation(&outcome);
3349 if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3354 self.deregister_runner(policy, &attempt).await;
3355 }
3356 self.conclude(&mut attempt, outcome)?;
3357 self.clean_or_quarantine(&mut attempt)?;
3358 Ok(
3359 replacement.map_or(ReconcileProgress::Reconciled, |operation| {
3360 ReconcileProgress::Replacement {
3361 attempt: attempt.id,
3362 operation,
3363 }
3364 }),
3365 )
3366 }
3367 RecoveryDecision::Terminate(payload) => {
3368 let idle_exit = payload.is_idle_exit();
3378 self.ports
3379 .processes
3380 .record_terminate_intent(&attempt)
3381 .map_err(LifecycleError::Failed)?;
3382 self.ports.events.emit(AttemptEvent::TerminateIntent {
3383 attempt: attempt.id,
3384 });
3385 if self
3386 .ports
3387 .processes
3388 .stop(&attempt)
3389 .map_err(LifecycleError::Failed)?
3390 == ProviderStop::StillRunning
3391 {
3392 return Ok(ReconcileProgress::Deferred);
3393 }
3394 if self
3395 .ports
3396 .processes
3397 .inspect(&attempt)
3398 .map_err(LifecycleError::Failed)?
3399 == EnvironmentState::Running
3400 {
3401 return Ok(ReconcileProgress::Deferred);
3402 }
3403 self.ports.events.emit(AttemptEvent::Terminated {
3404 attempt: attempt.id,
3405 });
3406 let outcome = if idle_exit {
3412 AttemptOutcome::ExitedIdleWithoutWork
3413 } else {
3414 AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout)
3415 };
3416 self.deregister_runner(policy, &attempt).await;
3417 self.conclude(&mut attempt, outcome)?;
3418 self.clean_or_quarantine(&mut attempt)?;
3419 if idle_exit {
3423 Ok(ReconcileProgress::Reconciled)
3424 } else {
3425 Ok(ReconcileProgress::Replacement {
3426 attempt: attempt.id,
3427 operation: "registration_timeout_replacement",
3428 })
3429 }
3430 }
3431 }
3432 }
3433
3434 async fn reconcile_isolated_one(
3435 &self,
3436 policy: &ScalePolicy,
3437 mut attempt: RunnerAttempt,
3438 ) -> Result<ReconcileProgress, LifecycleError> {
3439 if attempt.state() == AttemptState::Cleaned {
3440 return Ok(ReconcileProgress::Reconciled);
3441 }
3442 if attempt.is_terminal() {
3443 self.clean_or_quarantine(&mut attempt)?;
3444 return Ok(ReconcileProgress::Reconciled);
3445 }
3446 if matches!(
3447 attempt.state(),
3448 AttemptState::Allocated | AttemptState::Preparing | AttemptState::Prepared
3449 ) {
3450 self.conclude(
3453 &mut attempt,
3454 AttemptOutcome::failed(FailureReason::Other(
3455 "isolated preparation interrupted".into(),
3456 )),
3457 )?;
3458 self.clean_or_quarantine(&mut attempt)?;
3459 return Ok(ReconcileProgress::Reconciled);
3460 }
3461 let Ok(provider_state) = self.ports.processes.recover(&attempt) else {
3462 return Ok(ReconcileProgress::Deferred);
3463 };
3464 if provider_state != EnvironmentState::Missing {
3465 let Ok(owned) = self.ports.processes.owns(&attempt) else {
3466 return Ok(ReconcileProgress::Deferred);
3467 };
3468 if !owned {
3469 self.ports
3470 .events
3471 .emit(AttemptEvent::QuarantinedOwnedResource {
3472 attempt: attempt.id,
3473 });
3474 tracing::warn!(attempt_id = %attempt.id, reason = "isolated_resource_ownership_mismatch", "provider resource quarantined for operator review");
3475 return Ok(ReconcileProgress::Deferred);
3476 }
3477 }
3478 let live = matches!(
3479 provider_state,
3480 EnvironmentState::Starting | EnvironmentState::Running
3481 );
3482 if attempt.state() == AttemptState::JitReceived && live {
3483 attempt
3484 .started_isolated(self.ports.clock.now())
3485 .map_err(|_| LifecycleError::Transition)?;
3486 self.record(&attempt)?;
3487 }
3488 let github = self
3489 .ports
3490 .github
3491 .observe(&policy.target, attempt.id, &self.cancel)
3492 .await;
3493 if let Some(runner_id) = github.runner_id
3494 && read_runner_id(attempt.runtime_path()).is_none()
3495 {
3496 write_runner_id(attempt.runtime_path(), runner_id)?;
3497 }
3498 if attempt.state() == AttemptState::Busy
3502 && !live
3503 && github.status == GithubRunnerObservation::NotRegistered
3504 && self.ports.processes.completed_successfully(&attempt)
3505 {
3506 self.conclude(&mut attempt, AttemptOutcome::CompletedJob)?;
3507 self.clean_or_quarantine(&mut attempt)?;
3508 return Ok(ReconcileProgress::Reconciled);
3509 }
3510 match recovery_decision(
3511 &attempt,
3512 RecoveryObservation {
3513 process_alive: live,
3514 github: github.status,
3515 },
3516 self.timeouts,
3517 self.ports.clock.as_ref(),
3518 ) {
3519 RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
3520 RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
3521 RecoveryDecision::Adopt => {
3522 self.ports.events.emit(AttemptEvent::Adopted {
3523 attempt: attempt.id,
3524 });
3525 Ok(ReconcileProgress::Reconciled)
3526 }
3527 RecoveryDecision::Clean => {
3528 self.clean_or_quarantine(&mut attempt)?;
3529 Ok(ReconcileProgress::Reconciled)
3530 }
3531 RecoveryDecision::Observe(state) => {
3532 let runner_id = github
3533 .runner_id
3534 .or_else(|| read_runner_id(attempt.runtime_path()))
3535 .ok_or(LifecycleError::Transition)?;
3536 match state {
3537 AttemptState::Idle => attempt
3538 .registered_idle(runner_id, self.ports.clock.now())
3539 .map_err(|_| LifecycleError::Transition)?,
3540 AttemptState::Busy => attempt
3541 .assigned_job(runner_id, self.ports.clock.now())
3542 .map_err(|_| LifecycleError::Transition)?,
3543 _ => return Err(LifecycleError::Transition),
3544 }
3545 self.record(&attempt)?;
3546 Ok(ReconcileProgress::Reconciled)
3547 }
3548 RecoveryDecision::Conclude(outcome) | RecoveryDecision::Terminate(outcome) => {
3549 let replacement = replacement_operation(&outcome);
3550 if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3551 self.deregister_runner(policy, &attempt).await;
3552 }
3553 self.conclude(&mut attempt, outcome)?;
3554 self.clean_or_quarantine(&mut attempt)?;
3555 Ok(
3556 replacement.map_or(ReconcileProgress::Reconciled, |operation| {
3557 ReconcileProgress::Replacement {
3558 attempt: attempt.id,
3559 operation,
3560 }
3561 }),
3562 )
3563 }
3564 }
3565 }
3566
3567 fn transition(
3569 &self,
3570 attempt: &mut RunnerAttempt,
3571 edge: impl FnOnce(
3572 &mut RunnerAttempt,
3573 ) -> Result<(), runner_manager_domain::attempt::AttemptError>,
3574 ) -> Result<(), LifecycleError> {
3575 edge(attempt).map_err(|_| LifecycleError::Transition)?;
3576 self.record(attempt)
3577 }
3578
3579 fn record(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
3580 self.ports
3581 .store
3582 .record_attempt(attempt)
3583 .map_err(|_| LifecycleError::Journal)?;
3584 self.ports.events.emit(AttemptEvent::State {
3585 attempt: attempt.id,
3586 state: attempt.state(),
3587 });
3588 Ok(())
3589 }
3590
3591 async fn deregister_runner(&self, policy: &ScalePolicy, attempt: &RunnerAttempt) {
3612 let Some(runner_id) = attempt
3613 .github_runner_id()
3614 .or_else(|| read_runner_id(attempt.runtime_path()))
3615 else {
3616 return;
3617 };
3618 if self
3619 .ports
3620 .github
3621 .deregister(&policy.target, runner_id, &self.cancel)
3622 .await
3623 {
3624 self.ports.events.emit(AttemptEvent::Deregistered {
3625 attempt: attempt.id,
3626 runner_id,
3627 });
3628 } else {
3629 tracing::warn!(
3630 attempt = %attempt.id,
3631 runner_id,
3632 "the runner registration could not be removed from GitHub; it will show in the \
3633 target's runner settings until GitHub retires it or a later pass removes it"
3634 );
3635 }
3636 }
3637
3638 fn conclude(
3639 &self,
3640 attempt: &mut RunnerAttempt,
3641 outcome: AttemptOutcome,
3642 ) -> Result<(), LifecycleError> {
3643 attempt
3644 .conclude(outcome.clone(), self.ports.clock.now())
3645 .map_err(|_| LifecycleError::Transition)?;
3646 self.record(attempt)?;
3647 self.ports.events.emit(AttemptEvent::Concluded {
3648 attempt: attempt.id,
3649 outcome: OutcomeKind::of(&outcome),
3650 });
3651 Ok(())
3652 }
3653
3654 fn clean_or_quarantine(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
3674 let now = self.ports.clock.now();
3675 if !self.cleanup_retries().is_due(attempt.id, now) {
3676 return Ok(());
3677 }
3678 let reason = match self.clean_attempt(attempt) {
3679 Err(LifecycleError::SlotQuarantined { class, .. }) => class,
3680 Err(LifecycleError::WorkspaceCleanupDeferred) => {
3681 failure_reason_kind(&FailureReason::WorkspaceCleanupDeferred)
3682 }
3683 Err(LifecycleError::EnvironmentCleanupDeferred) => {
3684 "isolated_environment_cleanup_deferred"
3685 }
3686 other => {
3687 if other.is_ok() {
3688 self.cleanup_retries().succeeded(attempt.id);
3689 }
3690 return other;
3691 }
3692 };
3693 self.cleanup_retries().failed(attempt.id, now);
3694 self.ports
3695 .reconcile_events
3696 .emit(LifecycleEvent::AttemptCleanFailed {
3697 policy: attempt.policy_id,
3698 attempt: attempt.id,
3699 reason,
3700 });
3701 Ok(())
3702 }
3703
3704 fn cleanup_retries(&self) -> std::sync::MutexGuard<'_, CleanBackoff> {
3705 self.cleanup_retries
3706 .lock()
3707 .unwrap_or_else(std::sync::PoisonError::into_inner)
3708 }
3709
3710 fn clean_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
3711 if !attempt.execution().is_native() {
3712 return self.clean_isolated_attempt(attempt);
3713 }
3714 match self
3715 .ports
3716 .processes
3717 .destroy(attempt)
3718 .map_err(LifecycleError::Failed)?
3719 {
3720 ProviderDestroy::Destroyed => {}
3721 ProviderDestroy::Deferred(reason) => {
3722 return Err(LifecycleError::Failed(FailureReason::Other(reason.into())));
3723 }
3724 }
3725 let outcome = attempt
3726 .outcome()
3727 .cloned()
3728 .ok_or(LifecycleError::Transition)?;
3729 self.preserve_diagnostics(attempt, &outcome)?;
3730 self.scrub_workspace(attempt)?;
3731 self.ports
3732 .packages
3733 .release(attempt.id)
3734 .map_err(LifecycleError::Failed)?;
3735 attempt
3736 .clean(self.ports.clock.now())
3737 .map_err(|_| LifecycleError::Transition)?;
3738 self.record(attempt)?;
3739 let kind = OutcomeKind::of(&outcome);
3740 self.ports.events.emit(AttemptEvent::Cleaned {
3741 attempt: attempt.id,
3742 outcome: kind,
3743 });
3744 self.ports
3745 .reconcile_events
3746 .emit(LifecycleEvent::AttemptCleaned {
3747 policy: attempt.policy_id,
3748 attempt: attempt.id,
3749 outcome: kind,
3750 });
3751 Ok(())
3752 }
3753
3754 fn clean_isolated_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
3755 if attempt.state() != AttemptState::Destroying {
3756 attempt
3757 .begin_destroy(self.ports.clock.now())
3758 .map_err(|_| LifecycleError::Transition)?;
3759 self.record(attempt)?;
3760 }
3761 let provider = self.ports.processes.as_ref();
3762 let allocation_resources = provider.enumerate_owned(self.host_id);
3763 let matching_allocation = allocation_resources
3764 .iter()
3765 .filter(|identity| identity_same_allocation(self.host_id, attempt, identity))
3766 .collect::<Vec<_>>();
3767 if matching_allocation.len() > 1
3768 || matching_allocation
3769 .iter()
3770 .any(|identity| !identity_matches_intent(self.host_id, attempt, identity))
3771 {
3772 self.ports
3773 .events
3774 .emit(AttemptEvent::QuarantinedOwnedResource {
3775 attempt: attempt.id,
3776 });
3777 tracing::warn!(attempt_id = %attempt.id, reason = "duplicate_or_mismatched_isolated_ownership", "provider resources quarantined for operator review");
3778 return Err(self.defer_environment_cleanup(attempt)?);
3779 }
3780 let has_identity = matches!(
3781 attempt.execution(),
3782 AttemptExecution::Isolated {
3783 environment_id: Some(_),
3784 ..
3785 }
3786 );
3787 if !has_identity
3788 && allocation_resources
3789 .iter()
3790 .any(|identity| identity_matches_intent(self.host_id, attempt, identity))
3791 {
3792 return Err(self.defer_environment_cleanup(attempt)?);
3793 }
3794 let current = if has_identity {
3795 provider.inspect(attempt)
3796 } else {
3797 provider.recover(attempt)
3800 };
3801 let current = match current {
3802 Ok(state) => state,
3803 Err(_) => return Err(self.defer_environment_cleanup(attempt)?),
3804 };
3805 if !has_identity && current != EnvironmentState::Missing {
3806 return Err(self.defer_environment_cleanup(attempt)?);
3807 }
3808 if current != EnvironmentState::Missing {
3809 if !matches!(provider.owns(attempt), Ok(true)) {
3810 self.ports
3811 .events
3812 .emit(AttemptEvent::QuarantinedOwnedResource {
3813 attempt: attempt.id,
3814 });
3815 tracing::warn!(attempt_id = %attempt.id, reason = "isolated_resource_ownership_mismatch", "provider resource quarantined for operator review");
3816 return Err(self.defer_environment_cleanup(attempt)?);
3817 }
3818 if matches!(
3819 current,
3820 EnvironmentState::Starting | EnvironmentState::Running
3821 ) && !matches!(provider.stop(attempt), Ok(ProviderStop::Stopped))
3822 {
3823 return Err(self.defer_environment_cleanup(attempt)?);
3824 }
3825 if !matches!(provider.destroy(attempt), Ok(ProviderDestroy::Destroyed)) {
3826 return Err(self.defer_environment_cleanup(attempt)?);
3827 }
3828 if !matches!(provider.inspect(attempt), Ok(EnvironmentState::Missing)) {
3829 return Err(self.defer_environment_cleanup(attempt)?);
3830 }
3831 }
3832 if provider
3833 .enumerate_owned(self.host_id)
3834 .iter()
3835 .any(|identity| identity_same_allocation(self.host_id, attempt, identity))
3836 {
3837 return Err(self.defer_environment_cleanup(attempt)?);
3838 }
3839 let outcome = attempt
3840 .outcome()
3841 .cloned()
3842 .ok_or(LifecycleError::Transition)?;
3843 for diagnostic in provider.diagnostics(attempt).into_iter().take(32) {
3846 tracing::debug!(attempt_id = %attempt.id, provider_diagnostic = ?diagnostic, "provider lifecycle diagnostic");
3847 }
3848 self.preserve_diagnostics(attempt, &outcome)?;
3849 self.scrub_workspace(attempt)?;
3850 self.ports
3851 .packages
3852 .release(attempt.id)
3853 .map_err(LifecycleError::Failed)?;
3854 attempt
3855 .clean_isolated(self.ports.clock.now())
3856 .map_err(|_| LifecycleError::Transition)?;
3857 self.record(attempt)?;
3858 self.ports.events.emit(AttemptEvent::Cleaned {
3859 attempt: attempt.id,
3860 outcome: OutcomeKind::of(&outcome),
3861 });
3862 self.ports
3863 .reconcile_events
3864 .emit(LifecycleEvent::AttemptCleaned {
3865 policy: attempt.policy_id,
3866 attempt: attempt.id,
3867 outcome: OutcomeKind::of(&outcome),
3868 });
3869 Ok(())
3870 }
3871
3872 fn defer_environment_cleanup(
3873 &self,
3874 attempt: &mut RunnerAttempt,
3875 ) -> Result<LifecycleError, LifecycleError> {
3876 attempt
3877 .defer_cleanup(self.ports.clock.now())
3878 .map_err(|_| LifecycleError::Transition)?;
3879 self.record(attempt)?;
3880 Ok(LifecycleError::EnvironmentCleanupDeferred)
3881 }
3882
3883 fn scrub_workspace(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
3893 #[cfg(test)]
3894 {
3895 if matches!(
3899 std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref(),
3900 Ok("skip_workspace_cleanup" | "reuse_job_workspace")
3901 ) {
3902 return Ok(());
3903 }
3904 }
3905 match attempt.workspace() {
3906 AttemptWorkspace::Ephemeral => match remove_runtime_tree(attempt.runtime_path()) {
3907 Ok(()) => Ok(()),
3908 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3909 Err(_) => Err(LifecycleError::WorkspaceCleanupDeferred),
3910 },
3911 AttemptWorkspace::PersistentSlot { slot } => self.scrub_persistent_slot(attempt, slot),
3912 }
3913 }
3914
3915 fn scrub_persistent_slot(
3928 &self,
3929 attempt: &RunnerAttempt,
3930 slot: NonZeroU16,
3931 ) -> Result<(), LifecycleError> {
3932 let configured = self
3937 .ports
3938 .store
3939 .policy(attempt.policy_id)
3940 .map_err(|_| LifecycleError::Journal)?
3941 .and_then(|policy| match policy.workspace_policy() {
3942 WorkspacePolicy::Persistent { root } => Some(root.clone()),
3943 WorkspacePolicy::Ephemeral => None,
3944 });
3945 let runtime = attempt.runtime_path();
3946 self.quarantine_on_refusal(
3947 attempt,
3948 verify_journalled_slot(runtime, slot, configured.as_ref())
3949 .and_then(|()| slot_is_present(runtime))
3950 .and_then(|present| {
3951 if present {
3952 scrub_slot_entries(runtime).and_then(|()| verify_slot_scrubbed(runtime))
3953 } else {
3954 Ok(())
3955 }
3956 }),
3957 )
3958 }
3959
3960 fn quarantine_on_refusal(
3969 &self,
3970 attempt: &RunnerAttempt,
3971 outcome: Result<(), SlotQuarantine>,
3972 ) -> Result<(), LifecycleError> {
3973 let Err(quarantine) = outcome else {
3974 return Ok(());
3975 };
3976 let detail = quarantine.to_string();
3977 tracing::warn!(
3978 attempt = %attempt.id,
3979 policy = %attempt.policy_id,
3980 slot = attempt.workspace().slot_number(),
3981 refusal = quarantine.refusal.class(),
3982 "{detail}"
3983 );
3984 Err(LifecycleError::SlotQuarantined {
3985 class: quarantine.refusal.class(),
3986 detail,
3987 })
3988 }
3989
3990 fn preserve_diagnostics(
3991 &self,
3992 attempt: &RunnerAttempt,
3993 outcome: &AttemptOutcome,
3994 ) -> Result<(), LifecycleError> {
3995 fs::create_dir_all(&self.diagnostics_root).map_err(|_| {
3996 LifecycleError::Failed(FailureReason::Other(
3997 "diagnostics directory could not be created".into(),
3998 ))
3999 })?;
4000 let reason_kind = match outcome {
4003 AttemptOutcome::Failed { reason } => failure_reason_kind(reason),
4004 _ => "none",
4005 };
4006 let diagnostic = format!(
4007 "attempt_id={}\npolicy_id={}\noutcome={}\nreason={}\n",
4008 attempt.id,
4009 attempt.policy_id,
4010 OutcomeKind::of(outcome).as_str(),
4011 reason_kind,
4012 );
4013 fs::write(
4014 self.diagnostics_root.join(format!("{}.log", attempt.id)),
4015 diagnostic,
4016 )
4017 .map_err(|_| {
4018 LifecycleError::Failed(FailureReason::Other(
4019 "redacted diagnostics could not be preserved".into(),
4020 ))
4021 })
4022 }
4023
4024 async fn materialize_with_retry(
4025 &self,
4026 policy: &ScalePolicy,
4027 attempt: &RunnerAttempt,
4028 ) -> Result<RunnerVersion, FailureReason> {
4029 let mut issued = 0_u32;
4030 loop {
4031 issued = issued.saturating_add(1);
4032 match self.ports.packages.materialize(attempt).await {
4033 Ok(version) => return Ok(version),
4034 Err(reason)
4035 if package_failure_is_terminal(&reason)
4036 || issued >= self.retry.max_attempts.max(1) =>
4037 {
4038 return Err(reason);
4039 }
4040 Err(reason) => {
4041 if !self.ports.demand.persists(policy.id).await {
4042 return Err(reason);
4043 }
4044 let delay = self.retry.delay(issued);
4045 self.ports.events.emit(AttemptEvent::Retry {
4046 attempt: attempt.id,
4047 operation: "package_materialization",
4048 delay,
4049 });
4050 self.ports.delay.wait(delay).await;
4051 if !self.ports.demand.persists(policy.id).await {
4052 return Err(reason);
4053 }
4054 }
4055 }
4056 }
4057 }
4058
4059 async fn register_with_retry(
4060 &self,
4061 policy: &ScalePolicy,
4062 attempt: AttemptId,
4063 request: &JitRunnerRequest,
4064 ) -> Result<JitRegistration, LifecycleError> {
4065 let mut issued = 0_u32;
4066 loop {
4067 issued = issued.saturating_add(1);
4068 match self
4069 .ports
4070 .github
4071 .register(&policy.target, request, &self.cancel)
4072 .await
4073 {
4074 Ok(registration) => return Ok(registration),
4075 Err(error) if error.terminal => {
4076 return Err(LifecycleError::Failed(error.reason));
4077 }
4078 Err(error) => {
4079 if issued >= self.retry.max_attempts.max(1)
4080 || !self.ports.demand.persists(policy.id).await
4081 {
4082 return Err(LifecycleError::Failed(error.reason));
4083 }
4084 let delay = error
4085 .retry_after
4086 .unwrap_or_else(|| self.retry.delay(issued));
4087 self.ports.events.emit(AttemptEvent::Retry {
4088 attempt,
4089 operation: "jit_request",
4090 delay,
4091 });
4092 self.ports.delay.wait(delay).await;
4093 if !self.ports.demand.persists(policy.id).await {
4094 return Err(LifecycleError::Failed(error.reason));
4095 }
4096 }
4097 }
4098 }
4099 }
4100
4101 fn allocate_workspace(
4111 &self,
4112 policy: &ScalePolicy,
4113 id: AttemptId,
4114 ) -> Result<Placement, LifecycleError> {
4115 let placement = match policy.workspace_policy() {
4116 WorkspacePolicy::Persistent { root } => self.allocate_persistent_slot(policy, root),
4121 WorkspacePolicy::Ephemeral => self.allocate_disposable(policy, id),
4122 };
4123 if placement.is_ok() {
4126 self.root_accepted(policy.id);
4127 }
4128 placement
4129 }
4130
4131 fn configured_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
4138 let host = self
4139 .ports
4140 .store
4141 .host(self.host_id)
4142 .map_err(|_| LifecycleError::Journal)?
4143 .ok_or_else(|| LifecycleError::Failed(FailureReason::Other("host not found".into())))?;
4144 Ok(host.runner_root_override.clone())
4145 }
4146
4147 fn resolvable_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
4151 Ok(self
4152 .configured_host_root()?
4153 .or_else(|| default_runner_root(&self.app_paths).ok()))
4154 }
4155
4156 fn effective_host_root(
4161 &self,
4162 policy: &ScalePolicy,
4163 ) -> Result<LocalAbsolutePath, LifecycleError> {
4164 match self.configured_host_root()? {
4165 Some(configured) => Ok(configured),
4166 None => default_runner_root(&self.app_paths).map_err(|error| {
4167 self.root_refused(policy.id, "the platform default runner root", error)
4170 }),
4171 }
4172 }
4173
4174 fn root_refused(&self, policy: PolicyId, root: &str, error: RunnerRootError) -> LifecycleError {
4187 let _ = runner_manager_platform::service::record_runner_root_refusal(
4188 &self.app_paths,
4189 &policy.to_string(),
4190 self.ports.clock.now(),
4191 error.kind(),
4192 root,
4193 &error.to_string(),
4194 );
4195 root_failure(error)
4196 }
4197
4198 fn root_accepted(&self, policy: PolicyId) {
4205 let _ = runner_manager_platform::service::clear_runner_root_refusal(
4206 &self.app_paths,
4207 &policy.to_string(),
4208 );
4209 }
4210
4211 fn allocate_disposable(
4214 &self,
4215 policy: &ScalePolicy,
4216 id: AttemptId,
4217 ) -> Result<Placement, LifecycleError> {
4218 let effective_root = self.effective_host_root(policy)?;
4219 RootPreflight::new(&self.app_paths)
4220 .check(&RootOwner::Host, &effective_root)
4221 .map_err(|error| self.root_refused(policy.id, effective_root.as_str(), error))?;
4222 let runtime = effective_root.as_path().join({
4223 #[cfg(test)]
4224 {
4225 if std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref()
4226 == Ok("reuse_job_workspace")
4227 {
4228 "mutant-shared-workspace".to_owned()
4229 } else {
4230 workspace_name(id)
4231 }
4232 }
4233 #[cfg(not(test))]
4234 {
4235 workspace_name(id)
4236 }
4237 });
4238 fs::create_dir_all(&runtime)
4239 .map_err(|_| LifecycleError::Failed(FailureReason::ProcessStartFailed))?;
4240 Ok(Placement {
4241 runtime,
4242 workspace: AttemptWorkspace::Ephemeral,
4243 })
4244 }
4245
4246 fn allocate_persistent_slot(
4258 &self,
4259 policy: &ScalePolicy,
4260 root: &LocalAbsolutePath,
4261 ) -> Result<Placement, LifecycleError> {
4262 let ceiling = policy.max_capacity().ok_or_else(|| {
4265 LifecycleError::Failed(FailureReason::Other(
4266 "a persistent workspace needs the policy's max_capacity to bound its slots"
4267 .to_string(),
4268 ))
4269 })?;
4270 let leases = self
4271 .ports
4272 .store
4273 .slot_leases_for_policy(policy.id)
4274 .map_err(|_| LifecycleError::Journal)?;
4275 let slot = lowest_free_slot(&leases, ceiling).ok_or_else(|| {
4276 LifecycleError::Failed(FailureReason::Other(format!(
4277 "every persistent slot s1 to s{ceiling} for {} is leased by an attempt that has \
4278 not been cleaned, so no slot is free; raise the repository's max capacity, or \
4279 finish cleaning a concluded attempt",
4280 policy.target
4281 )))
4282 })?;
4283 let workspace = AttemptWorkspace::persistent_slot(slot);
4284 let name = workspace
4285 .slot_directory_name()
4286 .expect("a persistent allocation names its slot directory");
4287
4288 let host_root = self.resolvable_host_root()?;
4300 let mut preflight = RootPreflight::new(&self.app_paths);
4301 if let Some(host_root) = host_root {
4302 preflight = preflight.against(RootOwner::Host, host_root);
4303 }
4304 let checked = preflight
4305 .check(&RootOwner::Repository(policy.target.to_string()), root)
4306 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4307 if let Some(leaf) = checked.leaf_to_create() {
4308 fs::create_dir(leaf).map_err(|source| {
4309 LifecycleError::Failed(FailureReason::Other(format!(
4310 "the persistent workspace root {} could not be created: {source}",
4311 leaf.display()
4312 )))
4313 })?;
4314 }
4315
4316 let slot_path = runner_root::derive_child(root, &name)
4320 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4321 create_or_validate_slot(slot_path.as_path())?;
4322 runner_root::verify_containment(root, &slot_path)
4323 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4324 accept_reusable_slot(slot_path.as_path())?;
4325 Ok(Placement {
4326 runtime: slot_path.as_path().to_path_buf(),
4327 workspace,
4328 })
4329 }
4330
4331 fn record_allocation(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
4343 match self.ports.store.record_attempt(attempt) {
4344 Ok(()) => {
4345 self.ports.events.emit(AttemptEvent::State {
4346 attempt: attempt.id,
4347 state: attempt.state(),
4348 });
4349 Ok(())
4350 }
4351 Err(error @ StoreError::SlotAlreadyLeased { .. }) => Err(LifecycleError::Failed(
4352 FailureReason::Other(error.to_string()),
4353 )),
4354 Err(_) => Err(LifecycleError::Journal),
4355 }
4356 }
4357
4358 async fn launch_attempt(
4359 &self,
4360 policy: &ScalePolicy,
4361 allocation_guard: &AllocationGuard,
4362 lock_wait: Duration,
4363 ) -> Result<RunnerAttempt, LifecycleError> {
4364 let (attempt, labels) = self.allocate_attempt(policy)?;
4365 let id = attempt.id;
4366 let mut stopwatch = LaunchStopwatch::new(lock_wait);
4367 let result = self
4368 .launch_steps(policy, allocation_guard, attempt, labels, &mut stopwatch)
4369 .await;
4370 stopwatch.finish();
4371 self.ports.events.emit(AttemptEvent::Launched {
4372 attempt: id,
4373 started: result.is_ok(),
4374 timings: stopwatch.timings,
4375 });
4376 result
4377 }
4378
4379 fn allocate_attempt<'p>(
4382 &self,
4383 policy: &'p ScalePolicy,
4384 ) -> Result<(RunnerAttempt, &'p RoutingLabels), LifecycleError> {
4385 if !*self
4386 .recovery_complete
4387 .lock()
4388 .map_err(|_| LifecycleError::Journal)?
4389 {
4390 return Err(LifecycleError::RecoveryIncomplete);
4391 }
4392 let capability = self.ports.processes.probe(policy);
4393 if capability != ProviderCapability::Ready {
4394 return Err(LifecycleError::Failed(capability.refusal()));
4395 }
4396 let resolved = self.ports.processes.resolve(policy).map_err(|reason| {
4397 LifecycleError::Failed(if policy.execution_policy().is_native() {
4398 reason
4399 } else {
4400 safe_isolation_reason(reason, IsolationProviderFailure::RuntimeOperationFailed)
4401 })
4402 })?;
4403 if policy.execution_policy().is_native() != resolved.is_none() {
4404 return Err(LifecycleError::Failed(FailureReason::Other(
4405 "provider resolution mismatch".into(),
4406 )));
4407 }
4408 if let Some(resolution) = &resolved {
4409 if resolution.provider_kind == Backend::Auto {
4410 return Err(LifecycleError::Failed(FailureReason::Other(
4411 "provider resolution mismatch".into(),
4412 )));
4413 }
4414 AttemptExecution::Isolated {
4415 provider_kind: resolution.provider_kind,
4416 environment_id: None,
4417 resolved_image: resolution.image.clone(),
4418 generation: "generation-check".into(),
4419 }
4420 .validate()
4421 .map_err(|_| {
4422 LifecycleError::Failed(FailureReason::Other(
4423 "resolved image is incompatible".into(),
4424 ))
4425 })?;
4426 }
4427 let labels = policy
4428 .routing_labels()
4429 .ok_or(LifecycleError::Failed(FailureReason::JitRequestFailed))?;
4430 let id = AttemptId::new_random();
4431 let placement = self.allocate_workspace(policy, id)?;
4432 let mut attempt = RunnerAttempt::allocate_in(
4433 id,
4434 policy.id,
4435 placement.runtime,
4436 placement.workspace,
4437 self.ports.clock.now(),
4438 );
4439 if let Some(resolution) = &resolved {
4440 attempt
4441 .allocate_execution(AttemptExecution::Isolated {
4442 provider_kind: resolution.provider_kind,
4443 environment_id: None,
4444 resolved_image: resolution.image.clone(),
4445 generation: uuid::Uuid::new_v4().to_string(),
4446 })
4447 .map_err(|_| LifecycleError::Transition)?;
4448 }
4449 self.record_allocation(&attempt)?;
4454 Ok((attempt, labels))
4455 }
4456
4457 async fn launch_steps(
4458 &self,
4459 policy: &ScalePolicy,
4460 allocation_guard: &AllocationGuard,
4461 mut attempt: RunnerAttempt,
4462 labels: &RoutingLabels,
4463 stopwatch: &mut LaunchStopwatch,
4464 ) -> Result<RunnerAttempt, LifecycleError> {
4465 let id = attempt.id;
4466 let isolated = !attempt.execution().is_native();
4467 stopwatch.begin(LaunchStep::Materialize);
4468 let version = match self.materialize_with_retry(policy, &attempt).await {
4469 Ok(version) => version,
4470 Err(reason) => return self.fail_launch(&mut attempt, reason),
4471 };
4472 self.prune_under_allocation_lock(allocation_guard, &version)?;
4473 self.versions
4474 .lock()
4475 .map_err(|_| LifecycleError::Journal)?
4476 .insert(id, version);
4477
4478 if isolated {
4479 attempt
4480 .begin_prepare(self.ports.clock.now())
4481 .map_err(|_| LifecycleError::Transition)?;
4482 self.record(&attempt)?;
4483 }
4484 let prepared = match self.ports.processes.prepare(&attempt, policy) {
4485 Ok(prepared) => prepared,
4486 Err(reason) => {
4487 let safe_reason = if isolated {
4488 safe_isolation_reason(reason, IsolationProviderFailure::RuntimeOperationFailed)
4489 } else {
4490 reason
4491 };
4492 return self.fail_launch(&mut attempt, safe_reason);
4493 }
4494 };
4495 if isolated {
4496 let Some(identity) = prepared.identity() else {
4497 return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
4498 IsolationProviderFailure::OwnershipMismatch,
4499 )));
4500 };
4501 if !identity_matches_intent(self.host_id, &attempt, identity) {
4502 return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
4503 IsolationProviderFailure::OwnershipMismatch,
4504 )));
4505 }
4506 let EnvironmentIdentity::Isolated { environment_id, .. } = identity else {
4507 unreachable!()
4508 };
4509 attempt
4510 .prepared_environment(environment_id.clone())
4511 .map_err(|_| LifecycleError::Transition)?;
4512 attempt
4513 .mark_prepared(self.ports.clock.now())
4514 .map_err(|_| LifecycleError::Transition)?;
4515 self.record(&attempt)?;
4516 }
4517
4518 stopwatch.begin(LaunchStep::Register);
4519 let jit_request =
4520 JitRunnerRequest::for_policy(runner_name(id), self.runner_group_id, labels);
4521 let registration = match self.register_with_retry(policy, id, &jit_request).await {
4522 Ok(registration) => registration,
4523 Err(error) => return self.fail_launch(&mut attempt, error.reason()),
4524 };
4525 stopwatch.begin(LaunchStep::Spawn);
4526 let runner_id = registration.runner().id;
4527 write_runner_id(attempt.runtime_path(), runner_id)?;
4528 attempt
4529 .jit_received(self.ports.clock.now())
4530 .map_err(|_| LifecycleError::Transition)?;
4531 self.record(&attempt)?;
4532 let config = registration.into_config();
4533 if isolated {
4534 let started = self
4535 .ports
4536 .processes
4537 .start(prepared, &attempt, OneTimeJitHandoff::new(&config))
4538 .map_err(|failure| {
4539 LifecycleError::Failed(safe_isolation_reason(
4540 failure.reason,
4541 IsolationProviderFailure::RuntimeOperationFailed,
4542 ))
4543 })?;
4544 if !identity_matches_intent(self.host_id, &attempt, &started) {
4545 return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
4546 IsolationProviderFailure::OwnershipMismatch,
4547 )));
4548 }
4549 attempt
4550 .started_isolated(self.ports.clock.now())
4551 .map_err(|_| LifecycleError::Transition)?;
4552 self.record(&attempt)?;
4553 return Ok(attempt);
4554 }
4555 let mut issued = 0_u32;
4556 let pid = loop {
4557 issued = issued.saturating_add(1);
4558 match self.ports.processes.start(
4559 prepared.clone(),
4560 &attempt,
4561 OneTimeJitHandoff::new(&config),
4562 ) {
4563 Ok(EnvironmentIdentity::NativeProcess(pid)) => break pid,
4564 Ok(EnvironmentIdentity::Isolated { .. }) => {
4565 return Err(LifecycleError::Failed(FailureReason::Other(
4569 "unexpected isolated environment identity".into(),
4570 )));
4571 }
4572 Err(error) => {
4573 if let Some(pid) = error.live_pid {
4574 attempt
4575 .started(pid, self.ports.clock.now())
4576 .map_err(|_| LifecycleError::Transition)?;
4577 self.record(&attempt)?;
4578 return Err(LifecycleError::Failed(error.reason));
4579 }
4580 if !error.retryable
4581 || issued >= self.retry.max_attempts.max(1)
4582 || !self.ports.demand.persists(policy.id).await
4583 {
4584 return self.fail_launch(&mut attempt, error.reason);
4585 }
4586 let delay = self.retry.delay(issued);
4587 self.ports.events.emit(AttemptEvent::Retry {
4588 attempt: attempt.id,
4589 operation: "process_start",
4590 delay,
4591 });
4592 self.ports.delay.wait(delay).await;
4593 if !self.ports.demand.persists(policy.id).await {
4594 return self.fail_launch(&mut attempt, error.reason);
4595 }
4596 }
4597 }
4598 };
4599 attempt
4600 .started(pid, self.ports.clock.now())
4601 .map_err(|_| LifecycleError::Transition)?;
4602 self.record(&attempt)?;
4603 Ok(attempt)
4604 }
4605
4606 fn fail_launch<T>(
4607 &self,
4608 attempt: &mut RunnerAttempt,
4609 reason: FailureReason,
4610 ) -> Result<T, LifecycleError> {
4611 self.conclude(attempt, AttemptOutcome::failed(reason.clone()))?;
4612 Err(LifecycleError::Failed(reason))
4613 }
4614
4615 fn prune_under_allocation_lock(
4618 &self,
4619 guard: &AllocationGuard,
4620 version: &RunnerVersion,
4621 ) -> Result<(), LifecycleError> {
4622 let attempts = self
4623 .ports
4624 .store
4625 .attempts()
4626 .map_err(|_| LifecycleError::Journal)?;
4627 self.ports
4628 .packages
4629 .prune_obsolete_guarded(
4630 PruneAuthority::from_launch_request(guard),
4631 version,
4632 &attempts,
4633 )
4634 .map_err(LifecycleError::Failed)?;
4635 #[cfg(target_os = "macos")]
4636 self.prune_abandoned_seeds(&attempts);
4637 Ok(())
4638 }
4639
4640 #[cfg(target_os = "macos")]
4652 fn prune_abandoned_seeds(&self, attempts: &[RunnerAttempt]) {
4653 let Ok(policies) = self.ports.store.policies() else {
4654 return;
4655 };
4656 let Ok(Some(host_root)) = self.resolvable_host_root() else {
4657 return;
4658 };
4659 let in_use: BTreeSet<PathBuf> = policies
4660 .iter()
4661 .filter_map(|policy| match policy.workspace_policy() {
4662 WorkspacePolicy::Persistent { root } => Some(canonical_or_raw(root.as_path())),
4663 WorkspacePolicy::Ephemeral => None,
4664 })
4665 .chain(std::iter::once(canonical_or_raw(host_root.as_path())))
4666 .collect();
4667 let used: BTreeSet<&Path> = attempts
4669 .iter()
4670 .filter_map(|attempt| attempt.runtime_path().parent())
4671 .collect();
4672 for root in used.into_iter().map(canonical_or_raw) {
4673 if !in_use.contains(&root) {
4674 let _ = remove_runtime_tree(&root.join(PACKAGE_SEED_DIR));
4675 }
4676 }
4677 }
4678}
4679
4680#[cfg(target_os = "macos")]
4681fn canonical_or_raw(path: &Path) -> PathBuf {
4682 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
4683}
4684
4685#[async_trait]
4686impl RunnerLauncher for LifecycleLauncher {
4687 async fn supervise(
4688 &self,
4689 policy: &ScalePolicy,
4690 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
4691 LifecycleLauncher::supervise(self, policy)
4692 .await
4693 .map_err(|error| LaunchFailure::new(error.reason()))
4694 }
4695
4696 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
4697 self.ports.store.attempts().map_err(|_| {
4698 LaunchFailure::new(FailureReason::Other(
4699 "attempt journal could not be read".into(),
4700 ))
4701 })
4702 }
4703
4704 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
4705 self.launch_attempt(request.policy, request.allocation_guard, request.lock_wait)
4706 .await
4707 .map_err(|error| LaunchFailure::new(error.reason()))
4708 }
4709
4710 async fn clean(&self, id: AttemptId) -> Result<(), LaunchFailure> {
4711 let mut attempt = self
4712 .ports
4713 .store
4714 .attempt(id)
4715 .map_err(|_| {
4716 LaunchFailure::new(FailureReason::Other(
4717 "attempt journal could not be read".into(),
4718 ))
4719 })?
4720 .ok_or_else(|| {
4721 LaunchFailure::new(FailureReason::Other(
4722 "attempt disappeared from the journal".into(),
4723 ))
4724 })?;
4725 self.clean_attempt(&mut attempt)
4726 .map_err(|error| LaunchFailure::new(error.reason()))?;
4727 self.cleanup_retries().succeeded(id);
4728 Ok(())
4729 }
4730}
4731
4732fn runner_name(attempt: AttemptId) -> String {
4733 format!("runner-manager-{attempt}")
4734}
4735
4736const EVIDENCE_DEADLINE: Duration = Duration::from_secs(2);
4738
4739#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4749enum JobEvidence {
4750 RanAJob,
4751 NoJob,
4752 Unknown,
4753}
4754
4755impl JobEvidence {
4756 fn of(runtime: &Path) -> Self {
4757 let diag = runtime.join("_diag");
4758 if runner_manager_platform::host_fitness::directory_responds(&diag, EVIDENCE_DEADLINE)
4762 != runner_manager_platform::host_fitness::Responsiveness::Responds
4763 {
4764 return Self::Unknown;
4765 }
4766 let Ok(entries) = fs::read_dir(diag) else {
4767 return Self::Unknown;
4768 };
4769 let mut listener = false;
4770 for entry in entries.flatten() {
4771 let name = entry.file_name();
4772 let name = name.to_string_lossy();
4773 if !name.ends_with(".log") {
4774 continue;
4775 }
4776 if name.starts_with("Worker_") {
4777 return Self::RanAJob;
4778 }
4779 listener |= name.starts_with("Runner_");
4780 }
4781 if listener { Self::NoJob } else { Self::Unknown }
4782 }
4783}
4784
4785fn known_runner_id(attempt: &RunnerAttempt, observed: Option<u64>) -> Option<u64> {
4788 attempt
4789 .github_runner_id()
4790 .or(observed)
4791 .or_else(|| read_runner_id(attempt.runtime_path()))
4792}
4793
4794fn read_runner_id(runtime: &Path) -> Option<u64> {
4795 fs::read_to_string(runtime.join(RUNNER_ID_FILE))
4796 .ok()?
4797 .trim()
4798 .parse()
4799 .ok()
4800}
4801
4802fn write_runner_id(runtime: &Path, runner_id: u64) -> Result<(), LifecycleError> {
4803 let target = runtime.join(RUNNER_ID_FILE);
4804 if let Some(existing) = read_runner_id(runtime) {
4805 return (existing == runner_id)
4806 .then_some(())
4807 .ok_or(LifecycleError::Journal);
4808 }
4809 let temporary = runtime.join(format!("{RUNNER_ID_FILE}.{}.tmp", uuid::Uuid::new_v4()));
4810 write_durable_file(&temporary, runner_id.to_string().as_bytes())
4811 .map_err(|_| LifecycleError::Journal)?;
4812 match fs::rename(&temporary, &target) {
4813 Ok(()) => sync_directory(runtime).map_err(|_| LifecycleError::Journal),
4814 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
4815 let _ = fs::remove_file(&temporary);
4816 (read_runner_id(runtime) == Some(runner_id))
4817 .then_some(())
4818 .ok_or(LifecycleError::Journal)
4819 }
4820 Err(_) => {
4821 let _ = fs::remove_file(&temporary);
4822 Err(LifecycleError::Journal)
4823 }
4824 }
4825}
4826
4827fn write_durable_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
4828 let mut file = fs::OpenOptions::new()
4829 .create(true)
4830 .truncate(true)
4831 .write(true)
4832 .open(path)?;
4833 file.write_all(bytes)?;
4834 file.sync_all()?;
4835 let parent = path.parent().ok_or_else(|| {
4836 std::io::Error::new(
4837 std::io::ErrorKind::InvalidInput,
4838 "file has no parent directory",
4839 )
4840 })?;
4841 sync_directory(parent)
4842}
4843
4844#[cfg(unix)]
4845fn sync_directory(path: &Path) -> std::io::Result<()> {
4846 fs::File::open(path)?.sync_all()
4847}
4848
4849#[cfg(windows)]
4850fn sync_directory(path: &Path) -> std::io::Result<()> {
4851 use std::os::windows::fs::OpenOptionsExt;
4852
4853 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
4854 const FILE_SHARE_ALL: u32 = 0x0000_0007;
4855 const GENERIC_WRITE: u32 = 0x4000_0000;
4856 fs::OpenOptions::new()
4857 .access_mode(GENERIC_WRITE)
4858 .share_mode(FILE_SHARE_ALL)
4859 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
4860 .open(path)?
4861 .sync_all()
4862}
4863
4864#[cfg(test)]
4865mod tests {
4866 use super::*;
4867 use std::collections::BTreeSet;
4868 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
4869
4870 use crate::reconcile::{AllocationLock, InProcessAllocationLock};
4871 use runner_manager_domain::model::{Elapsed, TargetScope};
4872 use runner_manager_domain::store::SqliteStore;
4873 use runner_manager_github::jit::JitRunner;
4874 use runner_manager_testkit::clock::FakeClock;
4875 use runner_manager_testkit::fixtures;
4876
4877 #[test]
4878 fn production_provider_set_routes_every_implemented_isolated_backend() {
4879 let providers = PlatformExecutionProvider::new(HostId::from_u128(1));
4880 assert!(providers.isolated_provider(Backend::Auto).is_some());
4881 assert!(providers.isolated_provider(Backend::Oci).is_some());
4882 assert!(
4883 providers
4884 .isolated_provider(Backend::WindowsHyperVContainer)
4885 .is_some()
4886 );
4887 assert!(
4888 providers
4889 .isolated_provider(Backend::VirtualMachine)
4890 .is_none()
4891 );
4892 }
4893
4894 type CapturedFields = Vec<(String, String)>;
4896
4897 #[derive(Clone, Default)]
4905 struct CapturedEvents(std::sync::Arc<std::sync::Mutex<Vec<CapturedFields>>>);
4906
4907 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedEvents {
4908 fn on_event(
4909 &self,
4910 event: &tracing::Event<'_>,
4911 _context: tracing_subscriber::layer::Context<'_, S>,
4912 ) {
4913 struct Collect(Vec<(String, String)>);
4914 impl tracing::field::Visit for Collect {
4915 fn record_debug(
4916 &mut self,
4917 field: &tracing::field::Field,
4918 value: &dyn std::fmt::Debug,
4919 ) {
4920 self.0.push((
4923 field.name().to_owned(),
4924 format!("{value:?}").trim_matches('"').to_owned(),
4925 ));
4926 }
4927 }
4928 let mut collected = Collect(Vec::new());
4929 event.record(&mut collected);
4930 self.0
4931 .lock()
4932 .expect("the capture mutex is not poisoned")
4933 .push(collected.0);
4934 }
4935 }
4936
4937 #[test]
4955 fn a_launch_the_runner_root_refused_names_the_cause_in_the_log_that_ships() {
4956 use runner_manager_platform::logging;
4957 use tracing_subscriber::layer::SubscriberExt as _;
4958
4959 let captured = CapturedEvents::default();
4960 let error = RunnerRootError::DeniedByPrivacyPolicy {
4961 requested: PathBuf::from("/Volumes/NVME/runners"),
4962 refused: PathBuf::from("/Volumes/NVME"),
4963 remediation: RootOwner::Host.remediation(),
4964 };
4965 let kind = error.kind();
4966
4967 let failure = tracing::subscriber::with_default(
4968 tracing_subscriber::registry().with(captured.clone()),
4969 || root_failure(error),
4970 );
4971
4972 assert!(
4977 matches!(
4978 &failure,
4979 LifecycleError::Failed(FailureReason::Other(detail))
4980 if detail.contains("/Volumes/NVME/runners")
4981 && detail.contains("Full Disk Access")
4982 ),
4983 "the reason must still carry the detail: {failure:?}"
4984 );
4985
4986 let events = captured
4987 .0
4988 .lock()
4989 .expect("the capture mutex is not poisoned")
4990 .clone();
4991 let event = events
4992 .iter()
4993 .find(|fields| fields.iter().any(|(_, value)| value.contains(kind)))
4994 .unwrap_or_else(|| panic!("the refusal did not name its cause: {events:?}"));
4995
4996 for (name, value) in event {
5001 assert!(
5002 logging::is_field_allowed(name),
5003 "`{name}` is not allow-listed, so it ships as `{}`: {event:?}",
5004 logging::REDACTION
5005 );
5006 assert_eq!(
5007 &logging::redact(value),
5008 value,
5009 "`{name}` does not survive value-shape scrubbing: {event:?}"
5010 );
5011 }
5012 }
5013
5014 fn nz(slot: u16) -> NonZeroU16 {
5016 NonZeroU16::new(slot).expect("a positive slot")
5017 }
5018
5019 const JIT: &str = "eyJzZWNyZXQiOiJnaHBfRE9fTk9UX0xFQUsifQ==";
5020
5021 #[derive(Debug, Default)]
5022 struct FakeGithubLifecycle {
5023 registration_failures: Mutex<VecDeque<bool>>,
5024 observations: Mutex<VecDeque<LifecycleGithubObservation>>,
5025 registrations: AtomicUsize,
5026 remaining_runners: AtomicUsize,
5027 deregistrations: Mutex<Vec<u64>>,
5031 deregistration_fails: AtomicBool,
5034 journal: Mutex<Option<Arc<SqliteStore>>>,
5039 registration_facts: Mutex<Vec<RegistrationFact>>,
5041 }
5042
5043 #[derive(Debug, Clone)]
5050 struct RegistrationFact {
5051 leased_slots: Vec<u16>,
5053 work_folder: String,
5055 runner_name: String,
5058 journal_states: Vec<AttemptState>,
5059 }
5060
5061 impl FakeGithubLifecycle {
5062 fn fail(mut self, terminal: bool) -> Self {
5063 self.registration_failures
5064 .get_mut()
5065 .expect("unpoisoned")
5066 .push_back(terminal);
5067 self
5068 }
5069
5070 fn watch_journal(&self, store: Arc<SqliteStore>) {
5071 *self.journal.lock().unwrap() = Some(store);
5072 }
5073
5074 fn registration_facts(&self) -> Vec<RegistrationFact> {
5075 self.registration_facts.lock().unwrap().clone()
5076 }
5077
5078 fn observe(&self, observation: GithubRunnerObservation) {
5079 let observation = match observation {
5080 GithubRunnerObservation::Unreachable => LifecycleGithubObservation::unreachable(),
5081 GithubRunnerObservation::NotRegistered => {
5082 LifecycleGithubObservation::not_registered()
5083 }
5084 GithubRunnerObservation::Registered { busy } => {
5085 LifecycleGithubObservation::registered(73, busy)
5086 }
5087 };
5088 self.observations.lock().unwrap().push_back(observation);
5089 }
5090 }
5091
5092 #[async_trait]
5093 impl LifecycleGithub for FakeGithubLifecycle {
5094 async fn register(
5095 &self,
5096 _target: &ScaleTarget,
5097 request: &JitRunnerRequest,
5098 _cancel: &CancelToken,
5099 ) -> Result<JitRegistration, JitRequestFailure> {
5100 self.registrations.fetch_add(1, Ordering::SeqCst);
5101 if let Some(store) = self.journal.lock().unwrap().as_ref() {
5102 let attempts = store.attempts().expect("the journal is readable");
5103 let slots = attempts
5104 .iter()
5105 .filter_map(|attempt| attempt.workspace().slot_number())
5106 .collect();
5107 self.registration_facts
5108 .lock()
5109 .unwrap()
5110 .push(RegistrationFact {
5111 leased_slots: slots,
5112 work_folder: request.work_folder().to_string(),
5113 runner_name: request.name().to_string(),
5114 journal_states: attempts.iter().map(RunnerAttempt::state).collect(),
5115 });
5116 }
5117 if let Some(terminal) = self.registration_failures.lock().unwrap().pop_front() {
5118 return Err(JitRequestFailure {
5119 terminal,
5120 reason: if terminal {
5121 FailureReason::Other("GitHub refused JIT registration with 403".into())
5122 } else {
5123 FailureReason::JitRequestFailed
5124 },
5125 retry_after: None,
5126 });
5127 }
5128 self.remaining_runners.store(1, Ordering::SeqCst);
5129 Ok(JitRegistration::new(
5130 EncodedJitConfig::new(JIT),
5131 JitRunner {
5132 id: 73,
5133 name: request.name().to_string(),
5134 os: "windows".into(),
5135 status: "offline".into(),
5136 busy: false,
5137 runner_group_id: Some(1),
5138 labels: request.labels().to_vec(),
5139 },
5140 ))
5141 }
5142
5143 async fn observe(
5144 &self,
5145 _target: &ScaleTarget,
5146 _attempt: AttemptId,
5147 _cancel: &CancelToken,
5148 ) -> LifecycleGithubObservation {
5149 let observation = self
5150 .observations
5151 .lock()
5152 .unwrap()
5153 .pop_front()
5154 .unwrap_or(LifecycleGithubObservation::not_registered());
5155 if observation.status == GithubRunnerObservation::NotRegistered {
5156 self.remaining_runners.store(0, Ordering::SeqCst);
5157 }
5158 observation
5159 }
5160
5161 async fn deregister(
5162 &self,
5163 _target: &ScaleTarget,
5164 runner_id: u64,
5165 _cancel: &CancelToken,
5166 ) -> bool {
5167 self.deregistrations.lock().unwrap().push(runner_id);
5168 if self.deregistration_fails.load(Ordering::SeqCst) {
5169 return false;
5170 }
5171 self.remaining_runners.store(0, Ordering::SeqCst);
5172 true
5173 }
5174 }
5175
5176 #[derive(Debug)]
5177 struct FakePackages {
5178 version: RunnerVersion,
5179 leases: Mutex<BTreeSet<AttemptId>>,
5180 materializations: AtomicUsize,
5181 materialization_failures: AtomicUsize,
5182 release_failures: AtomicUsize,
5183 releases: AtomicUsize,
5184 prunes: AtomicUsize,
5185 prune_currents: Mutex<Vec<RunnerVersion>>,
5186 }
5187
5188 impl Default for FakePackages {
5189 fn default() -> Self {
5190 Self {
5191 version: RunnerVersion::parse("2.330.0").unwrap(),
5192 leases: Mutex::new(BTreeSet::new()),
5193 materializations: AtomicUsize::new(0),
5194 materialization_failures: AtomicUsize::new(0),
5195 release_failures: AtomicUsize::new(0),
5196 releases: AtomicUsize::new(0),
5197 prunes: AtomicUsize::new(0),
5198 prune_currents: Mutex::new(Vec::new()),
5199 }
5200 }
5201 }
5202
5203 impl FakePackages {
5204 fn fail_materializations(&self, count: usize) {
5205 self.materialization_failures.store(count, Ordering::SeqCst);
5206 }
5207
5208 fn fail_releases(&self, count: usize) {
5209 self.release_failures.store(count, Ordering::SeqCst);
5210 }
5211 }
5212
5213 #[async_trait]
5214 impl RuntimePackages for FakePackages {
5215 async fn materialize(
5216 &self,
5217 attempt: &RunnerAttempt,
5218 ) -> Result<RunnerVersion, FailureReason> {
5219 self.materializations.fetch_add(1, Ordering::SeqCst);
5220 if self
5221 .materialization_failures
5222 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5223 if left > 0 { Some(left - 1) } else { None }
5224 })
5225 .is_ok()
5226 {
5227 return Err(FailureReason::Other(
5228 "runner package materialization failed transiently".into(),
5229 ));
5230 }
5231 fs::create_dir_all(attempt.runtime_path()).unwrap();
5232 fs::write(attempt.runtime_path().join("runner-package"), b"verified").unwrap();
5233 self.leases.lock().unwrap().insert(attempt.id);
5234 Ok(self.version.clone())
5235 }
5236
5237 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
5238 if self
5239 .release_failures
5240 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5241 if left > 0 { Some(left - 1) } else { None }
5242 })
5243 .is_ok()
5244 {
5245 return Err(FailureReason::Other(
5246 "runner package lease could not be released".into(),
5247 ));
5248 }
5249 self.leases.lock().unwrap().remove(&attempt);
5250 self.releases.fetch_add(1, Ordering::SeqCst);
5251 Ok(())
5252 }
5253
5254 fn prune_obsolete_guarded(
5255 &self,
5256 _authority: PruneAuthority<'_>,
5257 current: &RunnerVersion,
5258 _attempts: &[RunnerAttempt],
5259 ) -> Result<(), FailureReason> {
5260 self.prunes.fetch_add(1, Ordering::SeqCst);
5261 self.prune_currents.lock().unwrap().push(current.clone());
5262 Ok(())
5263 }
5264 }
5265
5266 #[derive(Debug, Default)]
5267 struct FakeProcesses {
5268 alive: AtomicBool,
5269 completed_successfully: AtomicBool,
5270 spawns: AtomicUsize,
5271 spawn_failures: AtomicUsize,
5272 live_spawn_failure: AtomicBool,
5273 terminations: AtomicUsize,
5274 intent: AtomicBool,
5275 intent_failure: AtomicBool,
5276 actions: Mutex<Vec<&'static str>>,
5277 saw_secret: AtomicBool,
5278 }
5279
5280 impl FakeProcesses {
5281 fn fail_spawns(&self, count: usize) {
5282 self.spawn_failures.store(count, Ordering::SeqCst);
5283 }
5284
5285 fn fail_spawn_with_live_child(&self) {
5286 self.live_spawn_failure.store(true, Ordering::SeqCst);
5287 }
5288
5289 fn set_alive(&self, alive: bool) {
5290 self.alive.store(alive, Ordering::SeqCst);
5291 }
5292
5293 fn finish_successfully(&self) {
5294 self.completed_successfully.store(true, Ordering::SeqCst);
5295 self.alive.store(false, Ordering::SeqCst);
5296 }
5297
5298 fn fail_intent(&self) {
5299 self.intent_failure.store(true, Ordering::SeqCst);
5300 }
5301 }
5302
5303 impl ProcessSupervisor for FakeProcesses {
5304 fn spawn(
5305 &self,
5306 attempt: &RunnerAttempt,
5307 config: &EncodedJitConfig,
5308 ) -> Result<u32, ProcessStartFailure> {
5309 self.spawns.fetch_add(1, Ordering::SeqCst);
5310 let handoff = RestrictiveHandoff::create(
5313 attempt.runtime_path(),
5314 SecretString::from(config.expose().to_owned()),
5315 )
5316 .unwrap();
5317 self.saw_secret
5318 .store(config.expose() == JIT, Ordering::SeqCst);
5319 let handoff_path = handoff.path().to_path_buf();
5320 let failing = self
5321 .spawn_failures
5322 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5323 if left > 0 { Some(left - 1) } else { None }
5324 })
5325 .is_ok();
5326 drop(handoff);
5327 assert!(!handoff_path.exists(), "handoff must be absent on return");
5328 if self.live_spawn_failure.swap(false, Ordering::SeqCst) {
5329 self.alive.store(true, Ordering::SeqCst);
5330 return Err(ProcessStartFailure::after_spawn_live(4242));
5331 }
5332 if failing {
5333 return Err(ProcessStartFailure::before_spawn(
5334 FailureReason::ProcessStartFailed,
5335 ));
5336 }
5337 self.alive.store(true, Ordering::SeqCst);
5338 Ok(4242)
5339 }
5340
5341 fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
5342 self.actions.lock().unwrap().push("observe_process");
5343 Ok(self.alive.load(Ordering::SeqCst))
5344 }
5345
5346 fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
5347 Ok(self.alive.load(Ordering::SeqCst).then_some(4242))
5348 }
5349
5350 fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
5351 self.completed_successfully.load(Ordering::SeqCst)
5352 }
5353
5354 fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5355 self.actions.lock().unwrap().push("terminate_intent");
5356 if self.intent_failure.load(Ordering::SeqCst) {
5357 return Err(FailureReason::Other(
5358 "terminate intent directory sync failed".into(),
5359 ));
5360 }
5361 self.intent.store(true, Ordering::SeqCst);
5362 Ok(())
5363 }
5364
5365 fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
5366 self.intent.load(Ordering::SeqCst)
5367 }
5368
5369 fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5370 assert!(
5371 self.intent.load(Ordering::SeqCst),
5372 "the durable intent must exist before signalling"
5373 );
5374 self.actions.lock().unwrap().push("terminate");
5375 self.terminations.fetch_add(1, Ordering::SeqCst);
5376 self.alive.store(false, Ordering::SeqCst);
5377 Ok(())
5378 }
5379 }
5380
5381 #[derive(Debug, Default)]
5382 struct FakeIsolatedProvider {
5383 resources: Mutex<BTreeMap<AttemptId, (EnvironmentIdentity, EnvironmentState)>>,
5384 extra_resources: Mutex<Vec<EnvironmentIdentity>>,
5385 unsupported_host: AtomicBool,
5386 permission_denied: AtomicBool,
5387 incompatible_image: AtomicBool,
5388 prepare_failure: AtomicBool,
5389 recovery_unavailable: AtomicBool,
5390 destroy_deferred: AtomicBool,
5391 handoffs: AtomicUsize,
5392 completed_successfully: AtomicBool,
5393 native_calls: AtomicUsize,
5394 actions: Mutex<Vec<&'static str>>,
5395 }
5396
5397 impl FakeIsolatedProvider {
5398 fn identity(attempt: &RunnerAttempt, host: HostId) -> EnvironmentIdentity {
5399 let AttemptExecution::Isolated {
5400 provider_kind,
5401 resolved_image,
5402 generation,
5403 ..
5404 } = attempt.execution()
5405 else {
5406 panic!("isolated intent required")
5407 };
5408 EnvironmentIdentity::Isolated {
5409 host,
5410 attempt: attempt.id,
5411 provider_kind: *provider_kind,
5412 environment_id: format!("environment-{}", attempt.id),
5413 resolved_image: resolved_image.clone(),
5414 generation: generation.clone(),
5415 }
5416 }
5417
5418 fn resource_count(&self) -> usize {
5419 self.resources.lock().unwrap().len()
5420 }
5421 }
5422
5423 impl ExecutionProvider for FakeIsolatedProvider {
5424 fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
5425 if self.unsupported_host.load(Ordering::SeqCst) {
5426 ProviderCapability::Unsupported
5427 } else if self.permission_denied.load(Ordering::SeqCst) {
5428 ProviderCapability::PermissionDenied
5429 } else if policy.execution_policy().is_native() {
5430 ProviderCapability::Unsupported
5431 } else {
5432 ProviderCapability::Ready
5433 }
5434 }
5435
5436 fn resolve(
5437 &self,
5438 policy: &ScalePolicy,
5439 ) -> Result<Option<ResolvedEnvironment>, FailureReason> {
5440 let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
5441 policy.execution_policy()
5442 else {
5443 return Err(FailureReason::Other("isolated policy required".into()));
5444 };
5445 Ok(Some(ResolvedEnvironment {
5446 provider_kind: Backend::Oci,
5447 image: if self.incompatible_image.load(Ordering::SeqCst) {
5448 ImageReference::new("vm-version:wrong-provider")
5449 .expect("valid but incompatible")
5450 } else {
5451 image.clone()
5452 },
5453 }))
5454 }
5455
5456 fn prepare(
5457 &self,
5458 attempt: &RunnerAttempt,
5459 policy: &ScalePolicy,
5460 ) -> Result<PreparedEnvironment, FailureReason> {
5461 assert_eq!(
5462 attempt.state(),
5463 AttemptState::Preparing,
5464 "intent must be durable before provider effect"
5465 );
5466 self.actions.lock().unwrap().push("prepare");
5467 let identity = Self::identity(attempt, policy.host_id);
5468 self.resources
5469 .lock()
5470 .unwrap()
5471 .insert(attempt.id, (identity.clone(), EnvironmentState::Starting));
5472 if self.prepare_failure.swap(false, Ordering::SeqCst) {
5473 return Err(FailureReason::Other("provider-secret-needle".into()));
5474 }
5475 Ok(PreparedEnvironment::isolated(attempt.id, identity))
5476 }
5477
5478 fn start(
5479 &self,
5480 prepared: PreparedEnvironment,
5481 attempt: &RunnerAttempt,
5482 handoff: OneTimeJitHandoff<'_>,
5483 ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
5484 assert_eq!(prepared.attempt, attempt.id);
5485 assert_eq!(attempt.state(), AttemptState::JitReceived);
5486 assert_eq!(handoff.consume().expose(), JIT);
5487 self.handoffs.fetch_add(1, Ordering::SeqCst);
5488 self.actions.lock().unwrap().push("start");
5489 let mut resources = self.resources.lock().unwrap();
5490 let (identity, state) = resources.get_mut(&attempt.id).expect("prepared resource");
5491 *state = EnvironmentState::Running;
5492 Ok(identity.clone())
5493 }
5494
5495 fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
5496 self.actions.lock().unwrap().push("inspect");
5497 Ok(self
5498 .resources
5499 .lock()
5500 .unwrap()
5501 .get(&attempt.id)
5502 .map_or(EnvironmentState::Missing, |(_, state)| *state))
5503 }
5504
5505 fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
5506 if self.recovery_unavailable.load(Ordering::SeqCst) {
5507 return Err(FailureReason::Other(
5508 "provider discovery unavailable".into(),
5509 ));
5510 }
5511 self.inspect(attempt)
5512 }
5513
5514 fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
5515 Ok(self
5516 .resources
5517 .lock()
5518 .unwrap()
5519 .get(&attempt.id)
5520 .is_some_and(|(identity, _)| {
5521 identity_matches_intent(attempt_host(identity), attempt, identity)
5522 }))
5523 }
5524
5525 fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
5526 assert!(self.owns(attempt)?);
5527 self.actions.lock().unwrap().push("stop");
5528 if let Some((_, state)) = self.resources.lock().unwrap().get_mut(&attempt.id) {
5529 *state = EnvironmentState::Exited;
5530 }
5531 Ok(ProviderStop::Stopped)
5532 }
5533
5534 fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
5535 assert!(self.owns(attempt)?);
5536 self.actions.lock().unwrap().push("destroy");
5537 if self.destroy_deferred.load(Ordering::SeqCst) {
5538 return Ok(ProviderDestroy::Deferred("provider busy"));
5539 }
5540 self.resources.lock().unwrap().remove(&attempt.id);
5541 Ok(ProviderDestroy::Destroyed)
5542 }
5543
5544 fn enumerate_owned(&self, host: HostId) -> Vec<EnvironmentIdentity> {
5545 let mut owned = self
5546 .resources
5547 .lock()
5548 .unwrap()
5549 .values()
5550 .filter(|(identity, _)| attempt_host(identity) == host)
5551 .map(|(identity, _)| identity.clone())
5552 .collect::<Vec<_>>();
5553 owned.extend(
5554 self.extra_resources
5555 .lock()
5556 .unwrap()
5557 .iter()
5558 .filter(|identity| attempt_host(identity) == host)
5559 .cloned(),
5560 );
5561 owned
5562 }
5563
5564 fn diagnostics(&self, _attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
5565 vec![ProviderDiagnostic::CleanupDeferred; 40]
5566 }
5567
5568 fn spawn(
5569 &self,
5570 _attempt: &RunnerAttempt,
5571 _config: &EncodedJitConfig,
5572 ) -> Result<u32, ProcessStartFailure> {
5573 self.native_calls.fetch_add(1, Ordering::SeqCst);
5574 panic!("isolated provider must never spawn natively")
5575 }
5576 fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
5577 panic!("native fallback")
5578 }
5579 fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
5580 panic!("native fallback")
5581 }
5582 fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
5583 self.completed_successfully.load(Ordering::SeqCst)
5584 }
5585 fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5586 panic!("native fallback")
5587 }
5588 fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
5589 false
5590 }
5591 fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5592 panic!("native fallback")
5593 }
5594 }
5595
5596 fn attempt_host(identity: &EnvironmentIdentity) -> HostId {
5597 let EnvironmentIdentity::Isolated { host, .. } = identity else {
5598 panic!("isolated identity")
5599 };
5600 *host
5601 }
5602
5603 #[derive(Debug, Default)]
5604 struct FakeDemand {
5605 answers: Mutex<VecDeque<bool>>,
5606 }
5607
5608 impl FakeDemand {
5609 fn answering(answers: impl IntoIterator<Item = bool>) -> Self {
5610 Self {
5611 answers: Mutex::new(answers.into_iter().collect()),
5612 }
5613 }
5614 }
5615
5616 #[async_trait]
5617 impl DemandPersistence for FakeDemand {
5618 async fn persists(&self, _policy: PolicyId) -> bool {
5619 self.answers.lock().unwrap().pop_front().unwrap_or(true)
5620 }
5621 }
5622
5623 #[derive(Debug, Default)]
5624 struct FakeDelay(Mutex<Vec<Duration>>);
5625
5626 #[async_trait]
5627 impl RetryDelay for FakeDelay {
5628 async fn wait(&self, duration: Duration) {
5629 self.0.lock().unwrap().push(duration);
5630 }
5631 }
5632
5633 struct Harness {
5634 _root: tempfile::TempDir,
5635 app_paths: runner_manager_platform::paths::AppPaths,
5636 launcher: LifecycleLauncher,
5637 demand: Arc<dyn DemandPersistence>,
5638 store: Arc<SqliteStore>,
5639 github: Arc<FakeGithubLifecycle>,
5640 packages: Arc<FakePackages>,
5641 processes: Arc<FakeProcesses>,
5642 clock: Arc<FakeClock>,
5643 events: Arc<AttemptEventLog>,
5644 reconcile_events: Arc<crate::reconcile::EventLog>,
5645 delay: Arc<FakeDelay>,
5646 host: runner_manager_domain::model::Host,
5647 policy: ScalePolicy,
5648 allocation_lock: InProcessAllocationLock,
5649 workspace_root: Option<LocalAbsolutePath>,
5651 }
5652
5653 impl Harness {
5654 fn new(github: FakeGithubLifecycle, demand: Arc<dyn DemandPersistence>) -> Self {
5655 let root = tempfile::tempdir().unwrap();
5656 let paths = runner_manager_platform::paths::AppPaths::rooted_at(root.path());
5657 paths.create_all().unwrap();
5658 let policy = fixtures::policy()
5659 .repository("octo/repo")
5660 .autoscale("home", 2)
5661 .active()
5662 .build();
5663 let host_root = root.path().join("host-root");
5669 fs::create_dir_all(&host_root).unwrap();
5670 let mut host = fixtures::host().build();
5671 host.runner_root_override = Some(
5672 LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
5673 .expect("a local absolute host root"),
5674 );
5675 let store = Arc::new(SqliteStore::open_in_memory().unwrap());
5676 store.put_host(&host).unwrap();
5677 let github = Arc::new(github);
5678 let packages = Arc::new(FakePackages::default());
5679 let processes = Arc::new(FakeProcesses::default());
5680 let clock = Arc::new(FakeClock::default());
5681 let events = Arc::new(AttemptEventLog::default());
5682 let reconcile_events = Arc::new(crate::reconcile::EventLog::new());
5683 let delay = Arc::new(FakeDelay::default());
5684 let ports = LifecyclePorts {
5685 store: Arc::clone(&store) as Arc<dyn Store>,
5686 github: Arc::clone(&github) as Arc<dyn LifecycleGithub>,
5687 packages: Arc::clone(&packages) as Arc<dyn RuntimePackages>,
5688 processes: Arc::clone(&processes) as Arc<dyn ProcessSupervisor>,
5689 clock: Arc::clone(&clock) as Arc<dyn Clock>,
5690 demand: Arc::clone(&demand),
5691 delay: Arc::clone(&delay) as Arc<dyn RetryDelay>,
5692 events: Arc::clone(&events) as Arc<dyn AttemptEventSink>,
5693 reconcile_events: Arc::clone(&reconcile_events) as Arc<dyn EventSink>,
5694 };
5695 let launcher = Self::launcher_over(policy.host_id, &paths, ports);
5696 Self {
5697 _root: root,
5698 app_paths: paths,
5699 launcher,
5700 demand,
5701 store,
5702 github,
5703 packages,
5704 processes,
5705 clock,
5706 events,
5707 reconcile_events,
5708 delay,
5709 host,
5710 policy,
5711 allocation_lock: InProcessAllocationLock::new(),
5712 workspace_root: None,
5713 }
5714 }
5715
5716 fn with_host_runner_root(mut self) -> Self {
5723 let host_root = self.host_root();
5724 fs::create_dir_all(&host_root).unwrap();
5725 self.host.runner_root_override = Some(
5726 LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
5727 .expect("a local absolute host root"),
5728 );
5729 self.store.put_host(&self.host).unwrap();
5730 self
5731 }
5732
5733 fn with_persistent_workspace(mut self, capacity: u16) -> Self {
5735 self = self.with_host_runner_root();
5736 let root = self._root.path().join("persist");
5737 let root = LocalAbsolutePath::new(root.to_str().expect("a UTF-8 temporary path"))
5738 .expect("a local absolute workspace root");
5739 self.policy = fixtures::policy()
5740 .repository("octo/repo")
5741 .autoscale("home", capacity)
5742 .active()
5743 .build();
5744 self.policy
5745 .set_workspace_policy(
5746 WorkspacePolicy::persistent(root.clone(), TargetScope::Repository)
5747 .expect("a repository may be persistent"),
5748 )
5749 .expect("a repository may be persistent");
5750 self.workspace_root = Some(root);
5751 self.store.insert_policy(&self.policy).unwrap();
5754 self
5755 }
5756
5757 fn workspace_root(&self) -> &LocalAbsolutePath {
5758 self.workspace_root
5759 .as_ref()
5760 .expect("this harness configured a persistent workspace")
5761 }
5762
5763 fn slot_path(&self, slot: u16) -> PathBuf {
5764 self.workspace_root().as_path().join(format!("s{slot}"))
5765 }
5766
5767 fn host_root(&self) -> PathBuf {
5768 self._root.path().join("host-root")
5769 }
5770
5771 fn attempt(&self, id: AttemptId) -> RunnerAttempt {
5772 self.store
5773 .attempt(id)
5774 .unwrap()
5775 .expect("the attempt is journalled")
5776 }
5777
5778 fn conclude(&self, id: AttemptId) -> RunnerAttempt {
5780 let mut attempt = self.attempt(id);
5781 attempt
5782 .conclude(
5783 AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly),
5784 self.clock.now(),
5785 )
5786 .unwrap();
5787 self.store.record_attempt(&attempt).unwrap();
5788 attempt
5789 }
5790
5791 async fn cleanup_retaining_work(&self, id: AttemptId) {
5792 self.conclude(id);
5793 self.launcher
5794 .clean(id)
5795 .await
5796 .expect("the slot is scrubbed and the lease released");
5797 }
5798
5799 fn launcher_over(
5802 host: HostId,
5803 paths: &runner_manager_platform::paths::AppPaths,
5804 ports: LifecyclePorts,
5805 ) -> LifecycleLauncher {
5806 LifecycleLauncher::new(
5807 host,
5808 paths.clone(),
5809 paths.logs_dir(),
5810 1,
5811 RecoveryTimeouts::new(
5812 Elapsed::seconds(10),
5813 Elapsed::seconds(10),
5814 Elapsed::seconds(10),
5815 ),
5816 RetryPolicy::bounded(3, Duration::from_millis(10), Duration::from_millis(25)),
5817 ports,
5818 )
5819 }
5820
5821 fn restart(&self) -> LifecycleLauncher {
5824 Self::launcher_over(
5825 self.policy.host_id,
5826 &self.app_paths,
5827 LifecyclePorts {
5828 store: Arc::clone(&self.store) as Arc<dyn Store>,
5829 github: Arc::clone(&self.github) as Arc<dyn LifecycleGithub>,
5830 packages: Arc::clone(&self.packages) as Arc<dyn RuntimePackages>,
5831 processes: Arc::clone(&self.processes) as Arc<dyn ProcessSupervisor>,
5832 clock: Arc::clone(&self.clock) as Arc<dyn Clock>,
5833 demand: Arc::clone(&self.demand),
5834 delay: Arc::clone(&self.delay) as Arc<dyn RetryDelay>,
5835 events: Arc::clone(&self.events) as Arc<dyn AttemptEventSink>,
5836 reconcile_events: Arc::clone(&self.reconcile_events) as Arc<dyn EventSink>,
5837 },
5838 )
5839 }
5840
5841 async fn ready(&self) {
5842 self.launcher
5843 .recover_startup(std::slice::from_ref(&self.policy))
5844 .await
5845 .unwrap();
5846 }
5847
5848 async fn launch(&self) -> RunnerAttempt {
5849 self.launch_result().await.unwrap()
5850 }
5851
5852 async fn launch_result(&self) -> Result<RunnerAttempt, LaunchFailure> {
5853 self.launch_after_waiting(Duration::ZERO).await
5854 }
5855
5856 async fn launch_after_waiting(
5857 &self,
5858 lock_wait: Duration,
5859 ) -> Result<RunnerAttempt, LaunchFailure> {
5860 let guard = self.allocation_lock.acquire().await.unwrap();
5861 self.launcher
5862 .launch(LaunchRequest {
5863 host: &self.host,
5864 policy: &self.policy,
5865 allocation_guard: &guard,
5866 lock_wait,
5867 })
5868 .await
5869 }
5870
5871 fn only_attempt(&self) -> RunnerAttempt {
5872 self.store.attempts().unwrap().into_iter().next().unwrap()
5873 }
5874 }
5875
5876 fn isolated_harness() -> Harness {
5877 use runner_manager_domain::execution::{ExecutionPolicy, ResourceLimits};
5878 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5879 .with_host_runner_root();
5880 let image =
5881 ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
5882 .expect("pinned image");
5883 harness
5884 .policy
5885 .set_execution_policy(ExecutionPolicy::Isolated {
5886 backend: Backend::Oci,
5887 image,
5888 resources: ResourceLimits {
5889 cpu_millis: 1000,
5890 memory_mib: 1024,
5891 disk_mib: 4096,
5892 },
5893 })
5894 .expect("isolated policy");
5895 harness.github.watch_journal(Arc::clone(&harness.store));
5896 harness
5897 }
5898
5899 fn launcher_with_isolated(
5900 harness: &Harness,
5901 provider: Arc<FakeIsolatedProvider>,
5902 ) -> LifecycleLauncher {
5903 Harness::launcher_over(
5904 harness.policy.host_id,
5905 &harness.app_paths,
5906 LifecyclePorts {
5907 store: Arc::clone(&harness.store) as Arc<dyn Store>,
5908 github: Arc::clone(&harness.github) as Arc<dyn LifecycleGithub>,
5909 packages: Arc::clone(&harness.packages) as Arc<dyn RuntimePackages>,
5910 processes: provider as Arc<dyn ExecutionProvider>,
5911 clock: Arc::clone(&harness.clock) as Arc<dyn Clock>,
5912 demand: Arc::clone(&harness.demand),
5913 delay: Arc::clone(&harness.delay) as Arc<dyn RetryDelay>,
5914 events: Arc::clone(&harness.events) as Arc<dyn AttemptEventSink>,
5915 reconcile_events: Arc::clone(&harness.reconcile_events) as Arc<dyn EventSink>,
5916 },
5917 )
5918 }
5919
5920 #[tokio::test]
5921 async fn isolated_policy_and_journal_never_enter_native_provider_or_jit() {
5922 use runner_manager_domain::execution::{
5923 AttemptExecution, Backend, ExecutionPolicy, ImageReference, ResourceLimits,
5924 };
5925
5926 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5927 .with_host_runner_root();
5928 let image =
5929 ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
5930 .expect("pinned image");
5931 harness
5932 .policy
5933 .set_execution_policy(ExecutionPolicy::Isolated {
5934 backend: Backend::Oci,
5935 image: image.clone(),
5936 resources: ResourceLimits {
5937 cpu_millis: 1000,
5938 memory_mib: 1024,
5939 disk_mib: 4096,
5940 },
5941 })
5942 .expect("valid isolated profile");
5943
5944 harness.ready().await;
5945 assert_eq!(
5946 harness.processes.probe(&harness.policy),
5947 ProviderCapability::Unsupported
5948 );
5949 let failure = harness
5950 .launch_result()
5951 .await
5952 .expect_err("provider is unavailable");
5953 assert_eq!(
5954 failure.reason,
5955 FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
5956 );
5957
5958 let runtime = harness.host_root().join("isolated-owned-environment");
5959 fs::create_dir_all(&runtime).expect("provider resource fixture");
5960 fs::write(runtime.join("owned-marker"), "non-secret").expect("fixture marker");
5961 let mut attempt = RunnerAttempt::allocate(
5962 AttemptId::from_u128(0x123),
5963 harness.policy.id,
5964 runtime.clone(),
5965 harness.clock.now(),
5966 );
5967 attempt
5968 .allocate_execution(AttemptExecution::Isolated {
5969 provider_kind: Backend::Oci,
5970 environment_id: None,
5971 resolved_image: image,
5972 generation: "generation-123".into(),
5973 })
5974 .expect("isolated intent");
5975 attempt
5976 .prepared_environment("environment-123".into())
5977 .expect("prepared identity");
5978 harness
5979 .store
5980 .record_attempt(&attempt)
5981 .expect("durable isolated journal");
5982
5983 harness
5984 .launcher
5985 .recover_startup(std::slice::from_ref(&harness.policy))
5986 .await
5987 .expect("unsupported isolated cleanup is profile-local");
5988 let failure = harness
5989 .launch_result()
5990 .await
5991 .expect_err("provider unavailable for this policy");
5992 assert_eq!(
5993 failure.reason,
5994 FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
5995 );
5996 harness
5997 .launcher
5998 .supervise(&harness.policy)
5999 .await
6000 .expect("unavailable attempt is deferred");
6001 assert!(harness.launcher.clean(attempt.id).await.is_err());
6002 assert_eq!(
6003 harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6004 AttemptState::CleanupDeferred
6005 );
6006 assert!(matches!(
6007 harness.launcher.recover_startup(&[]).await,
6008 Err(LifecycleError::RecoveryIncomplete)
6009 ));
6010 assert!(
6011 runtime.join("owned-marker").is_file(),
6012 "native cleanup touched the isolated environment"
6013 );
6014 assert!(
6015 harness.processes.actions.lock().unwrap().is_empty(),
6016 "native recovery inspected an isolated identity"
6017 );
6018 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
6019 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6020 assert_eq!(harness.packages.materializations.load(Ordering::SeqCst), 0);
6021 }
6022
6023 #[tokio::test]
6024 async fn isolated_prepare_is_durable_before_jit_and_start_uses_one_handoff() {
6025 let harness = isolated_harness();
6026 let provider = Arc::new(FakeIsolatedProvider::default());
6027 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6028 launcher
6029 .recover_startup(std::slice::from_ref(&harness.policy))
6030 .await
6031 .expect("ready");
6032 let guard = harness.allocation_lock.acquire().await.unwrap();
6033 let attempt = launcher
6034 .launch(LaunchRequest {
6035 host: &harness.host,
6036 policy: &harness.policy,
6037 allocation_guard: &guard,
6038 lock_wait: Duration::ZERO,
6039 })
6040 .await
6041 .expect("isolated launch");
6042 assert_eq!(attempt.state(), AttemptState::Starting);
6043 assert_eq!(provider.resource_count(), 1);
6044 assert_eq!(provider.handoffs.load(Ordering::SeqCst), 1);
6045 assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6046 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
6047 let fact = harness
6048 .github
6049 .registration_facts()
6050 .pop()
6051 .expect("JIT registration");
6052 assert_eq!(
6053 fact.journal_states,
6054 vec![AttemptState::Prepared],
6055 "provider readiness preceded GitHub JIT"
6056 );
6057 assert_eq!(
6058 harness
6059 .events
6060 .events()
6061 .into_iter()
6062 .filter_map(|event| match event {
6063 AttemptEvent::State { state, .. } => Some(state),
6064 _ => None,
6065 })
6066 .collect::<Vec<_>>(),
6067 vec![
6068 AttemptState::Allocated,
6069 AttemptState::Preparing,
6070 AttemptState::Prepared,
6071 AttemptState::JitReceived,
6072 AttemptState::Starting
6073 ]
6074 );
6075 let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
6076 restarted
6077 .recover_startup(std::slice::from_ref(&harness.policy))
6078 .await
6079 .expect("adopt live sandbox");
6080 assert_eq!(provider.resource_count(), 1);
6081 assert_eq!(
6082 harness.github.registrations.load(Ordering::SeqCst),
6083 1,
6084 "restart cannot issue duplicate JIT"
6085 );
6086 harness
6087 .github
6088 .observe(GithubRunnerObservation::Registered { busy: true });
6089 restarted
6090 .supervise(&harness.policy)
6091 .await
6092 .expect("job observed");
6093 assert_eq!(
6094 harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6095 AttemptState::Busy
6096 );
6097 provider
6098 .resources
6099 .lock()
6100 .unwrap()
6101 .get_mut(&attempt.id)
6102 .unwrap()
6103 .1 = EnvironmentState::Exited;
6104 harness
6105 .github
6106 .observe(GithubRunnerObservation::NotRegistered);
6107 restarted
6108 .supervise(&harness.policy)
6109 .await
6110 .expect("terminal cleanup");
6111 assert_eq!(provider.resource_count(), 0);
6112 assert_eq!(
6113 harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6114 AttemptState::Cleaned
6115 );
6116 }
6117
6118 #[tokio::test]
6119 async fn attached_isolated_job_exit_is_recorded_as_completed() {
6120 let harness = isolated_harness();
6121 let provider = Arc::new(FakeIsolatedProvider::default());
6122 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6123 launcher
6124 .recover_startup(std::slice::from_ref(&harness.policy))
6125 .await
6126 .unwrap();
6127 let guard = harness.allocation_lock.acquire().await.unwrap();
6128 let attempt = launcher
6129 .launch(LaunchRequest {
6130 host: &harness.host,
6131 policy: &harness.policy,
6132 allocation_guard: &guard,
6133 lock_wait: Duration::ZERO,
6134 })
6135 .await
6136 .unwrap();
6137 harness
6138 .github
6139 .observe(GithubRunnerObservation::Registered { busy: true });
6140 launcher.supervise(&harness.policy).await.unwrap();
6141 provider
6142 .resources
6143 .lock()
6144 .unwrap()
6145 .get_mut(&attempt.id)
6146 .unwrap()
6147 .1 = EnvironmentState::Exited;
6148 provider
6149 .completed_successfully
6150 .store(true, Ordering::SeqCst);
6151 harness
6152 .github
6153 .observe(GithubRunnerObservation::NotRegistered);
6154 launcher.supervise(&harness.policy).await.unwrap();
6155 let cleaned = harness.store.attempt(attempt.id).unwrap().unwrap();
6156 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
6157 assert_eq!(cleaned.state(), AttemptState::Cleaned);
6158 }
6159
6160 #[tokio::test]
6161 async fn isolated_prepare_failure_stays_local_and_crash_gap_recovers_owned_resource() {
6162 let harness = isolated_harness();
6163 let provider = Arc::new(FakeIsolatedProvider::default());
6164 provider.prepare_failure.store(true, Ordering::SeqCst);
6165 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6166 launcher
6167 .recover_startup(std::slice::from_ref(&harness.policy))
6168 .await
6169 .expect("ready");
6170 let guard = harness.allocation_lock.acquire().await.unwrap();
6171 let failure = launcher
6172 .launch(LaunchRequest {
6173 host: &harness.host,
6174 policy: &harness.policy,
6175 allocation_guard: &guard,
6176 lock_wait: Duration::ZERO,
6177 })
6178 .await
6179 .expect_err("prepare failed after creating resource");
6180 assert_eq!(
6181 failure.reason,
6182 FailureReason::IsolationProvider(IsolationProviderFailure::RuntimeOperationFailed)
6183 );
6184 assert_eq!(provider.resource_count(), 1);
6185 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6186 assert!(
6187 !harness
6188 .store
6189 .dump_text()
6190 .unwrap()
6191 .contains("provider-secret-needle")
6192 );
6193 let id = harness.store.attempts().unwrap()[0].id;
6194 assert_eq!(
6195 harness.store.attempt(id).unwrap().unwrap().outcome(),
6196 Some(&AttemptOutcome::failed(FailureReason::IsolationProvider(
6197 IsolationProviderFailure::RuntimeOperationFailed
6198 )))
6199 );
6200 let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
6201 restarted
6202 .recover_startup(std::slice::from_ref(&harness.policy))
6203 .await
6204 .expect("profile-local recovery");
6205 assert_eq!(
6206 provider.resource_count(),
6207 0,
6208 "the exact owned resource was destroyed once"
6209 );
6210 assert_eq!(
6211 harness.store.attempt(id).unwrap().unwrap().state(),
6212 AttemptState::Cleaned
6213 );
6214 let diagnostic =
6215 fs::read_to_string(restarted.diagnostics_root.join(format!("{id}.log"))).unwrap();
6216 assert!(diagnostic.contains("reason=isolation_runtime_operation_failed"));
6217 assert!(!diagnostic.contains("provider-secret-needle"));
6218 assert_eq!(
6219 provider
6220 .actions
6221 .lock()
6222 .unwrap()
6223 .iter()
6224 .filter(|action| **action == "destroy")
6225 .count(),
6226 1
6227 );
6228 assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6229 }
6230
6231 #[test]
6232 fn isolation_capability_and_adapter_failures_keep_closed_categories() {
6233 let cases = [
6234 (
6235 ProviderCapability::NotInstalled,
6236 IsolationProviderFailure::NotInstalled,
6237 ),
6238 (
6239 ProviderCapability::PermissionDenied,
6240 IsolationProviderFailure::PermissionDenied,
6241 ),
6242 (
6243 ProviderCapability::ImageUnavailableOrIncompatible,
6244 IsolationProviderFailure::ImageUnavailableOrIncompatible,
6245 ),
6246 (
6247 ProviderCapability::DiskQuotaUnavailable,
6248 IsolationProviderFailure::DiskQuotaUnavailable,
6249 ),
6250 (
6251 ProviderCapability::Degraded,
6252 IsolationProviderFailure::Degraded,
6253 ),
6254 ];
6255 for (capability, category) in cases {
6256 assert_eq!(
6257 capability.refusal(),
6258 FailureReason::IsolationProvider(category)
6259 );
6260 }
6261 let raw = FailureReason::Other("ghp_secret_from_provider".into());
6262 let safe = safe_isolation_reason(raw, IsolationProviderFailure::RuntimeOperationFailed);
6263 assert_eq!(
6264 safe,
6265 FailureReason::IsolationProvider(IsolationProviderFailure::RuntimeOperationFailed)
6266 );
6267 assert!(!safe.to_string().contains("ghp_secret"));
6268 assert_eq!(
6269 safe_isolation_reason(
6270 FailureReason::IsolationProvider(IsolationProviderFailure::PermissionDenied),
6271 IsolationProviderFailure::RuntimeOperationFailed,
6272 ),
6273 FailureReason::IsolationProvider(IsolationProviderFailure::PermissionDenied)
6274 );
6275 }
6276
6277 #[tokio::test]
6278 async fn isolated_permission_and_image_failures_stop_before_jit_without_native_fallback() {
6279 for image_failure in [false, true] {
6280 let harness = isolated_harness();
6281 let provider = Arc::new(FakeIsolatedProvider::default());
6282 provider
6283 .permission_denied
6284 .store(!image_failure, Ordering::SeqCst);
6285 provider
6286 .incompatible_image
6287 .store(image_failure, Ordering::SeqCst);
6288 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6289 launcher
6290 .recover_startup(std::slice::from_ref(&harness.policy))
6291 .await
6292 .unwrap();
6293 let guard = harness.allocation_lock.acquire().await.unwrap();
6294 let error = launcher
6295 .launch(LaunchRequest {
6296 host: &harness.host,
6297 policy: &harness.policy,
6298 allocation_guard: &guard,
6299 lock_wait: Duration::ZERO,
6300 })
6301 .await
6302 .expect_err("pre-JIT refusal");
6303 let expected = if image_failure {
6304 "resolved image is incompatible"
6305 } else {
6306 "permission denied"
6307 };
6308 assert!(error.reason.to_string().contains(expected));
6309 assert!(
6310 harness.store.attempts().unwrap().is_empty(),
6311 "no allocation was journalled"
6312 );
6313 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6314 assert_eq!(provider.handoffs.load(Ordering::SeqCst), 0);
6315 assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6316 }
6317 }
6318
6319 #[tokio::test]
6320 async fn unsupported_host_stops_before_jit_without_allocation_or_native_fallback() {
6321 let harness = isolated_harness();
6322 let provider = Arc::new(FakeIsolatedProvider::default());
6323 provider.unsupported_host.store(true, Ordering::SeqCst);
6324 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6325 launcher
6326 .recover_startup(std::slice::from_ref(&harness.policy))
6327 .await
6328 .unwrap();
6329 let guard = harness.allocation_lock.acquire().await.unwrap();
6330 let error = launcher
6331 .launch(LaunchRequest {
6332 host: &harness.host,
6333 policy: &harness.policy,
6334 allocation_guard: &guard,
6335 lock_wait: Duration::ZERO,
6336 })
6337 .await
6338 .expect_err("unsupported host must be refused before JIT");
6339
6340 assert_eq!(
6341 error.reason,
6342 FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
6343 );
6344 assert!(harness.store.attempts().unwrap().is_empty());
6345 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6346 assert_eq!(provider.handoffs.load(Ordering::SeqCst), 0);
6347 assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6348 assert!(provider.actions.lock().unwrap().is_empty());
6349 }
6350
6351 #[tokio::test]
6352 async fn no_journalled_environment_id_waits_for_provider_absence_proof() {
6353 let harness = isolated_harness();
6354 let provider = Arc::new(FakeIsolatedProvider::default());
6355 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6356 let runtime = harness.host_root().join("prepare-without-identity");
6357 fs::create_dir_all(&runtime).unwrap();
6358 let mut attempt = RunnerAttempt::allocate(
6359 AttemptId::from_u128(0x9191),
6360 harness.policy.id,
6361 runtime,
6362 harness.clock.now(),
6363 );
6364 let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6365 harness.policy.execution_policy()
6366 else {
6367 unreachable!()
6368 };
6369 attempt
6370 .allocate_execution(AttemptExecution::Isolated {
6371 provider_kind: Backend::Oci,
6372 environment_id: None,
6373 resolved_image: image.clone(),
6374 generation: "prepare-generation".into(),
6375 })
6376 .unwrap();
6377 attempt.begin_prepare(harness.clock.now()).unwrap();
6378 attempt
6379 .conclude(
6380 AttemptOutcome::failed(FailureReason::Other("prepare interrupted".into())),
6381 harness.clock.now(),
6382 )
6383 .unwrap();
6384 harness.store.record_attempt(&attempt).unwrap();
6385
6386 provider.recovery_unavailable.store(true, Ordering::SeqCst);
6387 launcher
6388 .clean(attempt.id)
6389 .await
6390 .expect_err("absence unproven");
6391 let deferred = harness.store.attempt(attempt.id).unwrap().unwrap();
6392 assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
6393 assert!(deferred.counts_against_capacity());
6394
6395 provider.recovery_unavailable.store(false, Ordering::SeqCst);
6396 launcher.clean(attempt.id).await.expect("absence proven");
6397 assert_eq!(
6398 harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6399 AttemptState::Cleaned
6400 );
6401 }
6402
6403 #[tokio::test]
6404 async fn isolated_terminal_capacity_waits_for_confirmed_absence_and_generation_matches() {
6405 let harness = isolated_harness();
6406 let provider = Arc::new(FakeIsolatedProvider::default());
6407 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6408 launcher
6409 .recover_startup(std::slice::from_ref(&harness.policy))
6410 .await
6411 .expect("ready");
6412 let guard = harness.allocation_lock.acquire().await.unwrap();
6413 let mut attempt = launcher
6414 .launch(LaunchRequest {
6415 host: &harness.host,
6416 policy: &harness.policy,
6417 allocation_guard: &guard,
6418 lock_wait: Duration::ZERO,
6419 })
6420 .await
6421 .expect("launch");
6422 attempt
6423 .conclude(
6424 AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6425 harness.clock.now(),
6426 )
6427 .unwrap();
6428 harness.store.record_attempt(&attempt).unwrap();
6429 provider.destroy_deferred.store(true, Ordering::SeqCst);
6430 launcher
6431 .clean(attempt.id)
6432 .await
6433 .expect_err("provider deferred destroy");
6434 let deferred = harness.store.attempt(attempt.id).unwrap().unwrap();
6435 assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
6436 assert!(deferred.counts_against_capacity());
6437 assert_eq!(provider.resource_count(), 1);
6438 provider.destroy_deferred.store(false, Ordering::SeqCst);
6439 let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
6440 restarted
6441 .recover_startup(std::slice::from_ref(&harness.policy))
6442 .await
6443 .expect("retry cleanup");
6444 assert_eq!(provider.resource_count(), 0);
6445 assert!(
6446 !harness
6447 .store
6448 .attempt(attempt.id)
6449 .unwrap()
6450 .unwrap()
6451 .counts_against_capacity()
6452 );
6453
6454 let second_id = AttemptId::from_u128(0x9999);
6456 let second_runtime = harness.host_root().join("generation-mismatch");
6457 fs::create_dir_all(&second_runtime).unwrap();
6458 let mut terminal = RunnerAttempt::allocate(
6459 second_id,
6460 harness.policy.id,
6461 second_runtime,
6462 harness.clock.now(),
6463 );
6464 let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6465 harness.policy.execution_policy()
6466 else {
6467 unreachable!()
6468 };
6469 terminal
6470 .allocate_execution(AttemptExecution::Isolated {
6471 provider_kind: Backend::Oci,
6472 environment_id: None,
6473 resolved_image: image.clone(),
6474 generation: "expected-generation".into(),
6475 })
6476 .unwrap();
6477 let mut stranger = FakeIsolatedProvider::identity(&terminal, harness.policy.host_id);
6478 let EnvironmentIdentity::Isolated { environment_id, .. } = &stranger else {
6479 unreachable!()
6480 };
6481 terminal.begin_prepare(harness.clock.now()).unwrap();
6482 terminal
6483 .prepared_environment(environment_id.clone())
6484 .unwrap();
6485 terminal.mark_prepared(harness.clock.now()).unwrap();
6486 terminal.jit_received(harness.clock.now()).unwrap();
6487 terminal.started_isolated(harness.clock.now()).unwrap();
6488 terminal
6489 .conclude(
6490 AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6491 harness.clock.now(),
6492 )
6493 .unwrap();
6494 harness.store.record_attempt(&terminal).unwrap();
6495 let EnvironmentIdentity::Isolated { generation, .. } = &mut stranger else {
6496 unreachable!()
6497 };
6498 *generation = "another-generation".into();
6499 provider
6500 .resources
6501 .lock()
6502 .unwrap()
6503 .insert(second_id, (stranger, EnvironmentState::Running));
6504 restarted
6505 .clean(terminal.id)
6506 .await
6507 .expect_err("ownership mismatch quarantined");
6508 assert_eq!(provider.resource_count(), 1);
6509 assert_eq!(
6510 harness.store.attempt(terminal.id).unwrap().unwrap().state(),
6511 AttemptState::CleanupDeferred
6512 );
6513 assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6514 }
6515
6516 #[tokio::test]
6517 async fn isolated_restart_at_each_transition_adopts_or_destroys_one_resource() {
6518 for stage in [
6519 AttemptState::Allocated,
6520 AttemptState::Preparing,
6521 AttemptState::Prepared,
6522 AttemptState::JitReceived,
6523 AttemptState::Starting,
6524 AttemptState::Idle,
6525 AttemptState::Busy,
6526 AttemptState::Finished,
6527 AttemptState::Failed,
6528 AttemptState::Orphaned,
6529 AttemptState::Destroying,
6530 AttemptState::CleanupDeferred,
6531 ] {
6532 let harness = isolated_harness();
6533 let provider = Arc::new(FakeIsolatedProvider::default());
6534 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6535 let id = AttemptId::from_u128(0x1000 + stage as u128);
6536 let runtime = harness.host_root().join(format!("phase-{stage}"));
6537 fs::create_dir_all(&runtime).unwrap();
6538 let mut attempt =
6539 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
6540 let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6541 harness.policy.execution_policy()
6542 else {
6543 unreachable!()
6544 };
6545 attempt
6546 .allocate_execution(AttemptExecution::Isolated {
6547 provider_kind: Backend::Oci,
6548 environment_id: None,
6549 resolved_image: image.clone(),
6550 generation: format!("generation-{stage}"),
6551 })
6552 .unwrap();
6553 if stage != AttemptState::Allocated {
6554 attempt.begin_prepare(harness.clock.now()).unwrap();
6555 }
6556 let identity = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
6557 if stage != AttemptState::Allocated {
6558 let resource_state = if matches!(
6559 stage,
6560 AttemptState::Finished
6561 | AttemptState::Failed
6562 | AttemptState::Orphaned
6563 | AttemptState::Destroying
6564 | AttemptState::CleanupDeferred
6565 ) {
6566 EnvironmentState::Exited
6567 } else {
6568 EnvironmentState::Running
6569 };
6570 provider
6571 .resources
6572 .lock()
6573 .unwrap()
6574 .insert(id, (identity.clone(), resource_state));
6575 }
6576 if !matches!(stage, AttemptState::Allocated | AttemptState::Preparing) {
6577 let EnvironmentIdentity::Isolated { environment_id, .. } = &identity else {
6578 unreachable!()
6579 };
6580 attempt
6581 .prepared_environment(environment_id.clone())
6582 .unwrap();
6583 attempt.mark_prepared(harness.clock.now()).unwrap();
6584 }
6585 if matches!(
6586 stage,
6587 AttemptState::JitReceived
6588 | AttemptState::Starting
6589 | AttemptState::Idle
6590 | AttemptState::Busy
6591 | AttemptState::Finished
6592 | AttemptState::Failed
6593 | AttemptState::Orphaned
6594 | AttemptState::Destroying
6595 | AttemptState::CleanupDeferred
6596 ) {
6597 attempt.jit_received(harness.clock.now()).unwrap();
6598 }
6599 if matches!(
6600 stage,
6601 AttemptState::Starting
6602 | AttemptState::Idle
6603 | AttemptState::Busy
6604 | AttemptState::Finished
6605 | AttemptState::Failed
6606 | AttemptState::Orphaned
6607 | AttemptState::Destroying
6608 | AttemptState::CleanupDeferred
6609 ) {
6610 attempt.started_isolated(harness.clock.now()).unwrap();
6611 }
6612 if matches!(
6613 stage,
6614 AttemptState::Idle | AttemptState::Busy | AttemptState::Finished
6615 ) {
6616 attempt.registered_idle(73, harness.clock.now()).unwrap();
6617 }
6618 if matches!(stage, AttemptState::Busy | AttemptState::Finished) {
6619 attempt.assigned_job(73, harness.clock.now()).unwrap();
6620 }
6621 if matches!(
6622 stage,
6623 AttemptState::Finished
6624 | AttemptState::Failed
6625 | AttemptState::Orphaned
6626 | AttemptState::Destroying
6627 | AttemptState::CleanupDeferred
6628 ) {
6629 let outcome = match stage {
6630 AttemptState::Finished => AttemptOutcome::CompletedJob,
6631 AttemptState::Orphaned => AttemptOutcome::Orphaned,
6632 _ => AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6633 };
6634 attempt.conclude(outcome, harness.clock.now()).unwrap();
6635 }
6636 if matches!(
6637 stage,
6638 AttemptState::Destroying | AttemptState::CleanupDeferred
6639 ) {
6640 attempt.begin_destroy(harness.clock.now()).unwrap();
6641 }
6642 if stage == AttemptState::CleanupDeferred {
6643 attempt.defer_cleanup(harness.clock.now()).unwrap();
6644 }
6645 harness.store.record_attempt(&attempt).unwrap();
6646 launcher
6647 .recover_startup(std::slice::from_ref(&harness.policy))
6648 .await
6649 .unwrap_or_else(|error| panic!("restart from {stage}: {error}"));
6650 let resumed = harness.store.attempt(id).unwrap().unwrap();
6651 if matches!(
6652 stage,
6653 AttemptState::Allocated
6654 | AttemptState::Preparing
6655 | AttemptState::Prepared
6656 | AttemptState::Finished
6657 | AttemptState::Failed
6658 | AttemptState::Orphaned
6659 | AttemptState::Destroying
6660 | AttemptState::CleanupDeferred
6661 ) {
6662 assert_eq!(
6663 resumed.state(),
6664 AttemptState::Cleaned,
6665 "{stage} must retire after restart"
6666 );
6667 } else {
6668 assert!(resumed.counts_against_capacity(), "live {stage} is adopted");
6669 assert_eq!(provider.resource_count(), 1);
6670 provider.resources.lock().unwrap().get_mut(&id).unwrap().1 =
6671 EnvironmentState::Exited;
6672 launcher.supervise(&harness.policy).await.unwrap();
6673 assert_eq!(
6674 harness.store.attempt(id).unwrap().unwrap().state(),
6675 AttemptState::Cleaned
6676 );
6677 }
6678 assert_eq!(
6679 provider.resource_count(),
6680 0,
6681 "{stage} resource cleaned exactly once"
6682 );
6683 let destroyed = provider
6684 .actions
6685 .lock()
6686 .unwrap()
6687 .iter()
6688 .filter(|action| **action == "destroy")
6689 .count();
6690 assert_eq!(
6691 destroyed,
6692 usize::from(stage != AttemptState::Allocated),
6693 "{stage} destroy count"
6694 );
6695 assert_eq!(
6696 harness.github.registrations.load(Ordering::SeqCst),
6697 0,
6698 "restart from {stage} cannot duplicate JIT"
6699 );
6700 assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6701 }
6702 }
6703
6704 #[tokio::test]
6705 async fn duplicate_provider_ownership_is_quarantined_until_unambiguous() {
6706 let harness = isolated_harness();
6707 let provider = Arc::new(FakeIsolatedProvider::default());
6708 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6709 let id = AttemptId::from_u128(0x7777);
6710 let runtime = harness.host_root().join("duplicate-provider-ownership");
6711 fs::create_dir_all(&runtime).unwrap();
6712 let mut attempt =
6713 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
6714 let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6715 harness.policy.execution_policy()
6716 else {
6717 unreachable!()
6718 };
6719 attempt
6720 .allocate_execution(AttemptExecution::Isolated {
6721 provider_kind: Backend::Oci,
6722 environment_id: None,
6723 resolved_image: image.clone(),
6724 generation: "duplicate-generation".into(),
6725 })
6726 .unwrap();
6727 attempt.begin_prepare(harness.clock.now()).unwrap();
6728 harness.store.record_attempt(&attempt).unwrap();
6729 let first = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
6730 let mut second = first.clone();
6731 let EnvironmentIdentity::Isolated { environment_id, .. } = &mut second else {
6732 unreachable!()
6733 };
6734 *environment_id = "another-environment".into();
6735 provider
6736 .resources
6737 .lock()
6738 .unwrap()
6739 .insert(id, (first, EnvironmentState::Running));
6740 provider.extra_resources.lock().unwrap().push(second);
6741 launcher
6742 .recover_startup(std::slice::from_ref(&harness.policy))
6743 .await
6744 .expect("duplicate is local");
6745 let deferred = harness.store.attempt(id).unwrap().unwrap();
6746 assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
6747 assert!(deferred.counts_against_capacity());
6748 assert_eq!(provider.resource_count(), 1);
6749 assert_eq!(
6750 provider
6751 .actions
6752 .lock()
6753 .unwrap()
6754 .iter()
6755 .filter(|action| **action == "destroy")
6756 .count(),
6757 0
6758 );
6759 provider.extra_resources.lock().unwrap().clear();
6760 harness.clock.advance_secs(31);
6762 launcher
6763 .recover_startup(std::slice::from_ref(&harness.policy))
6764 .await
6765 .expect("single identity recovered");
6766 assert_eq!(provider.resource_count(), 0);
6767 assert_eq!(
6768 harness.store.attempt(id).unwrap().unwrap().state(),
6769 AttemptState::Cleaned
6770 );
6771 }
6772
6773 #[tokio::test]
6774 async fn provider_resource_without_live_journal_is_surfaced_but_never_deleted() {
6775 let harness = isolated_harness();
6776 let provider = Arc::new(FakeIsolatedProvider::default());
6777 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6778 let id = AttemptId::from_u128(0x8888);
6779 let runtime = harness.host_root().join("orphan-provider-resource");
6780 fs::create_dir_all(&runtime).unwrap();
6781 let mut attempt =
6782 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
6783 let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6784 harness.policy.execution_policy()
6785 else {
6786 unreachable!()
6787 };
6788 attempt
6789 .allocate_execution(AttemptExecution::Isolated {
6790 provider_kind: Backend::Oci,
6791 environment_id: None,
6792 resolved_image: image.clone(),
6793 generation: "orphan-generation".into(),
6794 })
6795 .unwrap();
6796 let identity = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
6797 provider
6798 .resources
6799 .lock()
6800 .unwrap()
6801 .insert(id, (identity, EnvironmentState::Running));
6802 launcher
6803 .recover_startup(std::slice::from_ref(&harness.policy))
6804 .await
6805 .expect("orphan cannot block host");
6806 assert_eq!(provider.resource_count(), 1);
6807 assert!(
6808 harness
6809 .events
6810 .events()
6811 .contains(&AttemptEvent::QuarantinedOwnedResource { attempt: id })
6812 );
6813 assert_eq!(
6814 provider
6815 .actions
6816 .lock()
6817 .unwrap()
6818 .iter()
6819 .filter(|action| **action == "destroy")
6820 .count(),
6821 0
6822 );
6823
6824 let EnvironmentIdentity::Isolated { environment_id, .. } = provider
6825 .resources
6826 .lock()
6827 .unwrap()
6828 .get(&id)
6829 .unwrap()
6830 .0
6831 .clone()
6832 else {
6833 unreachable!()
6834 };
6835 attempt.begin_prepare(harness.clock.now()).unwrap();
6836 attempt.prepared_environment(environment_id).unwrap();
6837 attempt.mark_prepared(harness.clock.now()).unwrap();
6838 attempt.jit_received(harness.clock.now()).unwrap();
6839 attempt.started_isolated(harness.clock.now()).unwrap();
6840 attempt
6841 .conclude(
6842 AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6843 harness.clock.now(),
6844 )
6845 .unwrap();
6846 attempt.begin_destroy(harness.clock.now()).unwrap();
6847 attempt.clean_isolated(harness.clock.now()).unwrap();
6848 harness.store.record_attempt(&attempt).unwrap();
6849 launcher
6850 .recover_startup(std::slice::from_ref(&harness.policy))
6851 .await
6852 .expect("cleaned row cannot authorize deletion");
6853 assert_eq!(provider.resource_count(), 1);
6854 assert_eq!(
6855 provider
6856 .actions
6857 .lock()
6858 .unwrap()
6859 .iter()
6860 .filter(|action| **action == "destroy")
6861 .count(),
6862 0
6863 );
6864 }
6865
6866 #[tokio::test]
6875 async fn a_root_that_refuses_a_launch_is_recorded_and_cleared_when_one_succeeds() {
6876 use runner_manager_platform::service::{clear_runner_root_refusal, runner_root_refusals};
6877
6878 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6879 .with_host_runner_root();
6880 harness.ready().await;
6881
6882 let unusable = harness
6886 ._root
6887 .path()
6888 .join("absent")
6889 .join("deeper")
6890 .join("runners");
6891 let mut host = harness.host.clone();
6892 host.runner_root_override = Some(
6893 LocalAbsolutePath::new(unusable.to_str().expect("a UTF-8 temporary path"))
6894 .expect("a local absolute host root"),
6895 );
6896 harness.store.put_host(&host).unwrap();
6897
6898 let failure = harness
6899 .launch_result()
6900 .await
6901 .expect_err("a root whose parents are missing cannot hold a runner");
6902 assert!(
6903 matches!(failure.reason, FailureReason::Other(_)),
6904 "{failure:?}"
6905 );
6906
6907 let refusals = runner_root_refusals(&harness.app_paths).expect("readable");
6908 let refusal = refusals
6909 .first()
6910 .expect("the refusal reached the one surface that can hold it");
6911 assert_eq!(refusal.policy, harness.policy.id.to_string());
6912 assert_eq!(refusal.kind, "missing_parents");
6913 assert!(
6914 refusal.root.contains("runners") && refusal.detail.contains("runners"),
6915 "the directory must be named in full: {refusal:?}"
6916 );
6917
6918 harness.store.put_host(&harness.host).unwrap();
6921 harness.launch().await;
6922 assert!(
6923 runner_root_refusals(&harness.app_paths)
6924 .expect("readable")
6925 .is_empty(),
6926 "a successful placement clears that policy's record"
6927 );
6928
6929 clear_runner_root_refusal(&harness.app_paths, &harness.policy.id.to_string())
6930 .expect("cleanup");
6931 }
6932
6933 #[tokio::test]
6934 async fn a_launch_reports_how_long_each_step_took() {
6935 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
6936 harness.ready().await;
6937 let started = harness.launch().await;
6938 let events = harness.events.events();
6939 let launched = events
6940 .iter()
6941 .position(|event| matches!(event, AttemptEvent::Launched { attempt, .. } if *attempt == started.id))
6942 .expect("a successful launch reports its step timings");
6943 let starting = events
6944 .iter()
6945 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == started.id))
6946 .unwrap();
6947 assert!(starting < launched, "timings follow the start: {events:?}");
6948 }
6949
6950 #[tokio::test]
6951 async fn a_failed_launch_still_reports_its_step_timings() {
6952 let harness = Harness::new(
6953 FakeGithubLifecycle::default().fail(true),
6954 Arc::new(PersistentDemand),
6955 );
6956 harness.ready().await;
6957 let failed = harness.launch_after_waiting(Duration::from_secs(90)).await;
6958 assert!(failed.is_err());
6959
6960 let timings = harness
6961 .events
6962 .events()
6963 .into_iter()
6964 .find_map(|event| match event {
6965 AttemptEvent::Launched {
6966 started: false,
6967 timings,
6968 ..
6969 } => Some(timings),
6970 _ => None,
6971 })
6972 .expect("a launch that failed at registration reports its timings");
6973 assert_eq!(timings.lock_wait, Duration::from_secs(90));
6974 assert_eq!(timings.spawn, Duration::ZERO, "spawn was never reached");
6975 assert!(timings.total() >= Duration::from_secs(90));
6976 }
6977
6978 #[cfg(target_os = "macos")]
6979 #[tokio::test]
6980 async fn a_launch_removes_the_seed_of_a_root_no_longer_in_use() {
6981 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
6982 harness.ready().await;
6983
6984 let old_root = harness._root.path().join("old-root");
6986 let old_runtime = old_root.join("abandoned");
6987 fs::create_dir_all(old_root.join(PACKAGE_SEED_DIR).join("2.335.0")).unwrap();
6988 fs::create_dir_all(&old_runtime).unwrap();
6989 let old = RunnerAttempt::allocate(
6990 AttemptId::new_random(),
6991 harness.policy.id,
6992 &old_runtime,
6993 harness.clock.now(),
6994 );
6995 harness.store.record_attempt(&old).unwrap();
6996 let kept = harness.host_root().join(PACKAGE_SEED_DIR);
6997 fs::create_dir_all(kept.join("2.336.0")).unwrap();
6998
6999 harness.launch().await;
7000
7001 assert!(
7002 !old_root.join(PACKAGE_SEED_DIR).exists(),
7003 "the abandoned root's seed is removed"
7004 );
7005 assert!(old_runtime.exists(), "only the seed is touched");
7006 assert!(kept.exists(), "the host root's own seed is kept");
7007 }
7008
7009 #[tokio::test]
7010 async fn a_job_walks_every_state_and_cleans_every_artifact() {
7011 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7012 harness.ready().await;
7013 let started = harness.launch().await;
7014 assert_eq!(started.state(), AttemptState::Starting);
7015 assert_eq!(read_runner_id(started.runtime_path()), Some(73));
7016
7017 harness
7018 .github
7019 .observe(GithubRunnerObservation::Registered { busy: false });
7020 harness.launcher.supervise(&harness.policy).await.unwrap();
7021 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7022
7023 harness
7024 .github
7025 .observe(GithubRunnerObservation::Registered { busy: true });
7026 harness.launcher.supervise(&harness.policy).await.unwrap();
7027 assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
7028
7029 harness.processes.finish_successfully();
7030 harness
7031 .github
7032 .observe(GithubRunnerObservation::NotRegistered);
7033 harness.launcher.supervise(&harness.policy).await.unwrap();
7034 let cleaned = harness.only_attempt();
7035 assert_eq!(cleaned.state(), AttemptState::Cleaned);
7036 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
7037 assert!(!started.runtime_path().exists());
7038 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7039 assert_eq!(harness.github.remaining_runners.load(Ordering::SeqCst), 0);
7040
7041 let states: Vec<_> = harness
7042 .events
7043 .events()
7044 .into_iter()
7045 .filter_map(|event| match event {
7046 AttemptEvent::State { state, .. } => Some(state),
7047 _ => None,
7048 })
7049 .collect();
7050 assert_eq!(
7051 states,
7052 vec![
7053 AttemptState::Allocated,
7054 AttemptState::JitReceived,
7055 AttemptState::Starting,
7056 AttemptState::Idle,
7057 AttemptState::Busy,
7058 AttemptState::Finished,
7059 AttemptState::Cleaned,
7060 ]
7061 );
7062 }
7063
7064 #[tokio::test]
7065 async fn a_cleaned_ephemeral_attempt_reaps_a_directory_recreated_after_cleanup() {
7066 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7067 harness.ready().await;
7068 let started = harness.launch().await;
7069 let runtime = started.runtime_path().to_path_buf();
7070
7071 harness
7072 .github
7073 .observe(GithubRunnerObservation::Registered { busy: true });
7074 harness.launcher.supervise(&harness.policy).await.unwrap();
7075 harness.processes.finish_successfully();
7076 harness
7077 .github
7078 .observe(GithubRunnerObservation::NotRegistered);
7079 harness.launcher.supervise(&harness.policy).await.unwrap();
7080
7081 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7082 assert!(!runtime.exists());
7083 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7084
7085 let residue = runtime.join("_work").join("late-node-process");
7089 fs::create_dir_all(&residue).unwrap();
7090 fs::write(residue.join("node_modules.lock"), b"late residue").unwrap();
7091
7092 harness.launcher.supervise(&harness.policy).await.unwrap();
7093
7094 assert!(!runtime.exists(), "late ephemeral residue is reaped");
7095 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7096 assert_eq!(
7097 harness.packages.releases.load(Ordering::SeqCst),
7098 1,
7099 "reaping residue does not release the package lease twice"
7100 );
7101 }
7102
7103 #[tokio::test]
7104 async fn locked_late_residue_never_blocks_startup_recovery() {
7105 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7106 harness.ready().await;
7107 let started = harness.launch().await;
7108 let runtime = started.runtime_path().to_path_buf();
7109
7110 harness
7111 .github
7112 .observe(GithubRunnerObservation::Registered { busy: true });
7113 harness.launcher.supervise(&harness.policy).await.unwrap();
7114 harness.processes.finish_successfully();
7115 harness
7116 .github
7117 .observe(GithubRunnerObservation::NotRegistered);
7118 harness.launcher.supervise(&harness.policy).await.unwrap();
7119 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7120
7121 let held = runtime.join("_work").join("late-node-process");
7122 fs::create_dir_all(&held).unwrap();
7123 fs::write(held.join("node_modules.lock"), b"late residue").unwrap();
7124 let Some(block) = BlockedDeletion::inject(&held) else {
7125 eprintln!(
7126 "skipped: this account cannot be refused a deletion, so locked late residue cannot be injected"
7127 );
7128 return;
7129 };
7130
7131 let restarted = harness.restart();
7132 restarted
7133 .recover_startup(std::slice::from_ref(&harness.policy))
7134 .await
7135 .expect("late residue cannot take the daemon offline");
7136 assert!(runtime.exists(), "the locked residue remains for a retry");
7137 assert!(
7138 harness
7139 .reconcile_events
7140 .events()
7141 .iter()
7142 .any(|event| matches!(
7143 event,
7144 LifecycleEvent::AttemptCleanFailed {
7145 attempt,
7146 reason: "late_ephemeral_workspace_could_not_be_removed",
7147 ..
7148 } if *attempt == started.id
7149 )),
7150 "the non-blocking cleanup failure remains visible"
7151 );
7152
7153 block.release();
7154 harness.clock.advance_secs(31);
7156 restarted
7157 .supervise(&harness.policy)
7158 .await
7159 .expect("the ordinary retry succeeds after the lock is released");
7160 assert!(!runtime.exists(), "the retry removes the late residue");
7161 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7162 assert_eq!(
7163 harness.packages.releases.load(Ordering::SeqCst),
7164 1,
7165 "retrying residue never releases the package lease twice"
7166 );
7167 }
7168
7169 #[tokio::test]
7170 async fn mixed_locked_cleanup_residue_isolated_from_the_whole_startup_pass() {
7171 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7172 harness.ready().await;
7173
7174 let terminal = harness.launch().await;
7178 let terminal_runtime = terminal.runtime_path().to_path_buf();
7179 harness.conclude(terminal.id);
7180 let Some(terminal_block) = BlockedDeletion::inject(&terminal_runtime.join("late-child"))
7181 else {
7182 eprintln!("skipped: this account cannot inject the two independent deletion refusals");
7183 return;
7184 };
7185
7186 let cleaned = harness.launch().await;
7190 let cleaned_runtime = cleaned.runtime_path().to_path_buf();
7191 harness.conclude(cleaned.id);
7192 harness
7193 .launcher
7194 .clean(cleaned.id)
7195 .await
7196 .expect("the second attempt initially cleans");
7197 let Some(cleaned_block) =
7198 BlockedDeletion::inject(&cleaned_runtime.join("_work").join("late-child"))
7199 else {
7200 terminal_block.release();
7201 eprintln!("skipped: this account cannot inject the two independent deletion refusals");
7202 return;
7203 };
7204
7205 let restarted = harness.restart();
7206 for _ in 0..2 {
7207 restarted
7208 .recover_startup(std::slice::from_ref(&harness.policy))
7209 .await
7210 .expect("repeated recovery remains ready while both residues are locked");
7211 }
7212 assert_ne!(
7213 harness.attempt(terminal.id).state(),
7214 AttemptState::Cleaned,
7215 "the journal does not claim the terminal workspace was removed"
7216 );
7217 assert_eq!(
7218 harness.attempt(cleaned.id).state(),
7219 AttemptState::Cleaned,
7220 "late residue does not undo an already durable cleaned transition"
7221 );
7222 assert!(terminal_runtime.exists());
7223 assert!(cleaned_runtime.exists());
7224 assert_eq!(
7225 harness.packages.releases.load(Ordering::SeqCst),
7226 1,
7227 "repeated recovery neither loses nor duplicates package leases"
7228 );
7229
7230 terminal_block.release();
7231 cleaned_block.release();
7232 harness.clock.advance_secs(31);
7234 restarted
7235 .supervise(&harness.policy)
7236 .await
7237 .expect("one ordinary pass clears both residues after their locks disappear");
7238 assert!(!terminal_runtime.exists());
7239 assert!(!cleaned_runtime.exists());
7240 assert_eq!(harness.attempt(terminal.id).state(), AttemptState::Cleaned);
7241 assert_eq!(harness.attempt(cleaned.id).state(), AttemptState::Cleaned);
7242 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 2);
7243 }
7244
7245 #[tokio::test]
7246 async fn a_terminal_attempt_whose_runtime_is_already_gone_recovers_cleanly() {
7247 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7248 harness.ready().await;
7249 let attempt = harness.launch().await;
7250 let runtime = attempt.runtime_path().to_path_buf();
7251 harness.conclude(attempt.id);
7252 fs::remove_dir_all(&runtime).unwrap();
7253
7254 harness
7255 .restart()
7256 .recover_startup(std::slice::from_ref(&harness.policy))
7257 .await
7258 .expect("an already absent disposable runtime is successful cleanup");
7259
7260 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7261 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7262 }
7263
7264 #[tokio::test]
7265 async fn a_non_workspace_cleanup_failure_still_fails_closed_and_can_retry() {
7266 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7267 harness.ready().await;
7268 let attempt = harness.launch().await;
7269 let runtime = attempt.runtime_path().to_path_buf();
7270 harness.conclude(attempt.id);
7271 harness.packages.fail_releases(1);
7272
7273 let restarted = harness.restart();
7274 let failure = restarted
7275 .recover_startup(std::slice::from_ref(&harness.policy))
7276 .await
7277 .expect_err("a package-accounting failure is not safe to downgrade to residue");
7278 assert!(
7279 failure
7280 .reason()
7281 .to_string()
7282 .contains("package lease could not be released")
7283 );
7284 assert!(
7285 !runtime.exists(),
7286 "workspace removal completed before release failed"
7287 );
7288 assert_ne!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7289
7290 restarted
7291 .recover_startup(std::slice::from_ref(&harness.policy))
7292 .await
7293 .expect("the same journal entry retries safely after the transient failure");
7294 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7295 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7296 }
7297
7298 #[tokio::test]
7299 async fn startup_never_scrubs_a_cleaned_persistent_work_directory() {
7300 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
7301 .with_persistent_workspace(1);
7302 harness.ready().await;
7303 let attempt = harness.launch().await;
7304 let retained = attempt.runtime_path().join("_work").join("checkout-marker");
7305 fs::create_dir_all(retained.parent().unwrap()).unwrap();
7306 fs::write(&retained, b"persistent checkout").unwrap();
7307 harness.cleanup_retaining_work(attempt.id).await;
7308
7309 harness
7310 .restart()
7311 .recover_startup(std::slice::from_ref(&harness.policy))
7312 .await
7313 .expect("a cleaned persistent slot is already reconciled");
7314
7315 assert_eq!(fs::read(&retained).unwrap(), b"persistent checkout");
7316 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7317 }
7318
7319 #[tokio::test]
7320 async fn an_idle_exit_is_not_a_failure_in_the_journal_or_events() {
7321 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7322 harness.ready().await;
7323 let started = harness.launch().await;
7324 harness
7325 .github
7326 .observe(GithubRunnerObservation::Registered { busy: false });
7327 harness.launcher.supervise(&harness.policy).await.unwrap();
7328 harness.clock.advance_secs(11);
7329 harness.processes.set_alive(false);
7330 harness
7331 .github
7332 .observe(GithubRunnerObservation::NotRegistered);
7333 harness.launcher.supervise(&harness.policy).await.unwrap();
7334
7335 let cleaned = harness.only_attempt();
7336 assert!(cleaned.outcome().unwrap().is_idle_exit());
7337 assert!(!cleaned.outcome().unwrap().is_failure());
7338 assert!(!started.runtime_path().exists());
7339 assert!(
7340 harness
7341 .reconcile_events
7342 .events()
7343 .iter()
7344 .any(|event| matches!(
7345 event,
7346 LifecycleEvent::AttemptCleaned {
7347 outcome: OutcomeKind::IdleExit,
7348 ..
7349 }
7350 ))
7351 );
7352 assert!(!harness.events.events().iter().any(|event| matches!(
7353 event,
7354 AttemptEvent::Concluded {
7355 outcome: OutcomeKind::Failed,
7356 ..
7357 }
7358 )));
7359 }
7360
7361 #[tokio::test]
7362 async fn handoff_is_absent_after_success_and_every_failed_spawn_retry() {
7363 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7364 harness.processes.fail_spawns(2);
7365 harness.ready().await;
7366 let attempt = harness.launch().await;
7367 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 3);
7368 assert!(harness.processes.saw_secret.load(Ordering::SeqCst));
7369 let names: Vec<_> = fs::read_dir(attempt.runtime_path())
7370 .unwrap()
7371 .map(|entry| entry.unwrap().file_name())
7372 .collect();
7373 assert!(
7374 names.iter().all(|name| {
7375 !name
7376 .to_string_lossy()
7377 .starts_with(RestrictiveHandoff::NAME_PREFIX)
7378 }),
7379 "JIT artifact survived: {names:?}"
7380 );
7381 assert_eq!(
7382 *harness.delay.0.lock().unwrap(),
7383 vec![Duration::from_millis(10), Duration::from_millis(20)]
7384 );
7385 }
7386
7387 #[tokio::test]
7388 async fn jit_retry_stops_with_demand_and_a_terminal_403_never_retries() {
7389 let gone = Harness::new(
7390 FakeGithubLifecycle::default().fail(false),
7391 Arc::new(FakeDemand::answering([false])),
7392 );
7393 gone.ready().await;
7394 assert!(gone.launch_result().await.is_err());
7395 assert_eq!(gone.github.registrations.load(Ordering::SeqCst), 1);
7396 assert!(gone.delay.0.lock().unwrap().is_empty());
7397
7398 let forbidden = Harness::new(
7399 FakeGithubLifecycle::default().fail(true),
7400 Arc::new(PersistentDemand),
7401 );
7402 forbidden.ready().await;
7403 assert!(forbidden.launch_result().await.is_err());
7404 assert_eq!(forbidden.github.registrations.load(Ordering::SeqCst), 1);
7405 assert!(forbidden.delay.0.lock().unwrap().is_empty());
7406 assert!(matches!(
7407 forbidden.only_attempt().outcome(),
7408 Some(AttemptOutcome::Failed {
7409 reason: FailureReason::Other(action)
7410 }) if action.contains("403")
7411 ));
7412
7413 let transient = Harness::new(
7414 FakeGithubLifecycle::default().fail(false).fail(false),
7415 Arc::new(PersistentDemand),
7416 );
7417 transient.ready().await;
7418 transient.launch().await;
7419 assert_eq!(transient.github.registrations.load(Ordering::SeqCst), 3);
7420 assert_eq!(
7421 *transient.delay.0.lock().unwrap(),
7422 vec![Duration::from_millis(10), Duration::from_millis(20)]
7423 );
7424 }
7425
7426 #[test]
7434 fn a_workspace_leaves_room_for_the_deepest_path_a_checkout_writes() {
7435 const MAX_PATH: usize = 260;
7436 let root = r"C:\Users\IvanD\AppData\Local\IvanMurzak\runner-manager\data\runtime";
7438 let repo = "GitHub-Runner-Scaler-UI";
7442 let deepest = format!(
7443 r"_work\{repo}\{repo}\.git\objects\pack\pack-{}.keep",
7444 "0".repeat(40)
7445 );
7446
7447 let name = workspace_name(AttemptId::new_random());
7448 assert_eq!(name.len(), WORKSPACE_NAME_LEN, "{name}");
7449 assert!(
7450 name.chars().all(|c| c.is_ascii_hexdigit()),
7451 "a directory name must not carry the identifier's dashes: {name}"
7452 );
7453
7454 let full = format!(r"{root}\{name}\{deepest}");
7455 assert!(
7456 full.len() < MAX_PATH,
7457 "the deepest path a checkout writes must fit: {} characters, limit {MAX_PATH}",
7458 full.len()
7459 );
7460
7461 let old = format!(
7464 r"{root}\{}\{}\{deepest}",
7465 PolicyId::new_random(),
7466 AttemptId::new_random()
7467 );
7468 assert!(
7469 old.len() > MAX_PATH,
7470 "the old layout is supposed to be the thing that did not fit: {} characters",
7471 old.len()
7472 );
7473 }
7474
7475 #[tokio::test]
7476 async fn two_attempts_never_share_a_workspace_even_after_failure() {
7477 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7478 harness.ready().await;
7479 let first = harness.launch().await;
7480 fs::write(first.runtime_path().join("hostile-leftover"), b"first job").unwrap();
7481 harness
7482 .github
7483 .observe(GithubRunnerObservation::Registered { busy: false });
7484 harness.launcher.supervise(&harness.policy).await.unwrap();
7485 harness.clock.advance_secs(11);
7486 harness.processes.set_alive(false);
7487 harness
7488 .github
7489 .observe(GithubRunnerObservation::NotRegistered);
7490 harness.launcher.supervise(&harness.policy).await.unwrap();
7491 assert!(!first.runtime_path().exists());
7492
7493 let second = harness.launch().await;
7494 assert_ne!(first.runtime_path(), second.runtime_path());
7495 assert!(!second.runtime_path().join("hostile-leftover").exists());
7496
7497 fs::write(
7498 second.runtime_path().join("hostile-on-failure"),
7499 b"second job",
7500 )
7501 .unwrap();
7502 harness.processes.set_alive(false);
7503 harness
7504 .github
7505 .observe(GithubRunnerObservation::NotRegistered);
7506 harness.launcher.supervise(&harness.policy).await.unwrap();
7507 assert!(
7508 !second.runtime_path().exists(),
7509 "failed workspace was retained"
7510 );
7511 }
7512
7513 #[tokio::test]
7514 async fn a_runner_that_never_gets_a_job_is_stopped_deregistered_and_not_replaced() {
7515 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7516 harness.ready().await;
7517 let attempt = harness.launch().await;
7518
7519 harness
7523 .github
7524 .observe(GithubRunnerObservation::Registered { busy: false });
7525 harness.launcher.supervise(&harness.policy).await.unwrap();
7526 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7527
7528 harness.clock.advance_secs(9);
7531 harness
7532 .github
7533 .observe(GithubRunnerObservation::Registered { busy: false });
7534 let none_yet = harness.launcher.supervise(&harness.policy).await.unwrap();
7535 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7536 assert!(none_yet.is_empty());
7537 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
7538
7539 harness.clock.advance_secs(1);
7541 harness
7542 .github
7543 .observe(GithubRunnerObservation::Registered { busy: false });
7544 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7545
7546 let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
7547 assert_eq!(
7548 concluded.outcome(),
7549 Some(&AttemptOutcome::ExitedIdleWithoutWork),
7550 "a surplus runner did not fail; recording one as a failure sends an operator \
7551 hunting a fault that does not exist"
7552 );
7553 assert_eq!(concluded.state(), AttemptState::Cleaned);
7554 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
7555 assert!(!attempt.runtime_path().exists());
7556
7557 assert_eq!(
7560 *harness.github.deregistrations.lock().unwrap(),
7561 vec![73],
7562 "the attempt's own runner id, deleted exactly once"
7563 );
7564
7565 assert!(
7568 replacements.is_empty(),
7569 "a surplus exit must not request a replacement"
7570 );
7571 }
7572
7573 #[tokio::test]
7574 async fn a_registration_github_will_not_delete_still_concludes_the_attempt() {
7575 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7579 harness.ready().await;
7580 let attempt = harness.launch().await;
7581 harness
7582 .github
7583 .observe(GithubRunnerObservation::Registered { busy: false });
7584 harness.launcher.supervise(&harness.policy).await.unwrap();
7585
7586 harness
7587 .github
7588 .deregistration_fails
7589 .store(true, Ordering::SeqCst);
7590 harness.clock.advance_secs(11);
7591 harness
7592 .github
7593 .observe(GithubRunnerObservation::Registered { busy: false });
7594 harness.launcher.supervise(&harness.policy).await.unwrap();
7595
7596 assert_eq!(
7597 *harness.github.deregistrations.lock().unwrap(),
7598 vec![73],
7599 "the delete was attempted"
7600 );
7601 let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
7602 assert_eq!(
7603 concluded.outcome(),
7604 Some(&AttemptOutcome::ExitedIdleWithoutWork),
7605 "the attempt concluded anyway"
7606 );
7607 assert_eq!(concluded.state(), AttemptState::Cleaned);
7608 assert!(!attempt.runtime_path().exists());
7609 }
7610
7611 #[tokio::test]
7612 async fn exit_before_acceptance_returns_replacement_intent_without_launching() {
7613 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7614 harness.ready().await;
7615 let first = harness.launch().await;
7616 harness.processes.set_alive(false);
7617 harness
7618 .github
7619 .observe(GithubRunnerObservation::NotRegistered);
7620 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7621 let failed = harness.store.attempt(first.id).unwrap().unwrap();
7622 assert!(matches!(
7623 failed.outcome(),
7624 Some(AttemptOutcome::Failed {
7625 reason: FailureReason::ProcessExitedUnexpectedly
7626 })
7627 ));
7628 assert!(!first.runtime_path().exists());
7629
7630 assert_eq!(
7631 replacements,
7632 vec![ReplacementIntent {
7633 policy: harness.policy.id,
7634 previous_attempt: first.id,
7635 operation: "exit_before_acceptance_replacement",
7636 }]
7637 );
7638 assert_eq!(harness.store.attempts().unwrap().len(), 1);
7639 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
7640 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
7641 assert!(harness.delay.0.lock().unwrap().is_empty());
7642 }
7643
7644 fn write_runner_diagnostics(runtime: &Path, ran_a_job: bool) {
7648 let diag = runtime.join("_diag");
7649 fs::create_dir_all(&diag).unwrap();
7650 fs::write(diag.join("Runner_20261007-211941-utc.log"), b"listener").unwrap();
7651 if ran_a_job {
7652 fs::write(diag.join("Worker_20261007-211950-utc.log"), b"worker").unwrap();
7653 }
7654 }
7655
7656 #[tokio::test]
7661 async fn a_job_too_short_to_be_seen_busy_is_recorded_as_completed() {
7662 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7663 harness.ready().await;
7664 let started = harness.launch().await;
7665 assert_eq!(started.state(), AttemptState::Starting);
7666 write_runner_diagnostics(started.runtime_path(), true);
7667 harness.processes.finish_successfully();
7668 harness
7669 .github
7670 .observe(GithubRunnerObservation::NotRegistered);
7671
7672 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7673
7674 let cleaned = harness.only_attempt();
7675 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
7676 assert_eq!(cleaned.state(), AttemptState::Cleaned);
7677 assert_eq!(cleaned.github_runner_id(), Some(73));
7678 assert!(
7679 replacements.is_empty(),
7680 "a finished job needs no replacement"
7681 );
7682 let busy_seen = harness.events.events().into_iter().any(|event| {
7683 matches!(
7684 event,
7685 AttemptEvent::State {
7686 state: AttemptState::Busy,
7687 ..
7688 }
7689 )
7690 });
7691 assert!(busy_seen, "the journal walks the diagram to `busy` first");
7692 }
7693
7694 #[tokio::test]
7696 async fn a_job_taken_between_two_passes_after_idle_is_recorded_as_completed() {
7697 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7698 harness.ready().await;
7699 let started = harness.launch().await;
7700 harness
7701 .github
7702 .observe(GithubRunnerObservation::Registered { busy: false });
7703 harness.launcher.supervise(&harness.policy).await.unwrap();
7704 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7705
7706 write_runner_diagnostics(started.runtime_path(), true);
7707 harness.processes.finish_successfully();
7708 harness
7709 .github
7710 .observe(GithubRunnerObservation::NotRegistered);
7711 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7712
7713 assert_eq!(
7714 harness.only_attempt().outcome(),
7715 Some(&AttemptOutcome::CompletedJob)
7716 );
7717 assert!(replacements.is_empty());
7718 }
7719
7720 #[tokio::test]
7724 async fn an_assignment_the_runner_never_took_up_is_not_a_completed_job() {
7725 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7726 harness.ready().await;
7727 let started = harness.launch().await;
7728 harness
7729 .github
7730 .observe(GithubRunnerObservation::Registered { busy: true });
7731 harness.launcher.supervise(&harness.policy).await.unwrap();
7732 assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
7733
7734 write_runner_diagnostics(started.runtime_path(), false);
7735 harness.processes.finish_successfully();
7736 harness
7737 .github
7738 .observe(GithubRunnerObservation::NotRegistered);
7739 harness.launcher.supervise(&harness.policy).await.unwrap();
7740
7741 let cleaned = harness.only_attempt();
7742 assert_eq!(
7743 cleaned.outcome(),
7744 Some(&AttemptOutcome::ExitedIdleWithoutWork)
7745 );
7746 assert_eq!(cleaned.state(), AttemptState::Cleaned);
7747 }
7748
7749 #[tokio::test]
7752 async fn a_finished_job_still_listed_at_github_is_completed_and_deregistered() {
7753 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7754 harness.ready().await;
7755 let started = harness.launch().await;
7756 harness
7757 .github
7758 .observe(GithubRunnerObservation::Registered { busy: true });
7759 harness.launcher.supervise(&harness.policy).await.unwrap();
7760
7761 write_runner_diagnostics(started.runtime_path(), true);
7762 harness.processes.finish_successfully();
7763 harness
7764 .github
7765 .observe(GithubRunnerObservation::Registered { busy: true });
7766 harness.launcher.supervise(&harness.policy).await.unwrap();
7767
7768 assert_eq!(
7769 harness.only_attempt().outcome(),
7770 Some(&AttemptOutcome::CompletedJob)
7771 );
7772 assert_eq!(*harness.github.deregistrations.lock().unwrap(), vec![73]);
7773 }
7774
7775 #[tokio::test]
7778 async fn a_live_runner_whose_assignment_was_withdrawn_goes_back_to_idle() {
7779 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7780 harness.ready().await;
7781 let started = harness.launch().await;
7782 harness
7783 .github
7784 .observe(GithubRunnerObservation::Registered { busy: true });
7785 harness.launcher.supervise(&harness.policy).await.unwrap();
7786 assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
7787
7788 write_runner_diagnostics(started.runtime_path(), false);
7789 harness
7790 .github
7791 .observe(GithubRunnerObservation::Registered { busy: false });
7792 harness.launcher.supervise(&harness.policy).await.unwrap();
7793 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7794
7795 harness
7797 .github
7798 .observe(GithubRunnerObservation::Registered { busy: true });
7799 harness.launcher.supervise(&harness.policy).await.unwrap();
7800 write_runner_diagnostics(started.runtime_path(), true);
7801 harness
7802 .github
7803 .observe(GithubRunnerObservation::Registered { busy: false });
7804 harness.launcher.supervise(&harness.policy).await.unwrap();
7805 assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
7806 }
7807
7808 #[tokio::test]
7811 async fn a_clean_exit_waits_for_github_to_answer() {
7812 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7813 harness.ready().await;
7814 let started = harness.launch().await;
7815 write_runner_diagnostics(started.runtime_path(), true);
7816 harness.processes.finish_successfully();
7817 harness.github.observe(GithubRunnerObservation::Unreachable);
7818 harness.launcher.supervise(&harness.policy).await.unwrap();
7819 assert_eq!(harness.only_attempt().state(), AttemptState::Starting);
7820
7821 harness
7822 .github
7823 .observe(GithubRunnerObservation::NotRegistered);
7824 harness.launcher.supervise(&harness.policy).await.unwrap();
7825 assert_eq!(
7826 harness.only_attempt().outcome(),
7827 Some(&AttemptOutcome::CompletedJob)
7828 );
7829 }
7830
7831 #[tokio::test]
7834 async fn a_short_job_with_no_runner_id_still_concludes() {
7835 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7836 harness.ready().await;
7837 let started = harness.launch().await;
7838 fs::remove_file(started.runtime_path().join(RUNNER_ID_FILE)).unwrap();
7839 write_runner_diagnostics(started.runtime_path(), true);
7840 harness.processes.finish_successfully();
7841 harness
7842 .github
7843 .observe(GithubRunnerObservation::NotRegistered);
7844 harness
7845 .launcher
7846 .supervise(&harness.policy)
7847 .await
7848 .expect("supervision goes on");
7849 assert!(harness.only_attempt().is_terminal());
7850 }
7851
7852 #[tokio::test]
7855 async fn without_runner_diagnostics_an_early_exit_is_still_a_failure() {
7856 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7857 harness.ready().await;
7858 harness.launch().await;
7859 harness.processes.finish_successfully();
7860 harness
7861 .github
7862 .observe(GithubRunnerObservation::NotRegistered);
7863 harness.launcher.supervise(&harness.policy).await.unwrap();
7864 assert!(matches!(
7865 harness.only_attempt().outcome(),
7866 Some(AttemptOutcome::Failed {
7867 reason: FailureReason::ProcessExitedUnexpectedly
7868 })
7869 ));
7870 }
7871
7872 #[tokio::test]
7873 async fn expired_jit_is_removed_and_does_not_reregister_after_demand_disappears() {
7874 let harness = Harness::new(
7875 FakeGithubLifecycle::default(),
7876 Arc::new(FakeDemand::answering([false])),
7877 );
7878 let id = AttemptId::new_random();
7879 let runtime = harness
7880 .launcher
7881 .app_paths
7882 .runtime_dir()
7883 .join(harness.policy.id.to_string())
7884 .join(id.to_string());
7885 fs::create_dir_all(&runtime).unwrap();
7886 let mut attempt =
7887 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
7888 attempt.jit_received(harness.clock.now()).unwrap();
7889 harness.store.record_attempt(&attempt).unwrap();
7890 harness.clock.advance_secs(11);
7891 let replacements = harness
7892 .launcher
7893 .recover_startup(std::slice::from_ref(&harness.policy))
7894 .await
7895 .unwrap();
7896 assert_eq!(
7897 replacements,
7898 vec![ReplacementIntent {
7899 policy: harness.policy.id,
7900 previous_attempt: id,
7901 operation: "jit_expired_replacement",
7902 }]
7903 );
7904
7905 let cleaned = harness.store.attempt(id).unwrap().unwrap();
7906 assert_eq!(cleaned.state(), AttemptState::Cleaned);
7907 assert!(matches!(
7908 cleaned.outcome(),
7909 Some(AttemptOutcome::Failed {
7910 reason: FailureReason::JitExpired
7911 })
7912 ));
7913 assert!(!runtime.exists());
7914 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
7915 assert!(harness.delay.0.lock().unwrap().is_empty());
7916 }
7917
7918 #[tokio::test]
7919 async fn expired_jit_returns_intent_but_never_launches_inside_lifecycle() {
7920 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7921 let id = AttemptId::new_random();
7922 let runtime = harness
7923 .launcher
7924 .app_paths
7925 .runtime_dir()
7926 .join("expired-with-demand");
7927 fs::create_dir_all(&runtime).unwrap();
7928 let mut attempt =
7929 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
7930 attempt.jit_received(harness.clock.now()).unwrap();
7931 harness.store.record_attempt(&attempt).unwrap();
7932 harness.clock.advance_secs(11);
7933 let replacements = harness
7934 .launcher
7935 .recover_startup(std::slice::from_ref(&harness.policy))
7936 .await
7937 .unwrap();
7938
7939 let attempts = harness.store.attempts().unwrap();
7940 assert_eq!(attempts.len(), 1);
7941 assert_eq!(
7942 attempts
7943 .iter()
7944 .find(|attempt| attempt.id == id)
7945 .unwrap()
7946 .state(),
7947 AttemptState::Cleaned
7948 );
7949 assert_eq!(
7950 replacements,
7951 vec![ReplacementIntent {
7952 policy: harness.policy.id,
7953 previous_attempt: id,
7954 operation: "jit_expired_replacement",
7955 }]
7956 );
7957 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
7958 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
7959 assert!(harness.delay.0.lock().unwrap().is_empty());
7960 }
7961
7962 #[tokio::test]
7963 async fn package_materialization_retries_are_bounded_and_demand_adjacent() {
7964 let persistent = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7965 persistent.packages.fail_materializations(2);
7966 persistent.ready().await;
7967 persistent.launch().await;
7968 assert_eq!(
7969 persistent.packages.materializations.load(Ordering::SeqCst),
7970 3
7971 );
7972 assert_eq!(
7973 *persistent.delay.0.lock().unwrap(),
7974 vec![Duration::from_millis(10), Duration::from_millis(20)]
7975 );
7976
7977 let gone_before_wait = Harness::new(
7978 FakeGithubLifecycle::default(),
7979 Arc::new(FakeDemand::answering([false])),
7980 );
7981 gone_before_wait.packages.fail_materializations(3);
7982 gone_before_wait.ready().await;
7983 assert!(gone_before_wait.launch_result().await.is_err());
7984 assert_eq!(
7985 gone_before_wait
7986 .packages
7987 .materializations
7988 .load(Ordering::SeqCst),
7989 1
7990 );
7991 assert!(gone_before_wait.delay.0.lock().unwrap().is_empty());
7992
7993 let gone_during_wait = Harness::new(
7994 FakeGithubLifecycle::default(),
7995 Arc::new(FakeDemand::answering([true, false])),
7996 );
7997 gone_during_wait.packages.fail_materializations(3);
7998 gone_during_wait.ready().await;
7999 assert!(gone_during_wait.launch_result().await.is_err());
8000 assert_eq!(
8001 gone_during_wait
8002 .packages
8003 .materializations
8004 .load(Ordering::SeqCst),
8005 1
8006 );
8007 assert_eq!(
8008 *gone_during_wait.delay.0.lock().unwrap(),
8009 vec![Duration::from_millis(10)]
8010 );
8011 }
8012
8013 #[tokio::test]
8014 async fn replacement_is_intent_only_and_never_launches_inside_lifecycle() {
8015 let harness = Harness::new(
8016 FakeGithubLifecycle::default(),
8017 Arc::new(FakeDemand::answering([true, false])),
8018 );
8019 harness.ready().await;
8020 let first = harness.launch().await;
8021 harness.processes.set_alive(false);
8022 harness
8023 .github
8024 .observe(GithubRunnerObservation::NotRegistered);
8025 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
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 assert_eq!(
8032 replacements,
8033 vec![ReplacementIntent {
8034 policy: harness.policy.id,
8035 previous_attempt: first.id,
8036 operation: "exit_before_acceptance_replacement",
8037 }]
8038 );
8039 assert_eq!(
8040 harness.store.attempt(first.id).unwrap().unwrap().state(),
8041 AttemptState::Cleaned
8042 );
8043 }
8044
8045 #[tokio::test]
8046 async fn startup_adopts_a_live_process_and_refuses_launch_before_recovery() {
8047 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8048 let before = harness.launch_result().await;
8049 assert!(before.is_err());
8050 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8051
8052 let id = AttemptId::new_random();
8053 let runtime = harness.launcher.app_paths.runtime_dir().join("adopt");
8054 fs::create_dir_all(&runtime).unwrap();
8055 let mut attempt =
8056 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
8057 attempt.jit_received(harness.clock.now()).unwrap();
8058 attempt.started(4242, harness.clock.now()).unwrap();
8059 harness.store.record_attempt(&attempt).unwrap();
8060 harness.processes.set_alive(true);
8061 harness
8062 .github
8063 .observe(GithubRunnerObservation::NotRegistered);
8064 let replacements = harness
8065 .launcher
8066 .recover_startup(std::slice::from_ref(&harness.policy))
8067 .await
8068 .unwrap();
8069 assert!(replacements.is_empty());
8070 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8071 assert!(
8072 harness
8073 .events
8074 .events()
8075 .contains(&AttemptEvent::Adopted { attempt: id })
8076 );
8077 }
8078
8079 #[tokio::test]
8080 async fn spawn_before_starting_crash_recovers_pid_then_completes_and_cleans() {
8081 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8082 let id = AttemptId::new_random();
8083 let runtime = harness
8084 .launcher
8085 .app_paths
8086 .runtime_dir()
8087 .join("spawn-before-starting");
8088 fs::create_dir_all(&runtime).unwrap();
8089 let mut attempt =
8090 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8091 attempt.jit_received(harness.clock.now()).unwrap();
8092 harness.store.record_attempt(&attempt).unwrap();
8093 harness.processes.set_alive(true);
8094 harness
8095 .github
8096 .observe(GithubRunnerObservation::Registered { busy: true });
8097
8098 let replacements = harness
8099 .launcher
8100 .recover_startup(std::slice::from_ref(&harness.policy))
8101 .await
8102 .unwrap();
8103 assert!(replacements.is_empty());
8104 let recovered = harness.store.attempt(id).unwrap().unwrap();
8105 assert_eq!(recovered.state(), AttemptState::Busy);
8106 assert_eq!(recovered.process_id(), Some(4242));
8107 assert_eq!(recovered.github_runner_id(), Some(73));
8108 let events = harness.events.events();
8109 let starting = events
8110 .iter()
8111 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == id))
8112 .unwrap();
8113 let busy = events
8114 .iter()
8115 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Busy } if *attempt == id))
8116 .unwrap();
8117 assert!(starting < busy, "recovery skipped a legal edge: {events:?}");
8118
8119 harness.processes.finish_successfully();
8120 harness
8121 .github
8122 .observe(GithubRunnerObservation::NotRegistered);
8123 assert!(
8124 harness
8125 .launcher
8126 .supervise(&harness.policy)
8127 .await
8128 .unwrap()
8129 .is_empty()
8130 );
8131 let cleaned = harness.store.attempt(id).unwrap().unwrap();
8132 assert_eq!(cleaned.state(), AttemptState::Cleaned);
8133 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
8134 assert!(!runtime.exists());
8135 }
8136
8137 #[tokio::test]
8138 async fn failed_post_spawn_stop_keeps_capacity_until_supervision_proves_death() {
8139 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8140 harness.processes.fail_spawn_with_live_child();
8141 harness.ready().await;
8142 assert!(harness.launch_result().await.is_err());
8143
8144 let attempt = harness.only_attempt();
8145 assert_eq!(attempt.state(), AttemptState::Starting);
8146 assert_eq!(attempt.process_id(), Some(4242));
8147 assert!(attempt.outcome().is_none());
8148 assert!(attempt.state().counts_against_capacity());
8149 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
8150 assert!(harness.delay.0.lock().unwrap().is_empty());
8151
8152 harness.processes.set_alive(false);
8153 harness
8154 .github
8155 .observe(GithubRunnerObservation::NotRegistered);
8156 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8157 assert_eq!(replacements.len(), 1);
8158 assert_eq!(
8159 harness.store.attempt(attempt.id).unwrap().unwrap().state(),
8160 AttemptState::Cleaned
8161 );
8162 }
8163
8164 #[tokio::test]
8165 async fn remote_runner_identity_closes_both_sides_of_the_registration_crash_boundary() {
8166 for sidecar_already_present in [false, true] {
8167 let harness = Harness::new(
8168 FakeGithubLifecycle::default(),
8169 Arc::new(FakeDemand::answering([false])),
8170 );
8171 let id = AttemptId::new_random();
8172 let runtime =
8173 harness
8174 .launcher
8175 .app_paths
8176 .runtime_dir()
8177 .join(if sidecar_already_present {
8178 "after-id-sidecar"
8179 } else {
8180 "before-id-sidecar"
8181 });
8182 fs::create_dir_all(&runtime).unwrap();
8183 let mut attempt =
8184 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8185 if sidecar_already_present {
8186 write_runner_id(&runtime, 73).unwrap();
8187 attempt.jit_received(harness.clock.now()).unwrap();
8188 }
8189 harness.store.record_attempt(&attempt).unwrap();
8190 harness.processes.set_alive(true);
8191 harness
8192 .github
8193 .observe(GithubRunnerObservation::Registered { busy: false });
8194 harness
8195 .launcher
8196 .recover_startup(std::slice::from_ref(&harness.policy))
8197 .await
8198 .unwrap();
8199
8200 assert_eq!(read_runner_id(&runtime), Some(73));
8201 assert!(
8202 harness
8203 .store
8204 .attempt(id)
8205 .unwrap()
8206 .unwrap()
8207 .outcome()
8208 .is_none()
8209 );
8210 let events = harness.events.events();
8211 let recovered = events.iter().position(|event| {
8212 matches!(
8213 event,
8214 AttemptEvent::RemoteIdentityRecovered {
8215 attempt,
8216 runner_id: 73
8217 } if *attempt == id
8218 )
8219 });
8220 assert_eq!(recovered.is_some(), !sidecar_already_present);
8221 if let Some(recovered) = recovered {
8222 let adopted = events
8223 .iter()
8224 .position(|event| matches!(event, AttemptEvent::Adopted { attempt } if *attempt == id))
8225 .unwrap();
8226 assert!(
8227 recovered < adopted,
8228 "identity was not durable before adoption: {events:?}"
8229 );
8230 }
8231 assert!(runtime.exists());
8232 }
8233 }
8234
8235 #[tokio::test]
8236 async fn recovery_stays_closed_for_unknown_policy_and_unreachable_attempts() {
8237 let unknown = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8238 let unknown_attempt = RunnerAttempt::allocate(
8239 AttemptId::new_random(),
8240 PolicyId::from_u128(0xfeed),
8241 unknown
8242 .launcher
8243 .app_paths
8244 .runtime_dir()
8245 .join("unknown-policy"),
8246 unknown.clock.now(),
8247 );
8248 unknown.store.record_attempt(&unknown_attempt).unwrap();
8249 let expired_id = AttemptId::new_random();
8250 let expired_runtime = unknown
8251 .launcher
8252 .app_paths
8253 .runtime_dir()
8254 .join("expired-beside-unknown");
8255 fs::create_dir_all(&expired_runtime).unwrap();
8256 let mut expired = RunnerAttempt::allocate(
8257 expired_id,
8258 unknown.policy.id,
8259 expired_runtime,
8260 unknown.clock.now(),
8261 );
8262 expired.jit_received(unknown.clock.now()).unwrap();
8263 unknown.store.record_attempt(&expired).unwrap();
8264 unknown.clock.advance_secs(11);
8265 assert!(matches!(
8266 unknown
8267 .launcher
8268 .recover_startup(std::slice::from_ref(&unknown.policy))
8269 .await,
8270 Err(LifecycleError::RecoveryIncomplete)
8271 ));
8272 assert!(unknown.launch_result().await.is_err());
8273 assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
8274 let recovered_policy = fixtures::policy()
8275 .id(PolicyId::from_u128(0xfeed))
8276 .repository("octo/repo")
8277 .autoscale("home", 2)
8278 .active()
8279 .build();
8280 let pending = unknown
8281 .launcher
8282 .recover_startup(&[unknown.policy.clone(), recovered_policy])
8283 .await
8284 .unwrap();
8285 assert_eq!(
8286 pending,
8287 vec![ReplacementIntent {
8288 policy: unknown.policy.id,
8289 previous_attempt: expired_id,
8290 operation: "jit_expired_replacement",
8291 }]
8292 );
8293 assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
8294
8295 let unreachable = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8296 let id = AttemptId::new_random();
8297 let runtime = unreachable
8298 .launcher
8299 .app_paths
8300 .runtime_dir()
8301 .join("unreachable");
8302 fs::create_dir_all(&runtime).unwrap();
8303 unreachable
8304 .store
8305 .record_attempt(&RunnerAttempt::allocate(
8306 id,
8307 unreachable.policy.id,
8308 runtime,
8309 unreachable.clock.now(),
8310 ))
8311 .unwrap();
8312 unreachable
8313 .github
8314 .observe(GithubRunnerObservation::Unreachable);
8315 assert!(matches!(
8316 unreachable
8317 .launcher
8318 .recover_startup(std::slice::from_ref(&unreachable.policy))
8319 .await,
8320 Err(LifecycleError::RecoveryIncomplete)
8321 ));
8322 assert!(unreachable.launch_result().await.is_err());
8323 assert_eq!(unreachable.processes.spawns.load(Ordering::SeqCst), 0);
8324 }
8325
8326 #[tokio::test]
8327 async fn a_dead_busy_process_unknown_to_github_is_orphaned_and_cleaned() {
8328 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8329 let id = AttemptId::new_random();
8330 let runtime = harness.launcher.app_paths.runtime_dir().join("orphan");
8331 fs::create_dir_all(&runtime).unwrap();
8332 let mut attempt =
8333 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8334 attempt.jit_received(harness.clock.now()).unwrap();
8335 attempt.started(4242, harness.clock.now()).unwrap();
8336 attempt.assigned_job(73, harness.clock.now()).unwrap();
8337 harness.store.record_attempt(&attempt).unwrap();
8338 harness.processes.set_alive(false);
8339 harness
8340 .github
8341 .observe(GithubRunnerObservation::NotRegistered);
8342 harness
8343 .launcher
8344 .recover_startup(std::slice::from_ref(&harness.policy))
8345 .await
8346 .unwrap();
8347 let cleaned = harness.store.attempt(id).unwrap().unwrap();
8348 assert_eq!(cleaned.state(), AttemptState::Cleaned);
8349 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::Orphaned));
8350 assert!(!runtime.exists());
8351 }
8352
8353 #[tokio::test]
8354 async fn registration_timeout_journals_intent_stops_then_concludes_with_dead_reason() {
8355 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8356 harness.ready().await;
8357 let id = AttemptId::new_random();
8358 let runtime = harness.launcher.app_paths.runtime_dir().join("timeout");
8359 fs::create_dir_all(&runtime).unwrap();
8360 let mut attempt =
8361 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
8362 attempt.jit_received(harness.clock.now()).unwrap();
8363 attempt.started(4242, harness.clock.now()).unwrap();
8364 harness.store.record_attempt(&attempt).unwrap();
8365 harness.clock.advance_secs(11);
8366 harness.processes.set_alive(true);
8367 harness
8368 .github
8369 .observe(GithubRunnerObservation::NotRegistered);
8370 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8371 assert_eq!(
8372 replacements,
8373 vec![ReplacementIntent {
8374 policy: harness.policy.id,
8375 previous_attempt: id,
8376 operation: "registration_timeout_replacement",
8377 }]
8378 );
8379
8380 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
8381 assert!(!harness.processes.alive.load(Ordering::SeqCst));
8382 let actions = harness.processes.actions.lock().unwrap().clone();
8383 let intent = actions
8384 .iter()
8385 .position(|action| *action == "terminate_intent")
8386 .unwrap();
8387 let signal = actions
8388 .iter()
8389 .position(|action| *action == "terminate")
8390 .unwrap();
8391 assert!(
8392 intent < signal,
8393 "intent was not durable before signal: {actions:?}"
8394 );
8395
8396 let cleaned = harness.store.attempt(id).unwrap().unwrap();
8397 assert!(matches!(
8398 cleaned.outcome(),
8399 Some(AttemptOutcome::Failed {
8400 reason: FailureReason::TerminatedAfterRegistrationTimeout
8401 })
8402 ));
8403 let events = harness.events.events();
8404 let intent = events
8405 .iter()
8406 .position(|event| matches!(event, AttemptEvent::TerminateIntent { .. }))
8407 .unwrap();
8408 let stopped = events
8409 .iter()
8410 .position(|event| matches!(event, AttemptEvent::Terminated { .. }))
8411 .unwrap();
8412 let concluded = events
8413 .iter()
8414 .position(|event| matches!(event, AttemptEvent::Concluded { .. }))
8415 .unwrap();
8416 assert!(intent < stopped && stopped < concluded, "{events:?}");
8417 }
8418
8419 #[tokio::test]
8420 async fn timeout_crash_recovery_returns_the_same_replacement_intent() {
8421 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8422 let id = AttemptId::new_random();
8423 let runtime = harness
8424 .launcher
8425 .app_paths
8426 .runtime_dir()
8427 .join("timeout-after-crash");
8428 fs::create_dir_all(&runtime).unwrap();
8429 let mut attempt =
8430 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
8431 attempt.jit_received(harness.clock.now()).unwrap();
8432 attempt.started(4242, harness.clock.now()).unwrap();
8433 harness.store.record_attempt(&attempt).unwrap();
8434 harness.processes.intent.store(true, Ordering::SeqCst);
8435 harness.processes.set_alive(false);
8436 harness
8437 .github
8438 .observe(GithubRunnerObservation::NotRegistered);
8439
8440 let replacements = harness
8441 .launcher
8442 .recover_startup(std::slice::from_ref(&harness.policy))
8443 .await
8444 .unwrap();
8445 assert_eq!(
8446 replacements,
8447 vec![ReplacementIntent {
8448 policy: harness.policy.id,
8449 previous_attempt: id,
8450 operation: "registration_timeout_replacement",
8451 }]
8452 );
8453 let consumed = RunnerLauncher::supervise(&harness.launcher, &harness.policy)
8454 .await
8455 .unwrap();
8456 assert_eq!(consumed, replacements);
8457 assert!(
8458 RunnerLauncher::supervise(&harness.launcher, &harness.policy)
8459 .await
8460 .unwrap()
8461 .is_empty(),
8462 "startup replacement evidence must be consumed exactly once by e1"
8463 );
8464 assert!(matches!(
8465 harness.store.attempt(id).unwrap().unwrap().outcome(),
8466 Some(AttemptOutcome::Failed {
8467 reason: FailureReason::TerminatedAfterRegistrationTimeout
8468 })
8469 ));
8470 }
8471
8472 #[tokio::test]
8473 async fn terminate_intent_sync_failure_prevents_signal_and_conclusion() {
8474 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8475 let id = AttemptId::new_random();
8476 let runtime = harness
8477 .launcher
8478 .app_paths
8479 .runtime_dir()
8480 .join("timeout-sync-failure");
8481 fs::create_dir_all(&runtime).unwrap();
8482 let mut attempt =
8483 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8484 attempt.jit_received(harness.clock.now()).unwrap();
8485 attempt.started(4242, harness.clock.now()).unwrap();
8486 harness.store.record_attempt(&attempt).unwrap();
8487 harness.clock.advance_secs(11);
8488 harness.processes.set_alive(true);
8489 harness.processes.fail_intent();
8490 harness
8491 .github
8492 .observe(GithubRunnerObservation::NotRegistered);
8493
8494 assert!(
8495 harness
8496 .launcher
8497 .recover_startup(std::slice::from_ref(&harness.policy))
8498 .await
8499 .is_err()
8500 );
8501 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
8502 assert!(harness.processes.alive.load(Ordering::SeqCst));
8503 assert_eq!(
8504 harness.store.attempt(id).unwrap().unwrap().state(),
8505 AttemptState::Starting
8506 );
8507 assert!(!harness.events.events().iter().any(|event| matches!(
8508 event,
8509 AttemptEvent::Terminated { attempt } | AttemptEvent::Concluded { attempt, .. }
8510 if *attempt == id
8511 )));
8512 }
8513
8514 #[tokio::test]
8515 async fn diagnostics_survive_cleanup_without_the_jit_or_a_token() {
8516 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8517 harness.ready().await;
8518 let attempt = harness.launch().await;
8519 harness
8520 .github
8521 .observe(GithubRunnerObservation::Registered { busy: false });
8522 harness.launcher.supervise(&harness.policy).await.unwrap();
8523 harness.clock.advance_secs(11);
8524 harness.processes.set_alive(false);
8525 harness
8526 .github
8527 .observe(GithubRunnerObservation::NotRegistered);
8528 harness.launcher.supervise(&harness.policy).await.unwrap();
8529 let diagnostic = fs::read_to_string(
8530 harness
8531 .launcher
8532 .diagnostics_root
8533 .join(format!("{}.log", attempt.id)),
8534 )
8535 .unwrap();
8536 assert!(diagnostic.contains("exited_idle_without_work"));
8537 assert!(!diagnostic.contains(JIT));
8538 assert!(!diagnostic.contains("ghp_"));
8539 assert!(!attempt.runtime_path().exists());
8540 }
8541
8542 #[test]
8543 fn native_process_listing_never_contains_jit_and_handoffs_never_survive() {
8544 let root = tempfile::tempdir().unwrap();
8545 let policy = fixtures::policy()
8546 .repository("octo/repo")
8547 .autoscale("home", 1)
8548 .active()
8549 .build();
8550 let runtime = root.path().join("successful");
8551 fs::create_dir_all(&runtime).unwrap();
8552 let processes = NativeProcesses::new();
8553 let config = EncodedJitConfig::new(JIT);
8554 let handoff =
8555 RestrictiveHandoff::create(&runtime, SecretString::from(config.expose().to_owned()))
8556 .unwrap();
8557 let mut child = native_inspection_spec()
8558 .spawn_runner_with_handoff(&handoff)
8559 .expect("native child starts");
8560 let pid = child.pid();
8561 handoff.delete().unwrap();
8562 let command_line = native_command_line(pid);
8563 assert!(
8564 !command_line.contains(JIT),
8565 "the encoded JIT configuration appeared in the native process listing"
8566 );
8567 assert_no_jit_file(&runtime);
8568 child
8569 .stop(Duration::from_secs(1))
8570 .expect("native child stops");
8571
8572 let failed_runtime = root.path().join("failed");
8573 fs::create_dir_all(&failed_runtime).unwrap();
8574 let failed = RunnerAttempt::allocate(
8575 AttemptId::new_random(),
8576 policy.id,
8577 &failed_runtime,
8578 FakeClock::default().now(),
8579 );
8580 assert!(
8581 processes
8582 .spawn(&failed, &EncodedJitConfig::new(JIT))
8583 .is_err(),
8584 "a runtime with no runner executable must fail"
8585 );
8586 assert_no_jit_file(&failed_runtime);
8587 processes
8588 .record_terminate_intent(&failed)
8589 .expect("the intent file and its directory entry are durably synced");
8590 assert_eq!(
8591 fs::read(NativeProcesses::intent_path(&failed)).unwrap(),
8592 b"registration-timeout\n"
8593 );
8594 }
8595
8596 #[test]
8597 fn post_spawn_boundaries_are_bounded_durable_and_never_retry_jit() {
8598 let root = tempfile::tempdir().unwrap();
8599 let policy = fixtures::policy()
8600 .repository("octo/repo")
8601 .autoscale("home", 1)
8602 .active()
8603 .build();
8604 let processes = NativeProcesses::new();
8605 processes.use_long_lived_test_listener();
8606 for (index, boundary) in [
8607 PostSpawnBoundary::HandoffDelete,
8608 PostSpawnBoundary::IdentitySerialize,
8609 PostSpawnBoundary::IdentityWrite,
8610 PostSpawnBoundary::ChildMapInsert,
8611 ]
8612 .into_iter()
8613 .enumerate()
8614 {
8615 let runtime = root.path().join(format!("post-spawn-{index}"));
8616 let bin = runtime.join("bin");
8617 fs::create_dir_all(&bin).unwrap();
8618 #[cfg(windows)]
8619 let listener = bin.join("Runner.Listener.exe");
8620 #[cfg(not(windows))]
8621 let listener = bin.join("Runner.Listener");
8622 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
8623 let attempt = RunnerAttempt::allocate(
8624 AttemptId::new_random(),
8625 policy.id,
8626 &runtime,
8627 FakeClock::default().now(),
8628 );
8629 processes.fail_post_spawn_at(boundary);
8630 let failure = processes
8631 .spawn(&attempt, &EncodedJitConfig::new(JIT))
8632 .expect_err("fault must cross the post-spawn cleanup path");
8633 assert!(!failure.retryable, "{boundary:?} allowed duplicate retry");
8634 assert!(
8635 !processes.is_alive(&attempt).unwrap(),
8636 "{boundary:?} left a child"
8637 );
8638 assert!(!NativeProcesses::identity_path(&attempt).exists());
8639 assert_no_jit_file(&runtime);
8640 }
8641 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
8642
8643 let runtime = root.path().join("identity-and-stop-fail");
8644 let bin = runtime.join("bin");
8645 fs::create_dir_all(&bin).unwrap();
8646 #[cfg(windows)]
8647 let listener = bin.join("Runner.Listener.exe");
8648 #[cfg(not(windows))]
8649 let listener = bin.join("Runner.Listener");
8650 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
8651 let attempt = RunnerAttempt::allocate(
8652 AttemptId::new_random(),
8653 policy.id,
8654 &runtime,
8655 FakeClock::default().now(),
8656 );
8657 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
8661 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
8662 processes.fail_next_post_spawn_stop();
8663 let failure = processes
8664 .spawn(&attempt, &EncodedJitConfig::new(JIT))
8665 .expect_err("the identity boundary must fail closed");
8666 assert!(failure.live_pid.is_some());
8667 assert_long_lived_listener_ready(&processes, &attempt);
8668 assert!(processes.is_alive(&attempt).unwrap());
8669 assert!(!NativeProcesses::identity_path(&attempt).exists());
8670 assert!(NativeProcesses::fallback_identity_path(&attempt).is_file());
8671 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
8672 processes.terminate(&attempt).unwrap();
8673
8674 let runtime = root.path().join("persistent-stop-and-identity-failures");
8675 let bin = runtime.join("bin");
8676 fs::create_dir_all(&bin).unwrap();
8677 #[cfg(windows)]
8678 let listener = bin.join("Runner.Listener.exe");
8679 #[cfg(not(windows))]
8680 let listener = bin.join("Runner.Listener");
8681 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
8682 let mut unresolved = RunnerAttempt::allocate(
8683 AttemptId::new_random(),
8684 policy.id,
8685 &runtime,
8686 FakeClock::default().now(),
8687 );
8688 for _ in 0..3 {
8689 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
8690 }
8691 processes.fail_post_spawn_stops(MAX_POST_SPAWN_STOP_ATTEMPTS);
8692 let failure = processes
8693 .spawn(&unresolved, &EncodedJitConfig::new(JIT))
8694 .expect_err("bounded cleanup must return even when every stop errors");
8695 let pid = failure
8696 .live_pid
8697 .expect("the owned child remains supervised in this invocation");
8698 assert!(matches!(failure.reason, FailureReason::Other(_)));
8699 assert_long_lived_listener_ready(&processes, &unresolved);
8700 unresolved.jit_received(FakeClock::default().now()).unwrap();
8701 unresolved.started(pid, FakeClock::default().now()).unwrap();
8702 let journal = SqliteStore::open_in_memory().unwrap();
8703 journal.record_attempt(&unresolved).unwrap();
8704 let recovered = journal.attempt(unresolved.id).unwrap().unwrap();
8705 assert_eq!(recovered.process_id(), Some(pid));
8706 assert_eq!(recovered.state(), AttemptState::Starting);
8707 assert!(processes.is_alive(&unresolved).unwrap());
8708 assert!(!NativeProcesses::identity_path(&unresolved).exists());
8709 assert!(!NativeProcesses::fallback_identity_path(&unresolved).exists());
8710 assert_eq!(
8711 fs::read_to_string(NativeProcesses::unresolved_process_path(&unresolved)).unwrap(),
8712 pid.to_string(),
8713 "bounded cleanup must leave durable unresolved-process evidence before returning"
8714 );
8715 assert!(
8716 NativeProcesses::new().is_alive(&recovered).is_err(),
8717 "restart must fail closed on the durable starting/PID journal rather than trust a bare PID"
8718 );
8719 processes.terminate(&unresolved).unwrap();
8720
8721 let runtime = root.path().join("post-spawn-stop-failed");
8722 let bin = runtime.join("bin");
8723 fs::create_dir_all(&bin).unwrap();
8724 #[cfg(windows)]
8725 let listener = bin.join("Runner.Listener.exe");
8726 #[cfg(not(windows))]
8727 let listener = bin.join("Runner.Listener");
8728 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
8729 let attempt = RunnerAttempt::allocate(
8730 AttemptId::new_random(),
8731 policy.id,
8732 &runtime,
8733 FakeClock::default().now(),
8734 );
8735 processes.fail_post_spawn_at(PostSpawnBoundary::ChildMapInsert);
8736 processes.fail_next_post_spawn_stop();
8737 let failure = processes
8738 .spawn(&attempt, &EncodedJitConfig::new(JIT))
8739 .expect_err("the injected stop failure must preserve supervision");
8740 let live_pid = failure
8741 .live_pid
8742 .expect("live PID is returned to the journal");
8743 assert!(!failure.retryable);
8744 assert_long_lived_listener_ready(&processes, &attempt);
8745 assert!(NativeProcesses::identity_path(&attempt).is_file());
8746 assert_eq!(
8747 NativeProcesses::read_identity(&attempt)
8748 .unwrap()
8749 .unwrap()
8750 .pid(),
8751 live_pid
8752 );
8753 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
8754 processes.terminate(&attempt).unwrap();
8755 }
8756
8757 #[test]
8758 #[ignore = "spawned only as the platform-stable native listener fixture"]
8759 fn long_lived_native_listener_helper() {
8760 let ready = std::env::var_os("RUNNER_MANAGER_TEST_LISTENER_READY")
8761 .map(PathBuf::from)
8762 .expect("the parent supplies the readiness path");
8763 fs::write(ready, b"ready\n").expect("the listener publishes readiness");
8764 std::thread::sleep(Duration::from_secs(30));
8765 }
8766
8767 fn assert_long_lived_listener_ready(processes: &NativeProcesses, attempt: &RunnerAttempt) {
8768 let ready = attempt.runtime_path().join(TEST_LISTENER_READY);
8769 let deadline = std::time::Instant::now() + Duration::from_secs(5);
8770 loop {
8771 if ready.is_file() {
8772 assert_eq!(fs::read(&ready).unwrap(), b"ready\n");
8773 return;
8774 }
8775 assert!(
8776 processes.is_alive(attempt).unwrap(),
8777 "the native listener exited before publishing readiness"
8778 );
8779 assert!(
8780 std::time::Instant::now() < deadline,
8781 "the native listener stayed alive but never published readiness"
8782 );
8783 std::thread::sleep(Duration::from_millis(10));
8784 }
8785 }
8786
8787 #[tokio::test]
8788 async fn every_production_launch_prunes_under_the_same_allocation_guard() {
8789 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8790 harness.ready().await;
8791 assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 0);
8792 harness.launch().await;
8793 assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 1);
8794 assert_eq!(
8795 *harness.packages.prune_currents.lock().unwrap(),
8796 vec![harness.packages.version.clone()],
8797 "the leased current version is an exclusion, never the prune target"
8798 );
8799 }
8800
8801 fn assert_no_jit_file(runtime: &Path) {
8802 for entry in fs::read_dir(runtime).unwrap() {
8803 let path = entry.unwrap().path();
8804 if path.is_file() {
8805 let bytes = fs::read(&path).unwrap();
8806 assert!(
8807 !bytes
8808 .windows(JIT.len())
8809 .any(|window| window == JIT.as_bytes()),
8810 "a JIT payload survived in a runtime file"
8811 );
8812 }
8813 }
8814 }
8815
8816 #[test]
8817 fn production_listener_command_uses_the_supported_jit_contract() {
8818 let runtime = Path::new("runtime");
8819 let spec = runner_listener_spec(
8820 PathBuf::from("Runner.Listener"),
8821 runtime,
8822 Vec::new(),
8823 &RunnerEnv::default(),
8824 );
8825 let arguments: Vec<_> = spec
8826 .arguments()
8827 .iter()
8828 .map(|argument| argument.to_string_lossy().into_owned())
8829 .collect();
8830
8831 assert_eq!(arguments, ["run"]);
8832 assert!(
8833 !arguments
8834 .iter()
8835 .any(|argument| argument == "--jit-config-file"),
8836 "the obsolete file option would be rejected by Runner.Listener 2.336.0"
8837 );
8838 }
8839
8840 #[test]
8844 fn production_listener_layers_runner_env_over_the_platform_defaults() {
8845 let root = tempfile::tempdir().unwrap();
8846 let runtime = root.path().join("attempt");
8847 let host_env =
8848 RunnerEnv::parse("ELECTRON_CACHE=/cache/electron\nHOME=/operator/chose/this\n")
8849 .unwrap();
8850 let caches = vec![
8851 (
8852 "ELECTRON_CACHE",
8853 std::ffi::OsString::from("/rman/_cache/o/r/electron"),
8854 ),
8855 (
8856 "npm_config_cache",
8857 std::ffi::OsString::from("/rman/_cache/o/r/npm"),
8858 ),
8859 ];
8860 let spec = runner_listener_spec(
8861 PathBuf::from("Runner.Listener"),
8862 &runtime,
8863 caches,
8864 &host_env,
8865 );
8866 let env = |name: &str| spec.configured_env(name).map(PathBuf::from);
8867
8868 assert!(spec.expects_normal_scheduling());
8869 assert_eq!(
8870 env("ELECTRON_CACHE"),
8871 Some(PathBuf::from("/cache/electron")),
8872 "runner.env must win over a dependency cache of the same name"
8873 );
8874 assert_eq!(
8875 env("npm_config_cache"),
8876 Some(PathBuf::from("/rman/_cache/o/r/npm"))
8877 );
8878 assert_eq!(
8879 env("HOME"),
8880 Some(PathBuf::from("/operator/chose/this")),
8881 "runner.env must win over a platform default of the same name"
8882 );
8883 for tmp in ["TMPDIR", "TEMP", "TMP"] {
8884 assert_eq!(env(tmp), Some(runtime.join("tmp")), "{tmp}");
8885 }
8886
8887 let home = runtime.join(runner_env::RUNNER_HOME_DIR);
8888 if cfg!(windows) {
8889 assert_eq!(env("USERPROFILE"), Some(home.clone()));
8892 assert_eq!(env("APPDATA"), Some(home.join(r"AppData\Roaming")));
8893 assert_eq!(env("LOCALAPPDATA"), Some(home.join(r"AppData\Local")));
8894 assert!(home.join(r"AppData\Roaming").is_dir());
8895 assert!(home.join(r"AppData\Local").is_dir());
8896 } else {
8897 assert_eq!(env("USERPROFILE"), None);
8899 assert!(!home.exists());
8900 }
8901 }
8902
8903 #[test]
8904 fn an_invalid_runner_env_fails_the_launch_without_echoing_a_value() {
8905 let root = tempfile::tempdir().unwrap();
8906 let path = runner_env::path_in(root.path());
8907 fs::write(&path, "GOOD=1\nghp_notarealtokenbutshapedlikeone\n").unwrap();
8908 let processes = NativeProcesses::new().with_runner_env_file(path.clone());
8909 let failure = processes.host_env().unwrap_err().to_string();
8910 assert!(
8911 failure.contains("runner.env") && failure.contains("line 2"),
8912 "{failure}"
8913 );
8914 assert!(
8915 !failure.contains("ghp_"),
8916 "the failure echoed a value: {failure}"
8917 );
8918
8919 let policy = fixtures::policy()
8922 .repository("octo/repo")
8923 .autoscale("home", 1)
8924 .active()
8925 .build();
8926 let attempt = RunnerAttempt::allocate(
8927 AttemptId::new_random(),
8928 policy.id,
8929 root.path(),
8930 FakeClock::default().now(),
8931 );
8932 assert!(processes.prepare(&attempt, &policy).is_err());
8933
8934 fs::write(&path, "GOOD=1\n").unwrap();
8935 assert!(processes.prepare(&attempt, &policy).is_ok());
8936 assert_eq!(processes.host_env().unwrap().get("GOOD"), Some("1"));
8937 assert!(NativeProcesses::new().host_env().unwrap().is_empty());
8938 }
8939
8940 #[test]
8944 fn prepare_hands_the_policys_cache_namespace_to_the_launch() {
8945 let root = tempfile::tempdir().unwrap();
8946 let runner_root = root.path().join("rman");
8947 let runtime = runner_root.join("a1");
8948 fs::create_dir_all(runtime.join("bin")).unwrap();
8949 let config = runner_manager_platform::dependency_cache::config_path_in(root.path());
8950 let source_root = runner_root.clone();
8951 let processes = NativeProcesses::new().with_dependency_caches(DependencyCaches::new(
8952 config.clone(),
8953 Arc::new(move || Some(source_root.clone())),
8954 None,
8955 ));
8956 let policy = fixtures::policy()
8957 .repository("Octo/Repo")
8958 .autoscale("home", 1)
8959 .active()
8960 .build();
8961 let attempt = RunnerAttempt::allocate(
8962 AttemptId::new_random(),
8963 policy.id,
8964 &runtime,
8965 FakeClock::default().now(),
8966 );
8967 assert!(
8968 processes
8969 .cache_env(&attempt, None, &RunnerEnv::default())
8970 .is_empty(),
8971 "a launch with no prepared target, such as a bare `spawn`, has no cache"
8972 );
8973 let prepared = processes.prepare(&attempt, &policy).unwrap();
8974 let variables = processes.cache_env(
8975 &attempt,
8976 prepared.cache_target.as_ref(),
8977 &RunnerEnv::default(),
8978 );
8979 let npm = variables
8980 .iter()
8981 .find(|(name, _)| *name == "npm_config_cache")
8982 .map(|(_, value)| PathBuf::from(value));
8983 assert_eq!(
8984 npm,
8985 Some(
8986 runner_root
8987 .join("_cache")
8988 .join("octo")
8989 .join("repo")
8990 .join("npm")
8991 )
8992 );
8993
8994 fs::write(&config, "[tools]\nnot-a-tool = true\n").unwrap();
8995 let refused = processes
8996 .prepare(&attempt, &policy)
8997 .unwrap_err()
8998 .to_string();
8999 assert!(refused.contains("caches.toml"), "{refused}");
9000 }
9001
9002 #[cfg(windows)]
9003 fn native_inspection_spec() -> SpawnSpec {
9004 SpawnSpec::new("powershell.exe").args([
9005 "-NoProfile",
9006 "-NonInteractive",
9007 "-Command",
9008 "Start-Sleep -Seconds 30",
9009 ])
9010 }
9011
9012 #[cfg(unix)]
9013 fn native_inspection_spec() -> SpawnSpec {
9014 SpawnSpec::new("/bin/sh").args(["-c", "sleep 30"])
9015 }
9016
9017 #[cfg(windows)]
9018 fn native_command_line(pid: u32) -> String {
9019 let output = std::process::Command::new("powershell.exe")
9020 .args([
9021 "-NoProfile",
9022 "-NonInteractive",
9023 "-Command",
9024 &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId = {pid}').CommandLine"),
9025 ])
9026 .output()
9027 .expect("PowerShell can inspect the native child");
9028 assert!(output.status.success(), "native process inspection failed");
9029 String::from_utf8(output.stdout).expect("Windows command lines are Unicode")
9030 }
9031
9032 #[cfg(target_os = "linux")]
9033 fn native_command_line(pid: u32) -> String {
9034 fs::read(format!("/proc/{pid}/cmdline"))
9035 .map(|bytes| String::from_utf8_lossy(&bytes).replace('\0', " "))
9036 .expect("/proc exposes the native child command line")
9037 }
9038
9039 #[cfg(target_os = "macos")]
9040 fn native_command_line(pid: u32) -> String {
9041 let output = std::process::Command::new("ps")
9042 .args(["-o", "command=", "-p", &pid.to_string()])
9043 .output()
9044 .expect("ps can inspect the native child");
9045 assert!(output.status.success(), "native process inspection failed");
9046 String::from_utf8(output.stdout).expect("the command line is UTF-8")
9047 }
9048
9049 #[tokio::test]
9052 async fn a_persistent_repository_leases_s1_and_journals_it_before_any_github_effect() {
9053 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9054 .with_persistent_workspace(2);
9055 harness.github.watch_journal(Arc::clone(&harness.store));
9056 harness.ready().await;
9057
9058 let attempt = harness.launch().await;
9059
9060 assert_eq!(
9061 attempt.workspace(),
9062 AttemptWorkspace::persistent_slot(nz(1)),
9063 "the lowest free slot is leased"
9064 );
9065 assert_eq!(attempt.runtime_path(), harness.slot_path(1));
9066 assert!(attempt.holds_slot_lease());
9067 assert_eq!(
9069 harness.attempt(attempt.id).runtime_path(),
9070 harness.slot_path(1)
9071 );
9072
9073 let facts = harness.github.registration_facts();
9077 assert_eq!(facts.len(), 1);
9078 assert_eq!(
9079 facts[0].leased_slots,
9080 vec![1],
9081 "the lease was journalled first"
9082 );
9083 assert_eq!(facts[0].work_folder, DEFAULT_WORK_FOLDER);
9084 }
9085
9086 #[tokio::test]
9087 async fn a_terminal_but_uncleaned_attempt_keeps_its_slot_without_holding_capacity() {
9088 let harness = Harness::new(
9089 FakeGithubLifecycle::default().fail(true),
9090 Arc::new(PersistentDemand),
9091 )
9092 .with_persistent_workspace(2);
9093 harness.ready().await;
9094
9095 harness.launch_result().await.unwrap_err();
9097 let first = harness.store.attempts().unwrap().remove(0);
9098 assert_eq!(first.state(), AttemptState::Failed);
9099 assert!(
9100 !first.state().counts_against_capacity(),
9101 "a concluded attempt is invisible to host capacity"
9102 );
9103 assert!(
9104 first.holds_slot_lease(),
9105 "and still owns its directory, so its slot is not free"
9106 );
9107
9108 let second = harness.launch().await;
9109 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
9110 assert_eq!(second.runtime_path(), harness.slot_path(2));
9111 assert_eq!(
9112 harness
9113 .store
9114 .slot_leases_for_policy(harness.policy.id)
9115 .unwrap()
9116 .len(),
9117 2
9118 );
9119 }
9120
9121 #[tokio::test]
9122 async fn two_sequential_allocations_at_capacity_one_reuse_s1_and_its_retained_work() {
9123 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9124 .with_persistent_workspace(1);
9125 harness.ready().await;
9126
9127 let first = harness.launch().await;
9128 assert_eq!(first.runtime_path(), harness.slot_path(1));
9129
9130 let checkout = harness.slot_path(1).join(DEFAULT_WORK_FOLDER).join("repo");
9132 fs::create_dir_all(&checkout).unwrap();
9133 fs::write(checkout.join("checkout.txt"), b"from the first job").unwrap();
9134
9135 harness.cleanup_retaining_work(first.id).await;
9136
9137 let second = harness.launch().await;
9138 assert_ne!(second.id, first.id);
9139 assert_eq!(
9140 second.workspace(),
9141 AttemptWorkspace::persistent_slot(nz(1)),
9142 "a released slot is leased again rather than skipped"
9143 );
9144 assert_eq!(
9145 second.runtime_path(),
9146 first.runtime_path(),
9147 "the same slot is the same exact path"
9148 );
9149 assert_eq!(
9150 fs::read_to_string(checkout.join("checkout.txt")).unwrap(),
9151 "from the first job",
9152 "the retained job workspace survived the second allocation"
9153 );
9154 assert!(harness.slot_path(1).join("runner-package").exists());
9156 }
9157
9158 #[tokio::test]
9159 async fn lowering_capacity_leaves_higher_slots_alone_and_raising_it_permits_them_again() {
9160 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9161 .with_persistent_workspace(2);
9162 harness.ready().await;
9163
9164 let first = harness.launch().await;
9165 let second = harness.launch().await;
9166 assert_eq!(second.runtime_path(), harness.slot_path(2));
9167 let kept = harness
9168 .slot_path(2)
9169 .join(DEFAULT_WORK_FOLDER)
9170 .join("kept.txt");
9171 fs::create_dir_all(kept.parent().unwrap()).unwrap();
9172 fs::write(&kept, b"s2 was here").unwrap();
9173 harness.cleanup_retaining_work(second.id).await;
9174
9175 harness.policy.set_max_capacity(nz(1)).unwrap();
9177 let refusal = harness.launch_result().await.unwrap_err().to_string();
9178 assert!(
9179 refusal.contains("s1 to s1"),
9180 "the refusal names the ceiling it reached: {refusal}"
9181 );
9182 assert!(
9183 harness.slot_path(2).exists() && kept.exists(),
9184 "lowering capacity deletes nothing; the higher slot is merely unusable"
9185 );
9186
9187 harness.policy.set_max_capacity(nz(2)).unwrap();
9189 let third = harness.launch().await;
9190 assert_eq!(third.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
9191 assert_eq!(third.runtime_path(), harness.slot_path(2));
9192 assert_eq!(fs::read_to_string(&kept).unwrap(), "s2 was here");
9193 assert!(first.holds_slot_lease(), "s1 was never disturbed");
9194 }
9195
9196 #[tokio::test]
9197 async fn organization_and_ephemeral_policies_never_enter_slot_allocation() {
9198 for policy in [
9199 fixtures::policy()
9200 .organization("octo")
9201 .autoscale("home", 2)
9202 .active()
9203 .build(),
9204 fixtures::policy()
9205 .repository("octo/repo")
9206 .autoscale("home", 2)
9207 .active()
9208 .build(),
9209 ] {
9210 let mut harness =
9211 Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9212 .with_host_runner_root();
9213 assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
9214 harness.policy = policy;
9215 harness.ready().await;
9216
9217 let attempt = harness.launch().await;
9218 assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
9219 assert_eq!(attempt.workspace().slot_number(), None);
9220 assert!(!attempt.holds_slot_lease());
9221 assert_eq!(
9222 attempt.runtime_path().parent().unwrap(),
9223 harness.host_root(),
9224 "a disposable attempt is a child of the host root, never of a slot"
9225 );
9226 assert!(
9227 harness
9228 .store
9229 .slot_leases_for_policy(harness.policy.id)
9230 .unwrap()
9231 .is_empty()
9232 );
9233 }
9234 }
9235
9236 #[tokio::test]
9237 async fn two_concurrent_allocations_never_share_a_slot() {
9238 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9239 .with_persistent_workspace(2);
9240 harness.github.watch_journal(Arc::clone(&harness.store));
9241 harness.ready().await;
9242
9243 let (first, second) = tokio::join!(harness.launch_result(), harness.launch_result());
9247 let first = first.unwrap();
9248 let second = second.unwrap();
9249
9250 let slots: BTreeSet<u16> = [&first, &second]
9251 .iter()
9252 .map(|attempt| {
9253 attempt
9254 .workspace()
9255 .slot_number()
9256 .expect("a persistent attempt leases a slot")
9257 })
9258 .collect();
9259 assert_eq!(slots, BTreeSet::from([1, 2]), "one slot each, never shared");
9260 assert_ne!(first.runtime_path(), second.runtime_path());
9261 assert_eq!(
9262 harness
9263 .store
9264 .slot_leases_for_policy(harness.policy.id)
9265 .unwrap()
9266 .len(),
9267 2
9268 );
9269
9270 let facts = harness.github.registration_facts();
9275 assert_eq!(facts.len(), 2);
9276 for fact in facts {
9277 let attempt = [&first, &second]
9278 .into_iter()
9279 .find(|attempt| runner_name(attempt.id) == fact.runner_name)
9280 .expect("every registration belongs to one of the two attempts");
9281 let slot = attempt
9282 .workspace()
9283 .slot_number()
9284 .expect("a persistent attempt leases a slot");
9285 assert!(
9286 fact.leased_slots.contains(&slot),
9287 "a JIT request never precedes its own lease: s{slot} not in {:?}",
9288 fact.leased_slots
9289 );
9290 assert_eq!(fact.work_folder, DEFAULT_WORK_FOLDER);
9291 }
9292 }
9293
9294 #[tokio::test]
9295 async fn the_database_is_the_final_fence_against_two_attempts_in_one_slot() {
9296 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9297 .with_persistent_workspace(2);
9298 harness.ready().await;
9299 let first = harness.launch().await;
9300
9301 let clash = RunnerAttempt::allocate_in(
9305 AttemptId::new_random(),
9306 harness.policy.id,
9307 first.runtime_path(),
9308 AttemptWorkspace::persistent_slot(nz(1)),
9309 harness.clock.now(),
9310 );
9311 assert!(matches!(
9312 harness.store.record_attempt(&clash).unwrap_err(),
9313 StoreError::SlotAlreadyLeased { slot: 1, .. }
9314 ));
9315
9316 let error = harness.launcher.record_allocation(&clash).unwrap_err();
9319 let rendered = error.to_string();
9320 assert!(rendered.contains("slot s1"), "{rendered}");
9321 assert!(rendered.contains("nothing was written"), "{rendered}");
9322 assert_eq!(
9323 harness.store.attempts().unwrap().len(),
9324 1,
9325 "the losing allocator journalled nothing"
9326 );
9327 }
9328
9329 #[test]
9330 fn slot_selection_fills_the_lowest_gap_and_stops_at_the_ceiling() {
9331 let leased = |slots: &[u16]| -> Vec<RunnerAttempt> {
9332 slots
9333 .iter()
9334 .map(|slot| {
9335 RunnerAttempt::allocate_in(
9336 AttemptId::new_random(),
9337 fixtures::POLICY_ID,
9338 format!("/srv/rman/acme/s{slot}"),
9339 AttemptWorkspace::persistent_slot(nz(*slot)),
9340 fixtures::created_at(),
9341 )
9342 })
9343 .collect()
9344 };
9345
9346 assert_eq!(lowest_free_slot(&[], nz(1)), Some(nz(1)));
9347 assert_eq!(lowest_free_slot(&leased(&[1]), nz(4)), Some(nz(2)));
9348 assert_eq!(lowest_free_slot(&leased(&[1, 3]), nz(4)), Some(nz(2)));
9350 assert_eq!(lowest_free_slot(&leased(&[1]), nz(1)), None);
9352 assert_eq!(lowest_free_slot(&leased(&[1, 2]), nz(2)), None);
9353 let ephemeral = vec![RunnerAttempt::allocate(
9355 AttemptId::new_random(),
9356 fixtures::POLICY_ID,
9357 "/srv/rman/host/abc",
9358 fixtures::created_at(),
9359 )];
9360 assert_eq!(lowest_free_slot(&ephemeral, nz(1)), Some(nz(1)));
9361 }
9362
9363 #[test]
9364 fn a_slot_is_reusable_only_when_it_is_empty_or_holds_one_real_work_directory() {
9365 let root = tempfile::tempdir().unwrap();
9366 let slot = root.path().join("s1");
9367 fs::create_dir(&slot).unwrap();
9368 accept_reusable_slot(&slot).expect("an empty slot is reusable");
9369
9370 fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
9371 accept_reusable_slot(&slot).expect("a retained job workspace is reusable");
9372
9373 fs::create_dir(slot.join("bin")).unwrap();
9376 fs::write(slot.join(".github-runner-id"), b"73").unwrap();
9377 let refusal = accept_reusable_slot(&slot).unwrap_err().to_string();
9378 assert!(refusal.contains("bin"), "{refusal}");
9379 assert!(refusal.contains(".github-runner-id"), "{refusal}");
9380
9381 let file_work = root.path().join("s2");
9383 fs::create_dir(&file_work).unwrap();
9384 fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
9385 assert!(accept_reusable_slot(&file_work).is_err());
9386 }
9387
9388 #[cfg(unix)]
9389 #[test]
9390 fn a_link_shaped_work_directory_is_refused_rather_than_followed() {
9391 let root = tempfile::tempdir().unwrap();
9395 let elsewhere = root.path().join("elsewhere");
9396 fs::create_dir(&elsewhere).unwrap();
9397
9398 let slot = root.path().join("s1");
9399 fs::create_dir(&slot).unwrap();
9400 std::os::unix::fs::symlink(&elsewhere, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
9401 assert!(accept_reusable_slot(&slot).is_err());
9402
9403 let linked_slot = root.path().join("s2");
9404 std::os::unix::fs::symlink(&elsewhere, &linked_slot).unwrap();
9405 assert!(create_or_validate_slot(&linked_slot).is_err());
9406 }
9407
9408 #[test]
9409 fn a_slot_standing_where_a_file_is_refuses_rather_than_replacing_it() {
9410 let root = tempfile::tempdir().unwrap();
9411 let occupied = root.path().join("s1");
9412 fs::write(&occupied, b"an operator's file").unwrap();
9413 let refusal = create_or_validate_slot(&occupied).unwrap_err().to_string();
9414 assert!(refusal.contains("is not a directory"), "{refusal}");
9415 assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
9416
9417 let fresh = root.path().join("s2");
9418 create_or_validate_slot(&fresh).expect("a missing slot is created");
9419 assert!(fresh.is_dir());
9420 create_or_validate_slot(&fresh).expect("an existing directory is accepted");
9421 }
9422
9423 #[test]
9424 fn the_retained_work_directory_is_matched_the_way_the_filesystem_matches_it() {
9425 assert!(is_work_folder(OsStr::new(DEFAULT_WORK_FOLDER)));
9426 assert!(!is_work_folder(OsStr::new("_work2")));
9427 assert_eq!(is_work_folder(OsStr::new("_Work")), cfg!(windows));
9431 }
9432
9433 #[test]
9434 fn package_materialization_never_overwrites_or_follows_a_retained_work_directory() {
9435 let root = tempfile::tempdir().unwrap();
9436 let package = root.path().join("package");
9437 fs::create_dir_all(package.join("bin")).unwrap();
9438 fs::write(package.join("bin").join("Runner.Listener"), b"binary").unwrap();
9439 fs::create_dir_all(package.join("externals").join(DEFAULT_WORK_FOLDER)).unwrap();
9441
9442 let slot = root.path().join("s1");
9443 let retained = slot.join(DEFAULT_WORK_FOLDER).join("repo");
9444 fs::create_dir_all(&retained).unwrap();
9445 fs::write(retained.join("checkout.txt"), b"from the first job").unwrap();
9446
9447 copy_package_tree(&package, &slot, &TrustedTrees::default())
9448 .expect("the package lays out around `_work`");
9449 assert!(slot.join("bin").join("Runner.Listener").exists());
9450 assert!(
9451 slot.join("externals").join(DEFAULT_WORK_FOLDER).is_dir(),
9452 "the guard is top-level only"
9453 );
9454 assert_eq!(
9455 fs::read_to_string(retained.join("checkout.txt")).unwrap(),
9456 "from the first job"
9457 );
9458
9459 fs::create_dir(package.join(DEFAULT_WORK_FOLDER)).unwrap();
9461 let error = copy_package_tree(&package, &slot, &TrustedTrees::default()).unwrap_err();
9462 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
9463 assert_eq!(
9464 fs::read_to_string(retained.join("checkout.txt")).unwrap(),
9465 "from the first job"
9466 );
9467 }
9468
9469 #[test]
9470 fn rolling_back_a_materialization_keeps_a_slot_but_removes_a_disposable_directory() {
9471 let root = tempfile::tempdir().unwrap();
9472
9473 let slot = root.path().join("s1");
9474 let retained = slot.join(DEFAULT_WORK_FOLDER);
9475 fs::create_dir_all(retained.join("repo")).unwrap();
9476 fs::write(retained.join("repo").join("checkout.txt"), b"kept").unwrap();
9477 fs::create_dir_all(slot.join("bin")).unwrap();
9478 fs::write(slot.join(".github-runner-id"), b"73").unwrap();
9479 let persistent = RunnerAttempt::allocate_in(
9480 AttemptId::new_random(),
9481 fixtures::POLICY_ID,
9482 &slot,
9483 AttemptWorkspace::persistent_slot(nz(1)),
9484 fixtures::created_at(),
9485 );
9486
9487 remove_materialized_package(&persistent).unwrap();
9488 assert!(slot.is_dir(), "the slot itself is not removed");
9489 assert!(!slot.join("bin").exists());
9490 assert!(!slot.join(".github-runner-id").exists());
9491 assert_eq!(
9492 fs::read_to_string(retained.join("repo").join("checkout.txt")).unwrap(),
9493 "kept"
9494 );
9495
9496 let disposable_path = root.path().join("abcdef012345");
9497 fs::create_dir_all(disposable_path.join(DEFAULT_WORK_FOLDER)).unwrap();
9498 let disposable = RunnerAttempt::allocate(
9499 AttemptId::new_random(),
9500 fixtures::POLICY_ID,
9501 &disposable_path,
9502 fixtures::created_at(),
9503 );
9504 remove_materialized_package(&disposable).unwrap();
9505 assert!(
9506 !disposable_path.exists(),
9507 "a disposable directory is still removed whole"
9508 );
9509 }
9510
9511 fn litter_the_slot(slot: &Path) {
9521 for directory in ["bin", "externals", "_diag"] {
9522 fs::create_dir_all(slot.join(directory)).unwrap();
9523 }
9524 fs::write(slot.join("bin").join("Runner.Listener"), b"binary").unwrap();
9525 for file in SENSITIVE_SLOT_ENTRIES
9526 .iter()
9527 .filter(|entry| !slot.join(entry).is_dir())
9528 {
9529 fs::write(slot.join(file), b"runner state").unwrap();
9530 }
9531 fs::write(
9533 slot.join(format!(
9534 "{}0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.tmp",
9535 RestrictiveHandoff::NAME_PREFIX
9536 )),
9537 JIT.as_bytes(),
9538 )
9539 .unwrap();
9540 }
9541
9542 fn retain_under_work(slot: &Path) -> PathBuf {
9544 let checkout = slot.join(DEFAULT_WORK_FOLDER).join("repo").join("target");
9545 fs::create_dir_all(&checkout).unwrap();
9546 let marker = checkout.join("build-output.bin");
9547 fs::write(&marker, RETAINED).unwrap();
9548 marker
9549 }
9550
9551 const RETAINED: &str = "a Git-ignored build output the next job reuses";
9553
9554 fn entries_of(directory: &Path) -> Vec<String> {
9556 let mut names: Vec<String> = fs::read_dir(directory)
9557 .unwrap()
9558 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
9559 .collect();
9560 names.sort();
9561 names
9562 }
9563
9564 fn only_the_job_workspace() -> Vec<String> {
9566 vec![DEFAULT_WORK_FOLDER.to_owned()]
9567 }
9568
9569 #[cfg(unix)]
9570 #[test]
9571 fn disposable_tree_removal_does_not_open_a_dotnet_diagnostic_fifo() {
9572 use std::sync::mpsc;
9573
9574 let temporary = tempfile::tempdir().unwrap();
9575 let tree = temporary.path().join("attempt");
9576 let diagnostic = tree.join("tmp/clr-debug-pipe-runner-in");
9577 fs::create_dir_all(diagnostic.parent().unwrap()).unwrap();
9578 assert!(
9579 std::process::Command::new("mkfifo")
9580 .arg(&diagnostic)
9581 .status()
9582 .unwrap()
9583 .success()
9584 );
9585
9586 let (finished, result) = mpsc::channel();
9587 std::thread::spawn(move || {
9588 let removed = remove_runtime_tree(&tree);
9589 let _ = finished.send(removed);
9590 });
9591
9592 result
9593 .recv_timeout(Duration::from_secs(2))
9594 .expect("runtime deletion must not wait for a FIFO peer")
9595 .unwrap();
9596 assert!(!diagnostic.exists());
9597 }
9598
9599 #[cfg(windows)]
9602 #[test]
9603 fn disposable_tree_removal_unlinks_a_junction_nobody_may_list() {
9604 let temporary = tempfile::tempdir().unwrap();
9605 let tree = temporary.path().join("attempt");
9606 let cache = tree.join(r"tmp\home\AppData\Local\Microsoft\Windows\INetCache");
9607 let outside = temporary.path().join("outside");
9608 fs::create_dir_all(&cache).unwrap();
9609 fs::create_dir_all(&outside).unwrap();
9610 fs::write(outside.join("keep.txt"), b"not the attempt's").unwrap();
9611 fs::write(tree.join("tmp").join("job.log"), b"job output").unwrap();
9612 let junction = cache.join("Content.IE5");
9613 let created = std::process::Command::new("cmd")
9614 .arg("/C")
9615 .arg("mklink")
9616 .arg("/J")
9617 .arg(&junction)
9618 .arg(&outside)
9619 .output()
9620 .unwrap();
9621 assert!(created.status.success(), "{created:?}");
9622 let denied = std::process::Command::new("icacls")
9626 .arg(&junction)
9627 .args(["/L", "/deny", "*S-1-1-0:(RD)"])
9628 .output()
9629 .unwrap();
9630 assert!(denied.status.success(), "{denied:?}");
9631
9632 let unfixed = remove_dir_all::remove_dir_all(&tree)
9634 .expect_err("the fixture no longer reproduces the WinINet junction");
9635 assert_eq!(unfixed.kind(), std::io::ErrorKind::PermissionDenied);
9636 assert!(junction.symlink_metadata().is_ok());
9637
9638 remove_runtime_tree(&tree).expect("a tree holding a deny-list junction is removed");
9639 assert!(!tree.exists(), "the attempt tree survived");
9640 assert_eq!(
9641 fs::read(outside.join("keep.txt")).unwrap(),
9642 b"not the attempt's",
9643 "removing the junction followed it into the directory it points at"
9644 );
9645 }
9646
9647 struct BlockedDeletion {
9663 directory: PathBuf,
9664 #[cfg(windows)]
9665 _handle: fs::File,
9666 }
9667
9668 impl BlockedDeletion {
9669 const HELD: &'static str = "held-open";
9670
9671 fn inject(directory: &Path) -> Option<Self> {
9674 #[cfg(unix)]
9675 if !Self::refusal_is_possible() {
9676 return None;
9677 }
9678 fs::create_dir_all(directory).unwrap();
9679 fs::write(
9680 directory.join(Self::HELD),
9681 b"a file the scrub cannot remove",
9682 )
9683 .unwrap();
9684 #[cfg(windows)]
9685 let handle = {
9686 use std::os::windows::fs::OpenOptionsExt;
9687
9688 fs::OpenOptions::new()
9689 .read(true)
9690 .share_mode(0)
9691 .open(directory.join(Self::HELD))
9692 .expect("the blocking handle opens")
9693 };
9694 #[cfg(unix)]
9695 Self::set_mode(directory, 0o555);
9696 Some(Self {
9697 directory: directory.to_path_buf(),
9698 #[cfg(windows)]
9699 _handle: handle,
9700 })
9701 }
9702
9703 fn release(self) {
9704 drop(self);
9705 }
9706
9707 #[cfg(unix)]
9708 fn refusal_is_possible() -> bool {
9709 let probe = tempfile::tempdir().unwrap();
9710 let directory = probe.path().join("probe");
9711 fs::create_dir(&directory).unwrap();
9712 fs::write(directory.join("file"), b"probe").unwrap();
9713 Self::set_mode(&directory, 0o555);
9714 let refused = fs::remove_dir_all(&directory).is_err();
9715 Self::set_mode(&directory, 0o755);
9716 refused
9717 }
9718
9719 #[cfg(unix)]
9720 fn set_mode(directory: &Path, mode: u32) {
9721 use std::os::unix::fs::PermissionsExt;
9722
9723 let mut permissions = fs::metadata(directory).unwrap().permissions();
9724 permissions.set_mode(mode);
9725 fs::set_permissions(directory, permissions).unwrap();
9726 }
9727 }
9728
9729 impl Drop for BlockedDeletion {
9730 fn drop(&mut self) {
9731 #[cfg(unix)]
9732 Self::set_mode(&self.directory, 0o755);
9733 #[cfg(not(unix))]
9734 let _ = &self.directory;
9735 }
9736 }
9737
9738 #[tokio::test]
9739 async fn two_sequential_jobs_keep_the_checkout_and_start_without_the_earlier_runner_state() {
9740 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9741 .with_persistent_workspace(1);
9742 harness.ready().await;
9743
9744 let first = harness.launch().await;
9745 let slot = harness.slot_path(1);
9746 assert_eq!(first.runtime_path(), slot);
9747 assert_eq!(
9748 read_runner_id(&slot),
9749 Some(73),
9750 "the attempt registered, so its identity is on disk"
9751 );
9752 let marker = retain_under_work(&slot);
9753 litter_the_slot(&slot);
9754
9755 harness.cleanup_retaining_work(first.id).await;
9756
9757 assert_eq!(entries_of(&slot), only_the_job_workspace());
9760 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
9761 assert_eq!(
9762 read_runner_id(&slot),
9763 None,
9764 "the first attempt's registration identity is gone before the second starts"
9765 );
9766 assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
9767 assert!(!harness.attempt(first.id).holds_slot_lease());
9768
9769 let second = harness.launch().await;
9770 assert_ne!(second.id, first.id);
9771 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(1)));
9772 assert_eq!(
9773 second.runtime_path(),
9774 slot,
9775 "the same slot, so the same retained `_work`"
9776 );
9777 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
9778 }
9779
9780 #[tokio::test]
9781 async fn cleaning_a_persistent_slot_needs_no_policy_and_scans_no_directory_for_ownership() {
9782 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9783 .with_persistent_workspace(1);
9784 harness.ready().await;
9785 let attempt = harness.launch().await;
9786 let slot = harness.slot_path(1);
9787 let marker = retain_under_work(&slot);
9788 litter_the_slot(&slot);
9789 harness.conclude(attempt.id);
9790
9791 harness
9797 .store
9798 .remove_policy(harness.policy.id, harness.policy.revision())
9799 .unwrap();
9800 assert!(harness.store.policy(harness.policy.id).unwrap().is_none());
9801
9802 harness
9803 .launcher
9804 .clean(attempt.id)
9805 .await
9806 .expect("journal facts alone are enough to clean the slot");
9807
9808 assert_eq!(entries_of(&slot), only_the_job_workspace());
9809 assert!(marker.exists());
9810 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
9811 }
9812
9813 #[tokio::test]
9814 async fn an_injected_partial_deletion_quarantines_the_slot_across_a_restart() {
9815 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9816 .with_persistent_workspace(2);
9817 harness.ready().await;
9818 let first = harness.launch().await;
9819 let slot = harness.slot_path(1);
9820 let marker = retain_under_work(&slot);
9821 litter_the_slot(&slot);
9822 harness.conclude(first.id);
9823
9824 let Some(block) = BlockedDeletion::inject(&slot.join("bin")) else {
9825 eprintln!(
9826 "skipped: this account cannot be refused a deletion, so no partial deletion can \
9827 be injected"
9828 );
9829 return;
9830 };
9831
9832 let refusal = harness
9833 .launcher
9834 .clean(first.id)
9835 .await
9836 .expect_err("a deletion that failed may not report a cleaned slot");
9837 let rendered = refusal.reason.to_string();
9838 assert!(rendered.contains("could not be removed"), "{rendered}");
9839
9840 let held = harness.attempt(first.id);
9841 assert_eq!(held.state(), AttemptState::Failed, "still not cleaned");
9842 assert!(held.holds_slot_lease(), "so the slot is still leased");
9843 assert!(
9844 !held.state().counts_against_capacity(),
9845 "and a concluded attempt still costs the host no capacity"
9846 );
9847
9848 let restarted = harness.restart();
9853 restarted
9854 .recover_startup(std::slice::from_ref(&harness.policy))
9855 .await
9856 .expect("one quarantined slot does not stop the host recovering");
9857 assert_eq!(
9858 harness.attempt(first.id).state(),
9859 AttemptState::Failed,
9860 "the quarantine survived the restart"
9861 );
9862 assert!(
9863 harness
9864 .reconcile_events
9865 .events()
9866 .iter()
9867 .any(|event| matches!(
9868 event,
9869 LifecycleEvent::AttemptCleanFailed {
9870 reason: "slot_entry_could_not_be_removed",
9871 ..
9872 }
9873 )),
9874 "the refusal is reported rather than retried in silence"
9875 );
9876
9877 let guard = harness.allocation_lock.acquire().await.unwrap();
9880 let second = restarted
9881 .launch(LaunchRequest {
9882 host: &harness.host,
9883 policy: &harness.policy,
9884 allocation_guard: &guard,
9885 lock_wait: Duration::ZERO,
9886 })
9887 .await
9888 .expect("the host can still launch");
9889 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
9890 drop(guard);
9891
9892 block.release();
9895 restarted
9896 .clean(first.id)
9897 .await
9898 .expect("the retried cleanup completes");
9899 assert_eq!(entries_of(&slot), only_the_job_workspace());
9900 assert!(marker.exists());
9901 assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
9902 }
9903
9904 #[tokio::test]
9915 async fn an_injected_deletion_failure_leaves_a_disposable_attempt_uncleaned() {
9916 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9917 harness.ready().await;
9918 let attempt = harness.launch().await;
9919 let runtime = attempt.runtime_path().to_path_buf();
9920 assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
9921 harness.conclude(attempt.id);
9922
9923 let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
9924 eprintln!(
9925 "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
9926 );
9927 return;
9928 };
9929
9930 let refusal = harness
9931 .launcher
9932 .clean(attempt.id)
9933 .await
9934 .expect_err("a deletion that failed may not report a removed workspace");
9935 let rendered = refusal.reason.to_string();
9936 assert!(
9937 rendered.contains("could not be removed"),
9938 "the refusal names what happened: {rendered}"
9939 );
9940 assert_ne!(
9941 harness.attempt(attempt.id).state(),
9942 AttemptState::Cleaned,
9943 "an attempt whose directory is still on disk is not cleaned"
9944 );
9945 assert!(
9946 runtime.is_dir(),
9947 "the directory the removal could not finish is still there, which is the fact the journal must keep agreeing with"
9948 );
9949
9950 let restarted = harness.restart();
9951 restarted
9952 .recover_startup(std::slice::from_ref(&harness.policy))
9953 .await
9954 .expect("one locked ephemeral workspace does not stop startup recovery");
9955 assert!(
9956 harness
9957 .reconcile_events
9958 .events()
9959 .iter()
9960 .any(|event| matches!(
9961 event,
9962 LifecycleEvent::AttemptCleanFailed {
9963 attempt: failed_attempt,
9964 reason: "ephemeral_workspace_could_not_be_removed",
9965 ..
9966 } if *failed_attempt == attempt.id
9967 )),
9968 "the deferred cleanup remains visible while the daemon keeps running"
9969 );
9970
9971 block.release();
9974 harness
9975 .launcher
9976 .clean(attempt.id)
9977 .await
9978 .expect("the retried cleanup completes");
9979 assert!(!runtime.exists(), "the whole attempt directory goes");
9980 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
9981 }
9982
9983 #[tokio::test]
9987 async fn supervision_backs_off_a_workspace_it_cannot_remove() {
9988 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9989 harness.ready().await;
9990 let attempt = harness.launch().await;
9991 let runtime = attempt.runtime_path().to_path_buf();
9992 harness.conclude(attempt.id);
9993 let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
9994 eprintln!(
9995 "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
9996 );
9997 return;
9998 };
9999 let failures = || {
10000 harness
10001 .reconcile_events
10002 .events()
10003 .iter()
10004 .filter(|event| {
10005 matches!(
10006 event,
10007 LifecycleEvent::AttemptCleanFailed { attempt: failed, .. }
10008 if *failed == attempt.id
10009 )
10010 })
10011 .count()
10012 };
10013 let supervise = async || {
10014 harness
10015 .launcher
10016 .supervise(&harness.policy)
10017 .await
10018 .expect("a stuck workspace does not fail supervision");
10019 };
10020
10021 supervise().await;
10022 assert_eq!(failures(), 1);
10023 supervise().await;
10024 assert_eq!(
10025 failures(),
10026 1,
10027 "the failed cleanup was retried on the very next pass"
10028 );
10029 harness.clock.advance_secs(31);
10030 supervise().await;
10031 assert_eq!(
10032 failures(),
10033 2,
10034 "the cleanup was not retried once its delay passed"
10035 );
10036
10037 block.release();
10038 harness.clock.advance_secs(61);
10039 supervise().await;
10040 assert!(
10041 !runtime.exists(),
10042 "the retry removes the workspace once it can"
10043 );
10044 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
10045 }
10046
10047 #[tokio::test]
10048 async fn changing_a_repository_back_to_ephemeral_leaves_every_old_slot_untouched() {
10049 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10050 .with_persistent_workspace(1);
10051 harness.ready().await;
10052 let first = harness.launch().await;
10053 let slot = harness.slot_path(1);
10054 let marker = retain_under_work(&slot);
10055 harness.cleanup_retaining_work(first.id).await;
10056
10057 harness
10061 .policy
10062 .set_workspace_policy(WorkspacePolicy::Ephemeral)
10063 .unwrap();
10064
10065 let second = harness.launch().await;
10066 assert_eq!(second.workspace(), AttemptWorkspace::Ephemeral);
10067 assert_eq!(
10068 second.runtime_path().parent().unwrap(),
10069 harness.host_root(),
10070 "a disposable attempt is a child of the host root"
10071 );
10072 assert!(slot.is_dir(), "the old slot is left where it stands");
10073 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
10074
10075 harness.conclude(second.id);
10078 harness.launcher.clean(second.id).await.unwrap();
10079 assert!(!second.runtime_path().exists());
10080 assert!(marker.exists());
10081 }
10082
10083 #[tokio::test]
10084 async fn a_persistent_slot_is_scrubbed_only_after_the_process_is_signalled_and_gone() {
10085 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10086 .with_persistent_workspace(1);
10087 harness.ready().await;
10088 let slot = harness.slot_path(1);
10089 fs::create_dir_all(&slot).unwrap();
10090 let marker = retain_under_work(&slot);
10091 litter_the_slot(&slot);
10092
10093 let id = AttemptId::new_random();
10094 let mut attempt = RunnerAttempt::allocate_in(
10095 id,
10096 harness.policy.id,
10097 &slot,
10098 AttemptWorkspace::persistent_slot(nz(1)),
10099 harness.clock.now(),
10100 );
10101 attempt.jit_received(harness.clock.now()).unwrap();
10102 attempt.started(4242, harness.clock.now()).unwrap();
10103 harness.store.record_attempt(&attempt).unwrap();
10104 harness.clock.advance_secs(11);
10105 harness.processes.set_alive(true);
10106 harness
10107 .github
10108 .observe(GithubRunnerObservation::NotRegistered);
10109
10110 harness.launcher.supervise(&harness.policy).await.unwrap();
10111
10112 let actions = harness.processes.actions.lock().unwrap().clone();
10116 let intent = actions
10117 .iter()
10118 .position(|action| *action == "terminate_intent")
10119 .unwrap();
10120 let signal = actions
10121 .iter()
10122 .position(|action| *action == "terminate")
10123 .unwrap();
10124 assert!(intent < signal, "{actions:?}");
10125 assert!(!harness.processes.alive.load(Ordering::SeqCst));
10126
10127 let cleaned = harness.attempt(id);
10128 assert_eq!(cleaned.state(), AttemptState::Cleaned);
10129 assert!(matches!(
10130 cleaned.outcome(),
10131 Some(AttemptOutcome::Failed {
10132 reason: FailureReason::TerminatedAfterRegistrationTimeout
10133 })
10134 ));
10135 assert_eq!(entries_of(&slot), only_the_job_workspace());
10136 assert!(marker.exists());
10137 }
10138
10139 #[test]
10140 fn a_scrub_retains_one_real_work_directory_and_removes_every_other_entry() {
10141 let root = tempfile::tempdir().unwrap();
10142 let slot = root.path().join("s1");
10143 fs::create_dir(&slot).unwrap();
10144 let marker = retain_under_work(&slot);
10145 litter_the_slot(&slot);
10146 fs::write(slot.join("runner-package"), b"verified").unwrap();
10147
10148 scrub_slot_entries(&slot).expect("a slot of ordinary runner state scrubs");
10149 verify_slot_scrubbed(&slot).expect("and proves it afterwards");
10150
10151 assert_eq!(entries_of(&slot), only_the_job_workspace());
10152 assert!(marker.exists());
10153 }
10154
10155 #[test]
10156 fn a_residue_refusal_never_reports_the_under_count_as_the_fact() {
10157 let slot = Path::new("/runners/s1");
10158
10159 let counted = residue_detail(slot, 2, &["`bin`".to_owned()]);
10162 assert!(counted.contains("2 entries other than"), "{counted}");
10163 assert!(counted.contains("including `bin`"), "{counted}");
10164 assert_eq!(
10165 residue_detail(slot, 1, &[]),
10166 format!(
10167 "1 entry other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}",
10168 slot.display()
10169 )
10170 );
10171
10172 let raced = residue_detail(slot, 0, &["`.credentials`".to_owned()]);
10177 assert!(!raced.contains('0'), "{raced}");
10178 assert!(raced.contains("reported nothing but"), "{raced}");
10179 assert!(raced.contains("`.credentials` survived cleanup"), "{raced}");
10180 }
10181
10182 #[test]
10183 fn verification_asks_the_filesystem_rather_than_the_listing_that_missed_an_entry() {
10184 let root = tempfile::tempdir().unwrap();
10185 let slot = root.path().join("s1");
10186 fs::create_dir(&slot).unwrap();
10187 fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10188 verify_slot_scrubbed(&slot).expect("only `_work` is a clean slot");
10189
10190 for survivor in ["bin", ".credentials", IDENTITY_FILE, RUNNER_ID_FILE] {
10194 fs::write(slot.join(survivor), b"left behind").unwrap();
10195 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
10196 assert_eq!(quarantine.refusal, SlotRefusal::Residue);
10197 assert!(
10198 quarantine.detail.contains(&format!("`{survivor}`")),
10199 "{quarantine}"
10200 );
10201 fs::remove_file(slot.join(survivor)).unwrap();
10202 }
10203
10204 let handoff = slot.join(format!("{}whatever.tmp", RestrictiveHandoff::NAME_PREFIX));
10207 fs::write(&handoff, JIT.as_bytes()).unwrap();
10208 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
10209 assert!(
10210 quarantine.detail.contains("an encoded JIT handoff"),
10211 "{quarantine}"
10212 );
10213 assert!(!quarantine.detail.contains(JIT), "{quarantine}");
10214 fs::remove_file(&handoff).unwrap();
10215
10216 fs::write(slot.join("ghp_DO_NOT_LEAK"), b"named by the job").unwrap();
10220 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
10221 assert!(
10222 quarantine.detail.contains("1 entry other than"),
10223 "{quarantine}"
10224 );
10225 assert!(
10226 !quarantine.detail.contains("ghp_DO_NOT_LEAK"),
10227 "{quarantine}"
10228 );
10229 }
10230
10231 #[test]
10232 fn a_slot_is_derived_from_the_journal_and_refused_when_it_disagrees() {
10233 let root = tempfile::tempdir().unwrap();
10234 let configured =
10235 LocalAbsolutePath::new(root.path().to_str().unwrap()).expect("a local absolute root");
10236 let slot = configured.as_path().join("s1");
10237 fs::create_dir(&slot).unwrap();
10238
10239 verify_journalled_slot(&slot, nz(1), Some(&configured))
10240 .expect("the journalled slot agrees");
10241 verify_journalled_slot(&slot, nz(1), None)
10242 .expect("and a policy that is gone removes a check, not the ability to clean");
10243
10244 assert_eq!(
10247 verify_journalled_slot(&slot, nz(2), None)
10248 .unwrap_err()
10249 .refusal,
10250 SlotRefusal::NotTheJournalledSlot
10251 );
10252 for stray in ["s1/nested", "not-a-slot", "s01"] {
10253 let path = configured.as_path().join(stray);
10254 assert_eq!(
10255 verify_journalled_slot(&path, nz(1), None)
10256 .unwrap_err()
10257 .refusal,
10258 SlotRefusal::NotTheJournalledSlot,
10259 "{}",
10260 path.display()
10261 );
10262 }
10263
10264 let elsewhere = tempfile::tempdir().unwrap();
10267 let other =
10268 LocalAbsolutePath::new(elsewhere.path().to_str().unwrap()).expect("a second root");
10269 assert_eq!(
10270 verify_journalled_slot(&slot, nz(1), Some(&other))
10271 .unwrap_err()
10272 .refusal,
10273 SlotRefusal::PolicyRootDisagrees
10274 );
10275 }
10276
10277 #[cfg(unix)]
10278 #[test]
10279 fn a_substituted_work_directory_quarantines_the_slot_and_deletes_nothing_outside_it() {
10280 let root = tempfile::tempdir().unwrap();
10284 let outside = root.path().join("operator-data");
10285 fs::create_dir(&outside).unwrap();
10286 let sentinel = outside.join("do-not-delete.txt");
10287 fs::write(
10288 &sentinel,
10289 b"an operator's data, outside every approved root",
10290 )
10291 .unwrap();
10292
10293 let slot = root.path().join("s1");
10294 fs::create_dir(&slot).unwrap();
10295 fs::create_dir(slot.join("bin")).unwrap();
10296 std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10297
10298 let quarantine = scrub_slot_entries(&slot).unwrap_err();
10299 assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
10300 assert!(
10301 sentinel.exists(),
10302 "the deletion followed the link out of the slot"
10303 );
10304 assert!(outside.is_dir());
10305 assert!(
10306 slot.join(DEFAULT_WORK_FOLDER).symlink_metadata().is_ok(),
10307 "the substituted link is left for the operator, never unlinked as if it were ours"
10308 );
10309
10310 let file_work = root.path().join("s2");
10314 fs::create_dir(&file_work).unwrap();
10315 fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
10316 assert_eq!(
10317 scrub_slot_entries(&file_work).unwrap_err().refusal,
10318 SlotRefusal::WorkNotADirectory
10319 );
10320 }
10321
10322 #[cfg(unix)]
10323 #[test]
10324 fn a_slot_replaced_by_a_link_out_of_its_root_is_refused_before_anything_is_read() {
10325 let root = tempfile::tempdir().unwrap();
10326 let outside = root.path().join("operator-data");
10327 fs::create_dir(&outside).unwrap();
10328 let sentinel = outside.join("do-not-delete.txt");
10329 fs::write(
10330 &sentinel,
10331 b"an operator's data, outside every approved root",
10332 )
10333 .unwrap();
10334
10335 let inside = root.path().join("inside");
10338 fs::create_dir(&inside).unwrap();
10339 let slot = inside.join("s1");
10340 std::os::unix::fs::symlink(&outside, &slot).unwrap();
10341
10342 assert_eq!(
10343 verify_journalled_slot(&slot, nz(1), None)
10344 .unwrap_err()
10345 .refusal,
10346 SlotRefusal::Containment
10347 );
10348 assert!(sentinel.exists());
10349 assert!(
10350 slot.symlink_metadata().is_ok(),
10351 "the link is left for the operator rather than removed as if it were ours"
10352 );
10353 }
10354
10355 #[cfg(windows)]
10365 #[test]
10366 fn a_slot_root_replaced_by_a_junction_is_refused_before_anything_is_read() {
10367 let root = tempfile::tempdir().unwrap();
10368 let outside = root.path().join("operator-data");
10369 fs::create_dir(&outside).unwrap();
10370 let sentinel = outside.join("do-not-delete.txt");
10371 fs::write(
10372 &sentinel,
10373 b"an operator's data, outside every approved root",
10374 )
10375 .unwrap();
10376
10377 let inside = root.path().join("inside");
10380 fs::create_dir(&inside).unwrap();
10381 let slot = inside.join("s1");
10382 let Some(()) = plant_junction(&slot, &outside) else {
10383 eprintln!("skipped: this machine would not create a directory junction");
10384 return;
10385 };
10386
10387 assert_eq!(
10388 verify_journalled_slot(&slot, nz(1), None)
10389 .unwrap_err()
10390 .refusal,
10391 SlotRefusal::Containment
10392 );
10393 assert!(
10394 sentinel.exists(),
10395 "the refusal resolved the junction and reached the operator's data"
10396 );
10397 assert!(
10398 slot.symlink_metadata().is_ok(),
10399 "the junction is left for the operator rather than removed as if it were ours"
10400 );
10401 }
10402
10403 #[cfg(windows)]
10412 fn plant_junction(link: &Path, target: &Path) -> Option<()> {
10413 let made = std::process::Command::new("cmd")
10414 .arg("/C")
10415 .arg("mklink")
10416 .arg("/J")
10417 .arg(link)
10418 .arg(target)
10419 .output()
10420 .ok()?;
10421 (made.status.success() && link.symlink_metadata().is_ok()).then_some(())
10422 }
10423
10424 #[test]
10429 fn removing_an_attempt_never_reaches_into_the_dependency_caches_it_linked() {
10430 let root = tempfile::tempdir().unwrap();
10431 let cache = root.path().join("_cache").join("o").join("r").join("npm");
10432 fs::create_dir_all(&cache).unwrap();
10433 let cached = cache.join("package.tgz");
10434 fs::write(&cached, b"kept between jobs").unwrap();
10435 let attempt = root.path().join("0123456789abcdef");
10436 let work = attempt.join("_work").join("repo");
10437 fs::create_dir_all(&work).unwrap();
10438 fs::hard_link(&cached, work.join("hard-linked.tgz")).unwrap();
10439 #[cfg(unix)]
10440 std::os::unix::fs::symlink(&cache, work.join("linked-cache")).unwrap();
10441 #[cfg(windows)]
10442 if plant_junction(&work.join("linked-cache"), &cache).is_none() {
10443 eprintln!("this machine would not create a junction; the hard link is still checked");
10444 }
10445
10446 remove_runtime_tree(&attempt).unwrap();
10447 assert!(!attempt.exists());
10448 assert_eq!(fs::read(&cached).unwrap(), b"kept between jobs");
10449 }
10450
10451 #[cfg(windows)]
10452 #[test]
10453 fn a_work_directory_replaced_by_a_junction_fails_closed_and_deletes_nothing_beyond_it() {
10454 let root = tempfile::tempdir().unwrap();
10455 let outside = root.path().join("operator-data");
10456 fs::create_dir(&outside).unwrap();
10457 let sentinel = outside.join("do-not-delete.txt");
10458 fs::write(
10459 &sentinel,
10460 b"an operator's data, outside every approved root",
10461 )
10462 .unwrap();
10463
10464 let slot = root.path().join("s1");
10465 fs::create_dir(&slot).unwrap();
10466 fs::create_dir(slot.join("bin")).unwrap();
10467 let Some(()) = plant_junction(&slot.join(DEFAULT_WORK_FOLDER), &outside) else {
10468 eprintln!("skipped: this machine would not create a directory junction");
10469 return;
10470 };
10471
10472 let work = fs::symlink_metadata(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10476 assert!(is_link_like(&work), "a junction is a reparse point");
10477 let quarantine = scrub_slot_entries(&slot).unwrap_err();
10478 assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
10479 assert!(
10480 sentinel.exists(),
10481 "the deletion followed the junction out of the slot"
10482 );
10483 assert!(outside.is_dir());
10484
10485 let elsewhere = root.path().join("s2");
10488 fs::create_dir(&elsewhere).unwrap();
10489 fs::create_dir(elsewhere.join(DEFAULT_WORK_FOLDER)).unwrap();
10490 if plant_junction(&elsewhere.join("externals"), &outside).is_some() {
10491 scrub_slot_entries(&elsewhere).expect("an ordinary entry is removed, junction or not");
10492 verify_slot_scrubbed(&elsewhere).expect("and the slot verifies");
10493 assert!(sentinel.exists(), "the junction was followed, not unlinked");
10494 assert_eq!(entries_of(&elsewhere), only_the_job_workspace());
10495 }
10496 }
10497
10498 #[cfg(unix)]
10499 #[tokio::test]
10500 async fn a_substituted_work_directory_leaves_the_attempt_uncleaned_and_still_leased() {
10501 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10502 .with_persistent_workspace(2);
10503 harness.ready().await;
10504 let first = harness.launch().await;
10505 let slot = harness.slot_path(1);
10506 harness.conclude(first.id);
10507
10508 let outside = harness._root.path().join("operator-data");
10509 fs::create_dir_all(&outside).unwrap();
10510 let sentinel = outside.join("do-not-delete.txt");
10511 fs::write(&sentinel, b"outside every approved root").unwrap();
10512 std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10513
10514 harness
10515 .launcher
10516 .clean(first.id)
10517 .await
10518 .expect_err("a slot whose `_work` was substituted is quarantined");
10519 assert!(sentinel.exists());
10520
10521 let held = harness.attempt(first.id);
10522 assert_eq!(held.state(), AttemptState::Failed);
10523 assert!(held.holds_slot_lease());
10524
10525 let second = harness.launch().await;
10527 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
10528 }
10529
10530 #[test]
10531 fn a_slot_that_is_a_file_is_refused_and_a_slot_that_is_gone_is_not() {
10532 let root = tempfile::tempdir().unwrap();
10533 let occupied = root.path().join("s1");
10534 fs::write(&occupied, b"an operator's file").unwrap();
10535 verify_journalled_slot(&occupied, nz(1), None).expect("the path is the journalled slot");
10538 assert_eq!(
10539 slot_is_present(&occupied).unwrap_err().refusal,
10540 SlotRefusal::SlotNotADirectory
10541 );
10542 assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
10543
10544 assert!(!slot_is_present(&root.path().join("s2")).unwrap());
10547 let present = root.path().join("s3");
10548 fs::create_dir(&present).unwrap();
10549 assert!(slot_is_present(&present).unwrap());
10550 }
10551
10552 #[test]
10553 fn cleanup_dispatches_on_the_journalled_kind_and_not_on_what_the_directory_holds() {
10554 let root = tempfile::tempdir().unwrap();
10555
10556 let disposable = root.path().join("abcdef012345");
10560 fs::create_dir_all(disposable.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
10561 let ephemeral = RunnerAttempt::allocate(
10562 AttemptId::new_random(),
10563 fixtures::POLICY_ID,
10564 &disposable,
10565 fixtures::created_at(),
10566 );
10567 remove_materialized_package(&ephemeral).unwrap();
10568 assert!(!disposable.exists());
10569
10570 let slot = root.path().join("s1");
10572 fs::create_dir_all(slot.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
10573 fs::create_dir_all(slot.join("bin")).unwrap();
10574 let persistent = RunnerAttempt::allocate_in(
10575 AttemptId::new_random(),
10576 fixtures::POLICY_ID,
10577 &slot,
10578 AttemptWorkspace::persistent_slot(nz(1)),
10579 fixtures::created_at(),
10580 );
10581 remove_materialized_package(&persistent).unwrap();
10582 assert_eq!(entries_of(&slot), only_the_job_workspace());
10583 assert!(slot.join(DEFAULT_WORK_FOLDER).join("repo").is_dir());
10584 }
10585
10586 #[test]
10587 fn every_slot_refusal_names_a_distinct_event_class_and_keeps_the_lease() {
10588 let refusals = [
10589 SlotRefusal::NotTheJournalledSlot,
10590 SlotRefusal::PolicyRootDisagrees,
10591 SlotRefusal::Containment,
10592 SlotRefusal::SlotNotADirectory,
10593 SlotRefusal::Enumeration,
10594 SlotRefusal::WorkNotADirectory,
10595 SlotRefusal::Deletion,
10596 SlotRefusal::Residue,
10597 ];
10598 let classes: BTreeSet<&str> = refusals.iter().map(|refusal| refusal.class()).collect();
10599 assert_eq!(
10600 classes.len(),
10601 refusals.len(),
10602 "an event class shared by two refusals tells an operator less than it appears to"
10603 );
10604 for refusal in refusals {
10605 assert!(
10608 refusal
10609 .class()
10610 .chars()
10611 .all(|c| c.is_ascii_lowercase() || c == '_'),
10612 "{}",
10613 refusal.class()
10614 );
10615 assert!(
10616 refusal.remediation().contains("slot lease"),
10617 "every refusal has to say the lease is still held: {}",
10618 refusal.class()
10619 );
10620 }
10621 }
10622
10623 #[test]
10624 fn copy_package_tree_copies_files_and_preserves_paths_with_spaces() {
10625 let root = tempfile::tempdir().unwrap();
10626 let source = root.path().join("source with spaces");
10627 let dest = root.path().join("dest with spaces");
10628
10629 fs::create_dir_all(&source).unwrap();
10630 fs::write(source.join("file1.txt"), b"hello").unwrap();
10631
10632 let nested = source.join("nested dir");
10633 fs::create_dir_all(&nested).unwrap();
10634 fs::write(nested.join("file2.txt"), b"world").unwrap();
10635
10636 let nested_work = nested.join(DEFAULT_WORK_FOLDER);
10638 fs::create_dir_all(&nested_work).unwrap();
10639 fs::write(nested_work.join("allowed.txt"), b"allowed").unwrap();
10640
10641 copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap();
10642
10643 assert_eq!(fs::read_to_string(dest.join("file1.txt")).unwrap(), "hello");
10644 assert_eq!(
10645 fs::read_to_string(dest.join("nested dir").join("file2.txt")).unwrap(),
10646 "world"
10647 );
10648 assert_eq!(
10649 fs::read_to_string(
10650 dest.join("nested dir")
10651 .join(DEFAULT_WORK_FOLDER)
10652 .join("allowed.txt")
10653 )
10654 .unwrap(),
10655 "allowed"
10656 );
10657 }
10658
10659 #[test]
10660 fn every_field_name_the_attempt_sink_emits_is_allowed() {
10661 use runner_manager_platform::logging::is_field_allowed;
10662
10663 for field in [
10665 "event",
10666 "attempt_id",
10667 "reason",
10668 "retry_in_ms",
10669 "duration_ms",
10670 "lock_wait_ms",
10671 "materialize_ms",
10672 "register_ms",
10673 "spawn_ms",
10674 ] {
10675 assert!(
10676 is_field_allowed(field),
10677 "`{field}` would be logged as `[redacted]`"
10678 );
10679 }
10680 }
10681
10682 #[cfg(unix)]
10683 fn package_entry(root: &Path, version: &str, manifest: &str) -> PathBuf {
10684 let entry = root.join("packages").join(version);
10685 fs::create_dir_all(entry.join("bin")).unwrap();
10686 fs::write(entry.join(crate::package::MANIFEST_FILE), manifest).unwrap();
10687 fs::write(entry.join("bin").join("Runner.Listener"), version).unwrap();
10688 entry
10689 }
10690
10691 #[cfg(unix)]
10693 fn listener_inode(seed: &Path) -> u64 {
10694 use std::os::unix::fs::MetadataExt as _;
10695 fs::metadata(seed.join("bin").join("Runner.Listener"))
10696 .unwrap()
10697 .ino()
10698 }
10699
10700 #[cfg(unix)]
10701 #[test]
10702 fn a_package_seed_is_built_once_and_reused_while_untouched() {
10703 let root = tempfile::tempdir().unwrap();
10704 let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
10705 let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
10706 let trusted = TrustedTrees::default();
10707
10708 let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
10709 assert_eq!(seed, seeds.join("2.336.0"));
10710 assert_eq!(
10711 fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
10712 "2.336.0"
10713 );
10714
10715 let built = listener_inode(&seed);
10716 assert_eq!(
10717 refresh_package_seed(&source, &seeds, &trusted).unwrap(),
10718 seed
10719 );
10720 assert_eq!(listener_inode(&seed), built, "an untouched seed is reused");
10721 }
10722
10723 #[cfg(unix)]
10724 #[test]
10725 fn a_tampered_or_unrecorded_seed_is_rebuilt() {
10726 let root = tempfile::tempdir().unwrap();
10727 let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
10728 let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
10729 let trusted = TrustedTrees::default();
10730 let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
10731
10732 fs::write(seed.join("bin").join("Runner.Listener"), b"planted").unwrap();
10734 refresh_package_seed(&source, &seeds, &trusted).unwrap();
10735 assert_eq!(
10736 fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
10737 "2.336.0",
10738 "a seed whose print moved is rebuilt from the cache entry"
10739 );
10740
10741 let old_listener = fs::File::open(seed.join("bin").join("Runner.Listener")).unwrap();
10745 let built = listener_inode(&seed);
10746 refresh_package_seed(&source, &seeds, &TrustedTrees::default()).unwrap();
10747 assert_ne!(
10748 listener_inode(&seed),
10749 built,
10750 "an unrecorded seed is rebuilt"
10751 );
10752 drop(old_listener);
10753 }
10754
10755 #[cfg(unix)]
10756 #[test]
10757 fn a_package_seed_is_rebuilt_for_a_new_entry_and_drops_every_other() {
10758 let root = tempfile::tempdir().unwrap();
10759 let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
10760 let trusted = TrustedTrees::default();
10761 let old = package_entry(root.path(), "2.335.0", "{\"digest\":\"old\"}");
10762 refresh_package_seed(&old, &seeds, &trusted).unwrap();
10763 fs::create_dir_all(seeds.join(".staging-interrupted")).unwrap();
10764 fs::write(seeds.join("stray"), b"").unwrap();
10765
10766 let new = package_entry(root.path(), "2.336.0", "{\"digest\":\"new\"}");
10767 let seed = refresh_package_seed(&new, &seeds, &trusted).unwrap();
10768
10769 let mut left: Vec<_> = fs::read_dir(&seeds)
10770 .unwrap()
10771 .map(|entry| entry.unwrap().file_name().into_string().unwrap())
10772 .collect();
10773 left.sort();
10774 assert_eq!(left, ["2.336.0"]);
10775
10776 fs::write(
10778 new.join(crate::package::MANIFEST_FILE),
10779 "{\"digest\":\"again\"}",
10780 )
10781 .unwrap();
10782 refresh_package_seed(&new, &seeds, &trusted).unwrap();
10783 assert_eq!(
10784 fs::read_to_string(seed.join(crate::package::MANIFEST_FILE)).unwrap(),
10785 "{\"digest\":\"again\"}",
10786 "a stale seed is rebuilt from the changed entry"
10787 );
10788 }
10789
10790 #[cfg(unix)]
10791 #[test]
10792 fn a_package_on_the_runtime_volume_is_its_own_seed() {
10793 let root = tempfile::tempdir().unwrap();
10794 let source = package_entry(root.path(), "2.336.0", "{}");
10795 let runtime = root.path().join("runners").join("attempt");
10796 fs::create_dir_all(&runtime).unwrap();
10797
10798 let trusted = TrustedTrees::default();
10799 assert_eq!(package_seed(&source, &runtime, &trusted).unwrap(), source);
10800 copy_package_tree(&source, &runtime, &trusted).unwrap();
10801 assert!(runtime.join("bin").join("Runner.Listener").exists());
10802 assert!(!root.path().join("runners").join(PACKAGE_SEED_DIR).exists());
10803 }
10804
10805 #[test]
10806 fn copy_package_tree_refuses_top_level_work_folder() {
10807 let root = tempfile::tempdir().unwrap();
10808 let source = root.path().join("source");
10809 let dest = root.path().join("dest");
10810
10811 fs::create_dir_all(&source).unwrap();
10812 fs::write(source.join("file1.txt"), b"hello").unwrap();
10813
10814 let top_work = source.join(DEFAULT_WORK_FOLDER);
10816 fs::create_dir_all(&top_work).unwrap();
10817
10818 let err = copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap_err();
10819 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
10820 }
10821}