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 docker_cleanup: Option<crate::docker_cleanup::DockerCleanup>,
2101 #[cfg(test)]
2102 post_spawn_faults: Mutex<VecDeque<PostSpawnBoundary>>,
2103 #[cfg(test)]
2104 post_spawn_reaps: std::sync::atomic::AtomicUsize,
2105 #[cfg(test)]
2106 post_spawn_stop_failures: std::sync::atomic::AtomicUsize,
2107 #[cfg(test)]
2108 use_long_lived_test_listener: std::sync::atomic::AtomicBool,
2109}
2110
2111#[derive(Debug)]
2115pub struct PlatformExecutionProvider {
2116 native: NativeProcesses,
2117 oci: OciProcesses,
2118 windows_hyper_v: WindowsHyperVContainers,
2119}
2120
2121impl PlatformExecutionProvider {
2122 #[must_use]
2123 pub fn new(host_id: HostId) -> Self {
2124 Self {
2125 native: NativeProcesses::new(),
2126 oci: OciProcesses::new(host_id),
2127 windows_hyper_v: WindowsHyperVContainers::new(host_id),
2128 }
2129 }
2130
2131 #[must_use]
2134 pub fn with_runner_env_file(mut self, path: PathBuf) -> Self {
2135 self.native = self.native.with_runner_env_file(path);
2136 self
2137 }
2138
2139 #[must_use]
2142 pub fn with_dependency_caches(mut self, caches: DependencyCaches) -> Self {
2143 self.native = self.native.with_dependency_caches(caches);
2144 self
2145 }
2146
2147 #[must_use]
2148 pub fn with_docker_cleanup(mut self, cleanup: crate::docker_cleanup::DockerCleanup) -> Self {
2149 self.native.docker_cleanup = Some(cleanup);
2150 self
2151 }
2152
2153 fn isolated_provider(&self, backend: Backend) -> Option<&dyn ExecutionProvider> {
2154 match backend {
2155 Backend::Auto => {
2156 #[cfg(target_os = "windows")]
2157 {
2158 Some(&self.windows_hyper_v)
2159 }
2160 #[cfg(not(target_os = "windows"))]
2161 {
2162 Some(&self.oci)
2163 }
2164 }
2165 Backend::Oci => Some(&self.oci),
2166 Backend::WindowsHyperVContainer => Some(&self.windows_hyper_v),
2167 Backend::VirtualMachine => None,
2168 }
2169 }
2170
2171 fn provider_for_attempt(&self, attempt: &RunnerAttempt) -> Option<&dyn ExecutionProvider> {
2172 match attempt.execution() {
2173 AttemptExecution::Native { .. } => Some(&self.native),
2174 AttemptExecution::Isolated { provider_kind, .. } => {
2175 self.isolated_provider(*provider_kind)
2176 }
2177 }
2178 }
2179
2180 fn required_provider_for_attempt(
2181 &self,
2182 attempt: &RunnerAttempt,
2183 ) -> Result<&dyn ExecutionProvider, FailureReason> {
2184 self.provider_for_attempt(attempt)
2185 .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))
2186 }
2187}
2188
2189impl ExecutionProvider for PlatformExecutionProvider {
2190 fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
2191 match policy.execution_policy() {
2192 runner_manager_domain::execution::ExecutionPolicy::Native => self.native.probe(policy),
2193 runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2194 .isolated_provider(*backend)
2195 .map_or(ProviderCapability::Unsupported, |provider| {
2196 provider.probe(policy)
2197 }),
2198 }
2199 }
2200
2201 fn resolve(&self, policy: &ScalePolicy) -> Result<Option<ResolvedEnvironment>, FailureReason> {
2202 match policy.execution_policy() {
2203 runner_manager_domain::execution::ExecutionPolicy::Native => {
2204 self.native.resolve(policy)
2205 }
2206 runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2207 .isolated_provider(*backend)
2208 .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))?
2209 .resolve(policy),
2210 }
2211 }
2212
2213 fn prepare(
2214 &self,
2215 attempt: &RunnerAttempt,
2216 policy: &ScalePolicy,
2217 ) -> Result<PreparedEnvironment, FailureReason> {
2218 match policy.execution_policy() {
2219 runner_manager_domain::execution::ExecutionPolicy::Native => {
2220 self.native.prepare(attempt, policy)
2221 }
2222 runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2223 .isolated_provider(*backend)
2224 .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))?
2225 .prepare(attempt, policy),
2226 }
2227 }
2228
2229 fn start(
2230 &self,
2231 prepared: PreparedEnvironment,
2232 attempt: &RunnerAttempt,
2233 handoff: OneTimeJitHandoff<'_>,
2234 ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2235 self.provider_for_attempt(attempt)
2236 .ok_or_else(|| {
2237 ProcessStartFailure::before_spawn(FailureReason::Other(
2238 "execution provider unavailable".into(),
2239 ))
2240 })?
2241 .start(prepared, attempt, handoff)
2242 }
2243
2244 fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2245 self.required_provider_for_attempt(attempt)?
2246 .inspect(attempt)
2247 }
2248
2249 fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
2250 self.required_provider_for_attempt(attempt)?.stop(attempt)
2251 }
2252
2253 fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
2254 self.required_provider_for_attempt(attempt)?
2255 .destroy(attempt)
2256 }
2257
2258 fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2259 self.required_provider_for_attempt(attempt)?
2260 .recover(attempt)
2261 }
2262
2263 fn enumerate_owned(&self, host_id: HostId) -> Vec<EnvironmentIdentity> {
2264 let mut owned = self.oci.enumerate_owned(host_id);
2265 owned.extend(self.windows_hyper_v.enumerate_owned(host_id));
2266 owned
2267 }
2268
2269 fn diagnostics(&self, attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
2270 self.provider_for_attempt(attempt)
2271 .map_or_else(Vec::new, |provider| provider.diagnostics(attempt))
2272 }
2273
2274 fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2275 self.required_provider_for_attempt(attempt)?.owns(attempt)
2276 }
2277
2278 fn spawn(
2279 &self,
2280 attempt: &RunnerAttempt,
2281 config: &EncodedJitConfig,
2282 ) -> Result<u32, ProcessStartFailure> {
2283 if !attempt.execution().is_native() {
2284 return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2285 "native process launch refused for an isolated allocation".into(),
2286 )));
2287 }
2288 self.native.spawn(attempt, config)
2289 }
2290
2291 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2292 self.required_provider_for_attempt(attempt)?
2293 .is_alive(attempt)
2294 }
2295
2296 fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
2297 self.required_provider_for_attempt(attempt)?
2298 .recovered_pid(attempt)
2299 }
2300
2301 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
2302 self.provider_for_attempt(attempt)
2303 .is_some_and(|provider| provider.completed_successfully(attempt))
2304 }
2305
2306 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2307 self.required_provider_for_attempt(attempt)?
2308 .record_terminate_intent(attempt)
2309 }
2310
2311 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
2312 self.provider_for_attempt(attempt)
2313 .is_some_and(|provider| provider.has_terminate_intent(attempt))
2314 }
2315
2316 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2317 self.required_provider_for_attempt(attempt)?
2318 .terminate(attempt)
2319 }
2320}
2321
2322#[cfg(test)]
2323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2324enum PostSpawnBoundary {
2325 HandoffDelete,
2326 IdentitySerialize,
2327 IdentityWrite,
2328 ChildMapInsert,
2329}
2330
2331impl NativeProcesses {
2332 #[must_use]
2333 pub fn new() -> Self {
2334 Self::default()
2335 }
2336
2337 #[must_use]
2339 pub fn with_runner_env_file(mut self, path: PathBuf) -> Self {
2340 self.runner_env_file = Some(path);
2341 self
2342 }
2343
2344 #[must_use]
2346 pub fn with_dependency_caches(mut self, caches: DependencyCaches) -> Self {
2347 self.dependency_caches = Some(caches);
2348 self
2349 }
2350
2351 fn cache_env(
2353 &self,
2354 attempt: &RunnerAttempt,
2355 target: Option<&runner_manager_domain::model::ScaleTarget>,
2356 host_env: &RunnerEnv,
2357 ) -> Vec<(&'static str, std::ffi::OsString)> {
2358 match (&self.dependency_caches, target) {
2359 (Some(caches), Some(target)) => {
2360 caches.for_launch(target, attempt.runtime_path(), host_env)
2361 }
2362 _ => Vec::new(),
2363 }
2364 }
2365
2366 fn spawn_listener(
2369 &self,
2370 attempt: &RunnerAttempt,
2371 config: &EncodedJitConfig,
2372 cache_target: Option<&runner_manager_domain::model::ScaleTarget>,
2373 ) -> Result<u32, ProcessStartFailure> {
2374 let handoff = RestrictiveHandoff::create(
2375 attempt.runtime_path(),
2376 SecretString::from(config.expose().to_owned()),
2377 )
2378 .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
2379 #[cfg(windows)]
2380 let program = attempt
2381 .runtime_path()
2382 .join("bin")
2383 .join("Runner.Listener.exe");
2384 #[cfg(not(windows))]
2385 let program = attempt.runtime_path().join("bin").join("Runner.Listener");
2386 if !program.is_file() {
2389 return Err(ProcessStartFailure::before_spawn(
2390 FailureReason::ProcessStartFailed,
2391 ));
2392 }
2393 let host_env = self.host_env().map_err(ProcessStartFailure::before_spawn)?;
2394 let caches = self.cache_env(attempt, cache_target, &host_env);
2395 #[cfg(test)]
2396 let spec = if self
2397 .use_long_lived_test_listener
2398 .load(std::sync::atomic::Ordering::SeqCst)
2399 {
2400 SpawnSpec::new(program)
2401 .args([
2402 "--ignored",
2403 "--exact",
2404 "lifecycle::tests::long_lived_native_listener_helper",
2405 "--nocapture",
2406 ])
2407 .env(
2408 "RUNNER_MANAGER_TEST_LISTENER_READY",
2409 attempt.runtime_path().join(TEST_LISTENER_READY),
2410 )
2411 .working_dir(attempt.runtime_path())
2412 } else {
2413 runner_listener_spec(program, attempt.runtime_path(), caches, &host_env)
2414 };
2415 #[cfg(not(test))]
2416 let spec = runner_listener_spec(program, attempt.runtime_path(), caches, &host_env);
2417 let (spec, docker_proxy) = if let Some(cleanup) = &self.docker_cleanup {
2418 let repository = cache_target.map(ToString::to_string).unwrap_or_default();
2419 match cleanup.launch_environment(attempt.id, &repository, &host_env) {
2420 Ok(Some(endpoint)) => (
2421 spec.env("DOCKER_HOST", endpoint),
2422 Some(cleanup.root.join(attempt.id.to_string())),
2423 ),
2424 Ok(None) => (spec, None),
2425 Err(error) => {
2426 tracing::warn!(attempt_id = %attempt.id, event = "docker_accounting_unavailable", reason = ?error.kind(), "native Docker accounting could not start");
2427 if crate::docker_cleanup::Config::load(&cleanup.config).map_or(true, |config| {
2428 config.mode == crate::docker_cleanup::Mode::Apply
2429 }) {
2430 return Err(ProcessStartFailure::before_spawn(
2431 FailureReason::ProcessStartFailed,
2432 ));
2433 }
2434 (spec, None)
2435 }
2436 }
2437 } else {
2438 (spec, None)
2439 };
2440 let child = spec
2441 .spawn_runner_with_handoff(&handoff)
2442 .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
2443 #[cfg(test)]
2445 if self.faults_at(PostSpawnBoundary::HandoffDelete) {
2446 drop(handoff);
2447 return Err(self.abort_spawned_child(child, attempt, false));
2448 }
2449 if handoff.delete().is_err() {
2450 return Err(self.abort_spawned_child(child, attempt, false));
2451 }
2452 #[cfg(test)]
2453 if self.faults_at(PostSpawnBoundary::IdentitySerialize) {
2454 return Err(self.abort_spawned_child(child, attempt, false));
2455 }
2456 let identity = match serde_json::to_vec(child.identity()) {
2457 Ok(identity) => identity,
2458 Err(_) => {
2459 return Err(self.abort_spawned_child(child, attempt, false));
2460 }
2461 };
2462 if self.persist_identity(attempt, &identity).is_err() {
2463 return Err(self.abort_spawned_child(child, attempt, true));
2464 }
2465 if let Some(directory) = docker_proxy
2466 && crate::docker_cleanup::write_json(&directory.join("runner.json"), child.identity())
2467 .is_err()
2468 {
2469 return Err(self.abort_spawned_child(child, attempt, true));
2470 }
2471 let pid = child.pid();
2472 #[cfg(test)]
2473 if self.faults_at(PostSpawnBoundary::ChildMapInsert) {
2474 return Err(self.abort_spawned_child(child, attempt, true));
2475 }
2476 let mut children = self
2477 .children
2478 .lock()
2479 .unwrap_or_else(std::sync::PoisonError::into_inner);
2480 children.insert(attempt.id, child);
2481 Ok(pid)
2482 }
2483
2484 fn host_env(&self) -> Result<RunnerEnv, FailureReason> {
2492 let Some(path) = &self.runner_env_file else {
2493 return Ok(RunnerEnv::default());
2494 };
2495 RunnerEnv::load(path).map_err(|error| {
2496 tracing::warn!(
2497 reason = "runner_env_file_invalid",
2498 "runner.env could not be applied, so no runner starts until it is fixed: {error}"
2499 );
2500 FailureReason::Other(format!(
2501 "runner.env cannot be applied ({error}); fix it with `runner-manager host env`"
2502 ))
2503 })
2504 }
2505
2506 #[cfg(test)]
2507 fn fail_post_spawn_at(&self, boundary: PostSpawnBoundary) {
2508 self.post_spawn_faults.lock().unwrap().push_back(boundary);
2509 }
2510
2511 #[cfg(test)]
2512 fn faults_at(&self, boundary: PostSpawnBoundary) -> bool {
2513 let mut faults = self.post_spawn_faults.lock().unwrap();
2514 if faults.front() == Some(&boundary) {
2515 faults.pop_front();
2516 true
2517 } else {
2518 false
2519 }
2520 }
2521
2522 #[cfg(test)]
2523 fn fail_post_spawn_stops(&self, count: usize) {
2524 self.post_spawn_stop_failures
2525 .fetch_add(count, std::sync::atomic::Ordering::SeqCst);
2526 }
2527
2528 #[cfg(test)]
2529 fn fail_next_post_spawn_stop(&self) {
2530 self.fail_post_spawn_stops(1);
2531 }
2532
2533 #[cfg(test)]
2534 fn use_long_lived_test_listener(&self) {
2535 self.use_long_lived_test_listener
2536 .store(true, std::sync::atomic::Ordering::SeqCst);
2537 }
2538
2539 fn stop_spawned_child(&self, child: &mut ChildProcess) -> Result<(), FailureReason> {
2540 #[cfg(test)]
2541 if self
2542 .post_spawn_stop_failures
2543 .fetch_update(
2544 std::sync::atomic::Ordering::SeqCst,
2545 std::sync::atomic::Ordering::SeqCst,
2546 |left| if left > 0 { Some(left - 1) } else { None },
2547 )
2548 .is_ok()
2549 {
2550 return Err(FailureReason::Other("injected runner stop failure".into()));
2551 }
2552 child
2553 .stop(Duration::from_secs(1))
2554 .map(|_| ())
2555 .map_err(|_| FailureReason::Other("spawned runner process could not be stopped".into()))
2556 }
2557
2558 fn abort_spawned_child(
2559 &self,
2560 mut child: ChildProcess,
2561 attempt: &RunnerAttempt,
2562 remove_identity: bool,
2563 ) -> ProcessStartFailure {
2564 let mut reaped = self.stop_spawned_child(&mut child).is_ok();
2565 if reaped {
2566 if remove_identity {
2567 Self::remove_identity_files(attempt);
2568 }
2569 } else {
2570 let identity_durable =
2573 serde_json::to_vec(child.identity())
2574 .ok()
2575 .is_some_and(|identity| {
2576 self.persist_identity(attempt, &identity).is_ok()
2577 || self.persist_fallback_identity(attempt, &identity).is_ok()
2578 });
2579 if !identity_durable {
2580 for _ in 1..MAX_POST_SPAWN_STOP_ATTEMPTS {
2585 if self.stop_spawned_child(&mut child).is_ok() {
2586 reaped = true;
2587 break;
2588 }
2589 }
2590 if !reaped {
2591 let pid = child.pid();
2596 let marker = write_durable_file(
2597 &Self::unresolved_process_path(attempt),
2598 pid.to_string().as_bytes(),
2599 );
2600 self.children
2601 .lock()
2602 .unwrap_or_else(std::sync::PoisonError::into_inner)
2603 .insert(attempt.id, child);
2604 let reason = if marker.is_ok() {
2605 FailureReason::Other(
2606 "spawn cleanup exhausted its bounded stop attempts; the live process remains under durable unresolved supervision"
2607 .into(),
2608 )
2609 } else {
2610 FailureReason::Other(
2611 "spawn cleanup exhausted its bounded stop attempts and the unresolved-process marker could not be journalled"
2612 .into(),
2613 )
2614 };
2615 return ProcessStartFailure::after_spawn_live_with_reason(pid, reason);
2616 }
2617 } else {
2618 let pid = child.pid();
2619 self.children
2620 .lock()
2621 .unwrap_or_else(std::sync::PoisonError::into_inner)
2622 .insert(attempt.id, child);
2623 return ProcessStartFailure::after_spawn_live(pid);
2624 }
2625 }
2626 #[cfg(test)]
2627 if reaped {
2628 self.post_spawn_reaps
2629 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2630 }
2631 #[cfg(not(test))]
2632 let _ = reaped;
2633 ProcessStartFailure::after_spawn_stopped()
2634 }
2635
2636 fn identity_path(attempt: &RunnerAttempt) -> PathBuf {
2637 attempt.runtime_path().join(IDENTITY_FILE)
2638 }
2639
2640 fn fallback_identity_path(attempt: &RunnerAttempt) -> PathBuf {
2641 attempt.runtime_path().join(FALLBACK_IDENTITY_FILE)
2642 }
2643
2644 fn unresolved_process_path(attempt: &RunnerAttempt) -> PathBuf {
2645 attempt.runtime_path().join(UNRESOLVED_PROCESS_FILE)
2646 }
2647
2648 fn remove_identity_files(attempt: &RunnerAttempt) {
2649 let _ = fs::remove_file(Self::identity_path(attempt));
2650 let _ = fs::remove_file(Self::fallback_identity_path(attempt));
2651 let _ = fs::remove_file(Self::unresolved_process_path(attempt));
2652 }
2653
2654 fn persist_identity(&self, attempt: &RunnerAttempt, bytes: &[u8]) -> std::io::Result<()> {
2655 self.persist_identity_at(&Self::identity_path(attempt), bytes)
2656 }
2657
2658 fn persist_fallback_identity(
2659 &self,
2660 attempt: &RunnerAttempt,
2661 bytes: &[u8],
2662 ) -> std::io::Result<()> {
2663 self.persist_identity_at(&Self::fallback_identity_path(attempt), bytes)
2664 }
2665
2666 fn persist_identity_at(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
2667 #[cfg(test)]
2668 if self.faults_at(PostSpawnBoundary::IdentityWrite) {
2669 return Err(std::io::Error::other("injected identity write failure"));
2670 }
2671 write_durable_file(path, bytes)
2672 }
2673
2674 fn intent_path(attempt: &RunnerAttempt) -> PathBuf {
2675 attempt.runtime_path().join(TERMINATE_INTENT_FILE)
2676 }
2677
2678 fn read_identity(attempt: &RunnerAttempt) -> Result<Option<ProcessIdentity>, FailureReason> {
2679 match Self::read_identity_at(&Self::identity_path(attempt))? {
2680 Some(identity) => Ok(Some(identity)),
2681 None => Self::read_identity_at(&Self::fallback_identity_path(attempt)),
2682 }
2683 }
2684
2685 fn read_identity_at(path: &Path) -> Result<Option<ProcessIdentity>, FailureReason> {
2686 match fs::read(path) {
2687 Ok(bytes) => serde_json::from_slice(&bytes)
2688 .map(Some)
2689 .map_err(|_| FailureReason::Other("process identity journal is unreadable".into())),
2690 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
2691 Err(_) => Err(FailureReason::Other(
2692 "process identity journal could not be read".into(),
2693 )),
2694 }
2695 }
2696}
2697
2698impl ProcessSupervisor for NativeProcesses {
2699 fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
2700 if !attempt.execution().is_native() {
2701 return Err(FailureReason::Other(
2702 "execution provider unavailable".into(),
2703 ));
2704 }
2705 if let Some(caches) = &self.dependency_caches {
2708 caches.capture_git(attempt.runtime_path());
2709 }
2710 Ok(ProviderDestroy::Destroyed)
2711 }
2712
2713 fn prepare(
2714 &self,
2715 attempt: &RunnerAttempt,
2716 policy: &ScalePolicy,
2717 ) -> Result<PreparedEnvironment, FailureReason> {
2718 if !attempt.execution().is_native() || !policy.execution_policy().is_native() {
2719 return Err(FailureReason::Other(
2720 "execution provider unavailable".into(),
2721 ));
2722 }
2723 self.host_env()?;
2724 let cache_target = match &self.dependency_caches {
2727 Some(caches) => {
2728 caches.config()?;
2729 Some(policy.target.clone())
2730 }
2731 None => None,
2732 };
2733 Ok(PreparedEnvironment {
2734 attempt: attempt.id,
2735 identity: None,
2736 cache_target,
2737 })
2738 }
2739
2740 fn start(
2741 &self,
2742 prepared: PreparedEnvironment,
2743 attempt: &RunnerAttempt,
2744 handoff: OneTimeJitHandoff<'_>,
2745 ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2746 if prepared.attempt != attempt.id || !attempt.execution().is_native() {
2747 return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2748 "execution provider identity mismatch".into(),
2749 )));
2750 }
2751 self.spawn_listener(attempt, handoff.consume(), prepared.cache_target.as_ref())
2752 .map(EnvironmentIdentity::NativeProcess)
2753 }
2754
2755 fn spawn(
2756 &self,
2757 attempt: &RunnerAttempt,
2758 config: &EncodedJitConfig,
2759 ) -> Result<u32, ProcessStartFailure> {
2760 self.spawn_listener(attempt, config, None)
2761 }
2762
2763 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2764 let mut children = self
2765 .children
2766 .lock()
2767 .unwrap_or_else(std::sync::PoisonError::into_inner);
2768 if let Some(child) = children.get_mut(&attempt.id) {
2769 return match child
2770 .try_exit_status()
2771 .map_err(|_| FailureReason::Other("runner process could not be observed".into()))?
2772 {
2773 None => Ok(true),
2774 Some(status) => {
2775 if let Ok(mut exits) = self.successful_exits.lock() {
2776 exits.insert(attempt.id, status.success());
2777 }
2778 Ok(false)
2779 }
2780 };
2781 }
2782 let Some(identity) = Self::read_identity(attempt)? else {
2783 if attempt.process_id().is_some() || Self::unresolved_process_path(attempt).is_file() {
2784 return Err(FailureReason::Other(
2785 "runner process identity is missing; refusing recovery until the process is resolved"
2786 .into(),
2787 ));
2788 }
2789 return Ok(false);
2790 };
2791 match identity.recheck() {
2792 Ok(Adoption::Live) => Ok(true),
2793 Ok(Adoption::Gone | Adoption::PidRecycled { .. }) => Ok(false),
2794 Err(_) => Ok(false),
2795 }
2796 }
2797
2798 fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
2799 Ok(Self::read_identity(attempt)?.map(|identity| identity.pid()))
2800 }
2801
2802 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
2803 self.successful_exits
2804 .lock()
2805 .ok()
2806 .and_then(|exits| exits.get(&attempt.id).copied())
2807 .unwrap_or(false)
2808 }
2809
2810 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2811 let path = Self::intent_path(attempt);
2812 write_durable_file(&path, b"registration-timeout\n")
2813 .map_err(|_| FailureReason::Other("terminate intent could not be journalled".into()))
2814 }
2815
2816 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
2817 Self::intent_path(attempt).is_file()
2818 }
2819
2820 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2821 let mut children = self
2822 .children
2823 .lock()
2824 .unwrap_or_else(std::sync::PoisonError::into_inner);
2825 if let Some(child) = children.get_mut(&attempt.id) {
2826 child
2827 .stop(Duration::from_secs(10))
2828 .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?;
2829 return Ok(());
2830 }
2831 let Some(identity) = Self::read_identity(attempt)? else {
2832 return Ok(());
2833 };
2834 match identity
2835 .terminate(Duration::from_secs(10))
2836 .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?
2837 {
2838 Termination::Terminated | Termination::AlreadyGone => Ok(()),
2839 Termination::RefusedPidRecycled { .. } => Err(FailureReason::Other(
2840 "runner PID was recycled; refusing to signal it".into(),
2841 )),
2842 }
2843 }
2844}
2845
2846pub struct LifecyclePorts {
2847 pub store: Arc<dyn Store>,
2848 pub github: Arc<dyn LifecycleGithub>,
2849 pub packages: Arc<dyn RuntimePackages>,
2850 pub processes: Arc<dyn ExecutionProvider>,
2851 pub clock: Arc<dyn Clock>,
2852 pub demand: Arc<dyn DemandPersistence>,
2853 pub delay: Arc<dyn RetryDelay>,
2854 pub events: Arc<dyn AttemptEventSink>,
2855 pub reconcile_events: Arc<dyn EventSink>,
2856}
2857
2858impl fmt::Debug for LifecyclePorts {
2859 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2860 f.debug_struct("LifecyclePorts")
2861 .field("store", &self.store)
2862 .field("github", &self.github)
2863 .field("packages", &self.packages)
2864 .field("processes", &self.processes)
2865 .finish_non_exhaustive()
2866 }
2867}
2868
2869#[derive(Debug, thiserror::Error)]
2870pub enum LifecycleError {
2871 #[error("attempt journal operation failed")]
2872 Journal,
2873 #[error("attempt {0} is not in the journal")]
2874 Missing(AttemptId),
2875 #[error("attempt lifecycle transition was refused")]
2876 Transition,
2877 #[error("startup recovery has not completed")]
2878 RecoveryIncomplete,
2879 #[error("the persistent slot was not cleaned: {detail}")]
2889 SlotQuarantined {
2890 class: &'static str,
2892 detail: String,
2893 },
2894 #[error("{}", FailureReason::WorkspaceCleanupDeferred)]
2898 WorkspaceCleanupDeferred,
2899 #[error("isolated environment cleanup is deferred")]
2900 EnvironmentCleanupDeferred,
2901 #[error("runner lifecycle failed: {0}")]
2902 Failed(FailureReason),
2903}
2904
2905impl LifecycleError {
2906 fn reason(&self) -> FailureReason {
2907 match self {
2908 Self::Failed(reason) => reason.clone(),
2909 Self::RecoveryIncomplete => FailureReason::Other("startup recovery incomplete".into()),
2910 Self::Journal => FailureReason::Other("attempt journal operation failed".into()),
2911 Self::Missing(_) => FailureReason::Other("attempt disappeared from the journal".into()),
2912 Self::Transition => FailureReason::Other("attempt transition was refused".into()),
2913 Self::SlotQuarantined { .. } => FailureReason::Other(self.to_string()),
2916 Self::WorkspaceCleanupDeferred => FailureReason::WorkspaceCleanupDeferred,
2917 Self::EnvironmentCleanupDeferred => FailureReason::Other(self.to_string()),
2918 }
2919 }
2920}
2921
2922#[derive(Debug)]
2924pub struct LifecycleLauncher {
2925 host_id: HostId,
2926 app_paths: runner_manager_platform::paths::AppPaths,
2927 diagnostics_root: PathBuf,
2928 runner_group_id: u64,
2929 timeouts: RecoveryTimeouts,
2930 retry: RetryPolicy,
2931 cancel: CancelToken,
2932 ports: LifecyclePorts,
2933 recovery_complete: Mutex<bool>,
2934 versions: Mutex<BTreeMap<AttemptId, RunnerVersion>>,
2935 pending_replacements: Mutex<BTreeMap<AttemptId, ReplacementIntent>>,
2936 cleanup_retries: Mutex<CleanBackoff>,
2940}
2941
2942#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2943enum ReconcileProgress {
2944 Reconciled,
2945 Deferred,
2946 Replacement {
2947 attempt: AttemptId,
2948 operation: &'static str,
2949 },
2950}
2951
2952impl LifecycleLauncher {
2953 #[must_use]
2954 pub fn new(
2955 host_id: HostId,
2956 app_paths: runner_manager_platform::paths::AppPaths,
2957 diagnostics_root: impl Into<PathBuf>,
2958 runner_group_id: u64,
2959 timeouts: RecoveryTimeouts,
2960 retry: RetryPolicy,
2961 ports: LifecyclePorts,
2962 ) -> Self {
2963 Self {
2964 host_id,
2965 app_paths,
2966 diagnostics_root: diagnostics_root.into(),
2967 runner_group_id,
2968 timeouts,
2969 retry,
2970 cancel: CancelToken::new(),
2971 ports,
2972 recovery_complete: Mutex::new(false),
2973 versions: Mutex::new(BTreeMap::new()),
2974 pending_replacements: Mutex::new(BTreeMap::new()),
2975 cleanup_retries: Mutex::new(CleanBackoff::default()),
2976 }
2977 }
2978
2979 pub async fn recover_startup(
2983 &self,
2984 policies: &[ScalePolicy],
2985 ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
2986 *self
2987 .recovery_complete
2988 .lock()
2989 .map_err(|_| LifecycleError::Journal)? = false;
2990 let by_id: BTreeMap<_, _> = policies.iter().map(|policy| (policy.id, policy)).collect();
2991 let attempts = self
2992 .ports
2993 .store
2994 .attempts()
2995 .map_err(|_| LifecycleError::Journal)?;
2996 let owned = self.ports.processes.enumerate_owned(self.host_id);
2997 for identity in &owned {
2998 if let EnvironmentIdentity::Isolated { attempt: id, .. } = identity
2999 && !attempts.iter().any(|attempt| {
3000 attempt.state() != AttemptState::Cleaned
3001 && identity_matches_intent(self.host_id, attempt, identity)
3002 })
3003 {
3004 self.ports
3006 .events
3007 .emit(AttemptEvent::QuarantinedOwnedResource { attempt: *id });
3008 tracing::warn!(host_id = %self.host_id, attempt_id = %id, reason = "owned_isolated_resource_without_matching_journal", "provider resource quarantined for operator review");
3009 }
3010 }
3011 let mut unresolved = false;
3012 for mut attempt in attempts {
3013 let Some(policy) = by_id.get(&attempt.policy_id) else {
3014 if attempt.counts_against_capacity() {
3015 unresolved = true;
3016 }
3017 continue;
3018 };
3019 authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
3020 let allocation_resources = owned
3021 .iter()
3022 .filter(|identity| identity_same_allocation(self.host_id, &attempt, identity))
3023 .collect::<Vec<_>>();
3024 if allocation_resources.len() > 1 {
3025 self.ports
3026 .events
3027 .emit(AttemptEvent::QuarantinedOwnedResource {
3028 attempt: attempt.id,
3029 });
3030 tracing::warn!(attempt_id = %attempt.id, reason = "duplicate_isolated_resource_ownership", "provider resources quarantined for operator review");
3031 }
3032 if attempt.state() != AttemptState::Cleaned
3033 && matches!(
3034 attempt.execution(),
3035 AttemptExecution::Isolated {
3036 environment_id: None,
3037 ..
3038 }
3039 )
3040 && allocation_resources.len() == 1
3041 && let Some(EnvironmentIdentity::Isolated { environment_id, .. }) =
3042 allocation_resources.first().copied()
3043 {
3044 attempt
3045 .prepared_environment(environment_id.clone())
3046 .map_err(|_| LifecycleError::Transition)?;
3047 if attempt.state() == AttemptState::Preparing {
3048 attempt
3049 .mark_prepared(self.ports.clock.now())
3050 .map_err(|_| LifecycleError::Transition)?;
3051 }
3052 self.record(&attempt)?;
3053 }
3054 match self.reconcile_one(policy, attempt).await? {
3055 ReconcileProgress::Deferred => {
3056 if policy.execution_policy().is_native() {
3057 unresolved = true;
3058 }
3059 }
3060 ReconcileProgress::Replacement { attempt, operation } => {
3061 self.pending_replacements
3062 .lock()
3063 .map_err(|_| LifecycleError::Journal)?
3064 .insert(
3065 attempt,
3066 ReplacementIntent {
3067 policy: policy.id,
3068 previous_attempt: attempt,
3069 operation,
3070 },
3071 );
3072 }
3073 ReconcileProgress::Reconciled => {}
3074 }
3075 }
3076 if unresolved {
3077 return Err(LifecycleError::RecoveryIncomplete);
3078 }
3079 *self
3080 .recovery_complete
3081 .lock()
3082 .map_err(|_| LifecycleError::Journal)? = true;
3083 Ok(self
3084 .pending_replacements
3085 .lock()
3086 .map_err(|_| LifecycleError::Journal)?
3087 .values()
3088 .copied()
3089 .collect())
3090 }
3091
3092 pub async fn supervise(
3094 &self,
3095 policy: &ScalePolicy,
3096 ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
3097 let mut replacements = Vec::new();
3098 self.pending_replacements
3099 .lock()
3100 .map_err(|_| LifecycleError::Journal)?
3101 .retain(|_, intent| {
3102 if intent.policy == policy.id {
3103 replacements.push(*intent);
3104 false
3105 } else {
3106 true
3107 }
3108 });
3109 let attempts = self
3110 .ports
3111 .store
3112 .attempts_for_policy(policy.id)
3113 .map_err(|_| LifecycleError::Journal)?;
3114 for attempt in attempts {
3115 authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
3116 if let ReconcileProgress::Replacement { attempt, operation } =
3117 self.reconcile_one(policy, attempt).await?
3118 {
3119 replacements.push(ReplacementIntent {
3120 policy: policy.id,
3121 previous_attempt: attempt,
3122 operation,
3123 });
3124 }
3125 }
3126 Ok(replacements)
3127 }
3128
3129 async fn reconcile_one(
3130 &self,
3131 policy: &ScalePolicy,
3132 mut attempt: RunnerAttempt,
3133 ) -> Result<ReconcileProgress, LifecycleError> {
3134 if !attempt.execution().is_native() {
3135 return self.reconcile_isolated_one(policy, attempt).await;
3136 }
3137 if attempt.state() == AttemptState::Cleaned {
3138 if matches!(attempt.workspace(), AttemptWorkspace::Ephemeral)
3147 && attempt.runtime_path().exists()
3148 {
3149 let now = self.ports.clock.now();
3157 if !self.cleanup_retries().is_due(attempt.id, now) {
3158 return Ok(ReconcileProgress::Reconciled);
3159 }
3160 match self.scrub_workspace(&attempt) {
3161 Ok(()) => self.cleanup_retries().succeeded(attempt.id),
3162 Err(LifecycleError::WorkspaceCleanupDeferred) => {
3163 self.cleanup_retries().failed(attempt.id, now);
3164 self.ports
3165 .reconcile_events
3166 .emit(LifecycleEvent::AttemptCleanFailed {
3167 policy: attempt.policy_id,
3168 attempt: attempt.id,
3169 reason: "late_ephemeral_workspace_could_not_be_removed",
3170 });
3171 }
3172 Err(error) => return Err(error),
3173 }
3174 }
3175 return Ok(ReconcileProgress::Reconciled);
3176 }
3177 if attempt.is_terminal() {
3178 self.clean_or_quarantine(&mut attempt)?;
3179 return Ok(ReconcileProgress::Reconciled);
3180 }
3181 let process_alive = self
3182 .ports
3183 .processes
3184 .inspect(&attempt)
3185 .map_err(LifecycleError::Failed)?
3186 == EnvironmentState::Running;
3187 let github = self
3188 .ports
3189 .github
3190 .observe(&policy.target, attempt.id, &self.cancel)
3191 .await;
3192
3193 if let Some(runner_id) = github.runner_id
3198 && read_runner_id(attempt.runtime_path()).is_none()
3199 {
3200 write_runner_id(attempt.runtime_path(), runner_id)?;
3201 self.ports
3202 .events
3203 .emit(AttemptEvent::RemoteIdentityRecovered {
3204 attempt: attempt.id,
3205 runner_id,
3206 });
3207 }
3208
3209 if attempt.state() == AttemptState::JitReceived
3214 && process_alive
3215 && let Some(pid) = self
3216 .ports
3217 .processes
3218 .recovered_pid(&attempt)
3219 .map_err(LifecycleError::Failed)?
3220 {
3221 attempt
3222 .started(pid, self.ports.clock.now())
3223 .map_err(|_| LifecycleError::Transition)?;
3224 self.record(&attempt)?;
3225 }
3226
3227 if !process_alive
3240 && github.status != GithubRunnerObservation::Unreachable
3241 && matches!(
3242 attempt.state(),
3243 AttemptState::Starting | AttemptState::Idle | AttemptState::Busy
3244 )
3245 && self.ports.processes.completed_successfully(&attempt)
3246 {
3247 let state = attempt.state();
3248 let outcome = match (state, JobEvidence::of(attempt.runtime_path())) {
3249 (AttemptState::Starting | AttemptState::Idle, JobEvidence::RanAJob) => {
3252 known_runner_id(&attempt, github.runner_id)
3253 .map(|runner_id| (Some(runner_id), AttemptOutcome::CompletedJob))
3254 }
3255 (AttemptState::Busy, JobEvidence::RanAJob) => {
3256 Some((None, AttemptOutcome::CompletedJob))
3257 }
3258 (AttemptState::Busy, JobEvidence::NoJob) => {
3259 Some((None, AttemptOutcome::ExitedIdleWithoutWork))
3260 }
3261 (AttemptState::Busy, JobEvidence::Unknown)
3262 if github.status == GithubRunnerObservation::NotRegistered =>
3263 {
3264 Some((None, AttemptOutcome::CompletedJob))
3265 }
3266 _ => None,
3267 };
3268 if let Some((walk_to_busy, outcome)) = outcome {
3269 if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3276 self.deregister_runner(policy, &attempt).await;
3277 }
3278 let now = self.ports.clock.now();
3279 match (walk_to_busy, &outcome) {
3280 (Some(runner_id), _) => {
3281 self.transition(&mut attempt, |a| a.assigned_job(runner_id, now))?;
3282 }
3283 (None, AttemptOutcome::ExitedIdleWithoutWork) => {
3284 self.transition(&mut attempt, |a| a.assignment_not_taken(now))?;
3285 }
3286 (None, _) => {}
3287 }
3288 self.conclude(&mut attempt, outcome)?;
3289 self.clean_or_quarantine(&mut attempt)?;
3290 return Ok(ReconcileProgress::Reconciled);
3291 }
3292 }
3293
3294 if process_alive
3299 && attempt.state() == AttemptState::Busy
3300 && github.status == (GithubRunnerObservation::Registered { busy: false })
3301 && JobEvidence::of(attempt.runtime_path()) == JobEvidence::NoJob
3302 {
3303 let now = self.ports.clock.now();
3304 self.transition(&mut attempt, |a| a.assignment_not_taken(now))?;
3305 return Ok(ReconcileProgress::Reconciled);
3306 }
3307
3308 if self.ports.processes.has_terminate_intent(&attempt) && !process_alive {
3311 self.deregister_runner(policy, &attempt).await;
3312 self.conclude(
3313 &mut attempt,
3314 AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout),
3315 )?;
3316 self.clean_or_quarantine(&mut attempt)?;
3317 return Ok(ReconcileProgress::Replacement {
3318 attempt: attempt.id,
3319 operation: "registration_timeout_replacement",
3320 });
3321 }
3322
3323 if matches!(
3329 attempt.state(),
3330 AttemptState::Allocated | AttemptState::JitReceived
3331 ) && !process_alive
3332 && matches!(github.status, GithubRunnerObservation::Registered { .. })
3333 {
3334 if attempt.state() == AttemptState::Allocated {
3335 attempt
3336 .jit_received(self.ports.clock.now())
3337 .map_err(|_| LifecycleError::Transition)?;
3338 self.record(&attempt)?;
3339 }
3340 self.deregister_runner(policy, &attempt).await;
3341 self.conclude(
3342 &mut attempt,
3343 AttemptOutcome::failed(FailureReason::JitExpired),
3344 )?;
3345 self.clean_or_quarantine(&mut attempt)?;
3346 return Ok(ReconcileProgress::Replacement {
3347 attempt: attempt.id,
3348 operation: "jit_expired_replacement",
3349 });
3350 }
3351
3352 match recovery_decision(
3353 &attempt,
3354 RecoveryObservation {
3355 process_alive,
3356 github: github.status,
3357 },
3358 self.timeouts,
3359 self.ports.clock.as_ref(),
3360 ) {
3361 RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
3362 RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
3363 RecoveryDecision::Adopt => {
3364 self.ports.events.emit(AttemptEvent::Adopted {
3365 attempt: attempt.id,
3366 });
3367 Ok(ReconcileProgress::Reconciled)
3368 }
3369 RecoveryDecision::Clean => {
3370 self.clean_or_quarantine(&mut attempt)?;
3371 Ok(ReconcileProgress::Reconciled)
3372 }
3373 RecoveryDecision::Observe(state) => {
3374 let runner_id = known_runner_id(&attempt, github.runner_id)
3375 .ok_or(LifecycleError::Transition)?;
3376 match state {
3377 AttemptState::JitReceived => attempt
3378 .jit_received(self.ports.clock.now())
3379 .map_err(|_| LifecycleError::Transition)?,
3380 AttemptState::Starting => {
3381 let pid = attempt.process_id().ok_or(LifecycleError::Transition)?;
3382 attempt
3383 .started(pid, self.ports.clock.now())
3384 .map_err(|_| LifecycleError::Transition)?;
3385 }
3386 AttemptState::Idle => attempt
3387 .registered_idle(runner_id, self.ports.clock.now())
3388 .map_err(|_| LifecycleError::Transition)?,
3389 AttemptState::Busy => attempt
3390 .assigned_job(runner_id, self.ports.clock.now())
3391 .map_err(|_| LifecycleError::Transition)?,
3392 _ => return Err(LifecycleError::Transition),
3393 }
3394 self.record(&attempt)?;
3395 Ok(ReconcileProgress::Reconciled)
3396 }
3397 RecoveryDecision::Conclude(outcome) => {
3398 let replacement = replacement_operation(&outcome);
3399 if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3404 self.deregister_runner(policy, &attempt).await;
3405 }
3406 self.conclude(&mut attempt, outcome)?;
3407 self.clean_or_quarantine(&mut attempt)?;
3408 Ok(
3409 replacement.map_or(ReconcileProgress::Reconciled, |operation| {
3410 ReconcileProgress::Replacement {
3411 attempt: attempt.id,
3412 operation,
3413 }
3414 }),
3415 )
3416 }
3417 RecoveryDecision::Terminate(payload) => {
3418 let idle_exit = payload.is_idle_exit();
3428 self.ports
3429 .processes
3430 .record_terminate_intent(&attempt)
3431 .map_err(LifecycleError::Failed)?;
3432 self.ports.events.emit(AttemptEvent::TerminateIntent {
3433 attempt: attempt.id,
3434 });
3435 if self
3436 .ports
3437 .processes
3438 .stop(&attempt)
3439 .map_err(LifecycleError::Failed)?
3440 == ProviderStop::StillRunning
3441 {
3442 return Ok(ReconcileProgress::Deferred);
3443 }
3444 if self
3445 .ports
3446 .processes
3447 .inspect(&attempt)
3448 .map_err(LifecycleError::Failed)?
3449 == EnvironmentState::Running
3450 {
3451 return Ok(ReconcileProgress::Deferred);
3452 }
3453 self.ports.events.emit(AttemptEvent::Terminated {
3454 attempt: attempt.id,
3455 });
3456 let outcome = if idle_exit {
3462 AttemptOutcome::ExitedIdleWithoutWork
3463 } else {
3464 AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout)
3465 };
3466 self.deregister_runner(policy, &attempt).await;
3467 self.conclude(&mut attempt, outcome)?;
3468 self.clean_or_quarantine(&mut attempt)?;
3469 if idle_exit {
3473 Ok(ReconcileProgress::Reconciled)
3474 } else {
3475 Ok(ReconcileProgress::Replacement {
3476 attempt: attempt.id,
3477 operation: "registration_timeout_replacement",
3478 })
3479 }
3480 }
3481 }
3482 }
3483
3484 async fn reconcile_isolated_one(
3485 &self,
3486 policy: &ScalePolicy,
3487 mut attempt: RunnerAttempt,
3488 ) -> Result<ReconcileProgress, LifecycleError> {
3489 if attempt.state() == AttemptState::Cleaned {
3490 return Ok(ReconcileProgress::Reconciled);
3491 }
3492 if attempt.is_terminal() {
3493 self.clean_or_quarantine(&mut attempt)?;
3494 return Ok(ReconcileProgress::Reconciled);
3495 }
3496 if matches!(
3497 attempt.state(),
3498 AttemptState::Allocated | AttemptState::Preparing | AttemptState::Prepared
3499 ) {
3500 self.conclude(
3503 &mut attempt,
3504 AttemptOutcome::failed(FailureReason::Other(
3505 "isolated preparation interrupted".into(),
3506 )),
3507 )?;
3508 self.clean_or_quarantine(&mut attempt)?;
3509 return Ok(ReconcileProgress::Reconciled);
3510 }
3511 let Ok(provider_state) = self.ports.processes.recover(&attempt) else {
3512 return Ok(ReconcileProgress::Deferred);
3513 };
3514 if provider_state != EnvironmentState::Missing {
3515 let Ok(owned) = self.ports.processes.owns(&attempt) else {
3516 return Ok(ReconcileProgress::Deferred);
3517 };
3518 if !owned {
3519 self.ports
3520 .events
3521 .emit(AttemptEvent::QuarantinedOwnedResource {
3522 attempt: attempt.id,
3523 });
3524 tracing::warn!(attempt_id = %attempt.id, reason = "isolated_resource_ownership_mismatch", "provider resource quarantined for operator review");
3525 return Ok(ReconcileProgress::Deferred);
3526 }
3527 }
3528 let live = matches!(
3529 provider_state,
3530 EnvironmentState::Starting | EnvironmentState::Running
3531 );
3532 if attempt.state() == AttemptState::JitReceived && live {
3533 attempt
3534 .started_isolated(self.ports.clock.now())
3535 .map_err(|_| LifecycleError::Transition)?;
3536 self.record(&attempt)?;
3537 }
3538 let github = self
3539 .ports
3540 .github
3541 .observe(&policy.target, attempt.id, &self.cancel)
3542 .await;
3543 if let Some(runner_id) = github.runner_id
3544 && read_runner_id(attempt.runtime_path()).is_none()
3545 {
3546 write_runner_id(attempt.runtime_path(), runner_id)?;
3547 }
3548 if attempt.state() == AttemptState::Busy
3552 && !live
3553 && github.status == GithubRunnerObservation::NotRegistered
3554 && self.ports.processes.completed_successfully(&attempt)
3555 {
3556 self.conclude(&mut attempt, AttemptOutcome::CompletedJob)?;
3557 self.clean_or_quarantine(&mut attempt)?;
3558 return Ok(ReconcileProgress::Reconciled);
3559 }
3560 match recovery_decision(
3561 &attempt,
3562 RecoveryObservation {
3563 process_alive: live,
3564 github: github.status,
3565 },
3566 self.timeouts,
3567 self.ports.clock.as_ref(),
3568 ) {
3569 RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
3570 RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
3571 RecoveryDecision::Adopt => {
3572 self.ports.events.emit(AttemptEvent::Adopted {
3573 attempt: attempt.id,
3574 });
3575 Ok(ReconcileProgress::Reconciled)
3576 }
3577 RecoveryDecision::Clean => {
3578 self.clean_or_quarantine(&mut attempt)?;
3579 Ok(ReconcileProgress::Reconciled)
3580 }
3581 RecoveryDecision::Observe(state) => {
3582 let runner_id = github
3583 .runner_id
3584 .or_else(|| read_runner_id(attempt.runtime_path()))
3585 .ok_or(LifecycleError::Transition)?;
3586 match state {
3587 AttemptState::Idle => attempt
3588 .registered_idle(runner_id, self.ports.clock.now())
3589 .map_err(|_| LifecycleError::Transition)?,
3590 AttemptState::Busy => attempt
3591 .assigned_job(runner_id, self.ports.clock.now())
3592 .map_err(|_| LifecycleError::Transition)?,
3593 _ => return Err(LifecycleError::Transition),
3594 }
3595 self.record(&attempt)?;
3596 Ok(ReconcileProgress::Reconciled)
3597 }
3598 RecoveryDecision::Conclude(outcome) | RecoveryDecision::Terminate(outcome) => {
3599 let replacement = replacement_operation(&outcome);
3600 if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3601 self.deregister_runner(policy, &attempt).await;
3602 }
3603 self.conclude(&mut attempt, outcome)?;
3604 self.clean_or_quarantine(&mut attempt)?;
3605 Ok(
3606 replacement.map_or(ReconcileProgress::Reconciled, |operation| {
3607 ReconcileProgress::Replacement {
3608 attempt: attempt.id,
3609 operation,
3610 }
3611 }),
3612 )
3613 }
3614 }
3615 }
3616
3617 fn transition(
3619 &self,
3620 attempt: &mut RunnerAttempt,
3621 edge: impl FnOnce(
3622 &mut RunnerAttempt,
3623 ) -> Result<(), runner_manager_domain::attempt::AttemptError>,
3624 ) -> Result<(), LifecycleError> {
3625 edge(attempt).map_err(|_| LifecycleError::Transition)?;
3626 self.record(attempt)
3627 }
3628
3629 fn record(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
3630 self.ports
3631 .store
3632 .record_attempt(attempt)
3633 .map_err(|_| LifecycleError::Journal)?;
3634 self.ports.events.emit(AttemptEvent::State {
3635 attempt: attempt.id,
3636 state: attempt.state(),
3637 });
3638 Ok(())
3639 }
3640
3641 async fn deregister_runner(&self, policy: &ScalePolicy, attempt: &RunnerAttempt) {
3662 let Some(runner_id) = attempt
3663 .github_runner_id()
3664 .or_else(|| read_runner_id(attempt.runtime_path()))
3665 else {
3666 return;
3667 };
3668 if self
3669 .ports
3670 .github
3671 .deregister(&policy.target, runner_id, &self.cancel)
3672 .await
3673 {
3674 self.ports.events.emit(AttemptEvent::Deregistered {
3675 attempt: attempt.id,
3676 runner_id,
3677 });
3678 } else {
3679 tracing::warn!(
3680 attempt = %attempt.id,
3681 runner_id,
3682 "the runner registration could not be removed from GitHub; it will show in the \
3683 target's runner settings until GitHub retires it or a later pass removes it"
3684 );
3685 }
3686 }
3687
3688 fn conclude(
3689 &self,
3690 attempt: &mut RunnerAttempt,
3691 outcome: AttemptOutcome,
3692 ) -> Result<(), LifecycleError> {
3693 attempt
3694 .conclude(outcome.clone(), self.ports.clock.now())
3695 .map_err(|_| LifecycleError::Transition)?;
3696 self.record(attempt)?;
3697 self.ports.events.emit(AttemptEvent::Concluded {
3698 attempt: attempt.id,
3699 outcome: OutcomeKind::of(&outcome),
3700 });
3701 Ok(())
3702 }
3703
3704 fn clean_or_quarantine(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
3724 let now = self.ports.clock.now();
3725 if !self.cleanup_retries().is_due(attempt.id, now) {
3726 return Ok(());
3727 }
3728 let reason = match self.clean_attempt(attempt) {
3729 Err(LifecycleError::SlotQuarantined { class, .. }) => class,
3730 Err(LifecycleError::WorkspaceCleanupDeferred) => {
3731 failure_reason_kind(&FailureReason::WorkspaceCleanupDeferred)
3732 }
3733 Err(LifecycleError::EnvironmentCleanupDeferred) => {
3734 "isolated_environment_cleanup_deferred"
3735 }
3736 other => {
3737 if other.is_ok() {
3738 self.cleanup_retries().succeeded(attempt.id);
3739 }
3740 return other;
3741 }
3742 };
3743 self.cleanup_retries().failed(attempt.id, now);
3744 self.ports
3745 .reconcile_events
3746 .emit(LifecycleEvent::AttemptCleanFailed {
3747 policy: attempt.policy_id,
3748 attempt: attempt.id,
3749 reason,
3750 });
3751 Ok(())
3752 }
3753
3754 fn cleanup_retries(&self) -> std::sync::MutexGuard<'_, CleanBackoff> {
3755 self.cleanup_retries
3756 .lock()
3757 .unwrap_or_else(std::sync::PoisonError::into_inner)
3758 }
3759
3760 fn clean_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
3761 if !attempt.execution().is_native() {
3762 return self.clean_isolated_attempt(attempt);
3763 }
3764 match self
3765 .ports
3766 .processes
3767 .destroy(attempt)
3768 .map_err(LifecycleError::Failed)?
3769 {
3770 ProviderDestroy::Destroyed => {}
3771 ProviderDestroy::Deferred(reason) => {
3772 return Err(LifecycleError::Failed(FailureReason::Other(reason.into())));
3773 }
3774 }
3775 let outcome = attempt
3776 .outcome()
3777 .cloned()
3778 .ok_or(LifecycleError::Transition)?;
3779 self.preserve_diagnostics(attempt, &outcome)?;
3780 self.scrub_workspace(attempt)?;
3781 self.ports
3782 .packages
3783 .release(attempt.id)
3784 .map_err(LifecycleError::Failed)?;
3785 attempt
3786 .clean(self.ports.clock.now())
3787 .map_err(|_| LifecycleError::Transition)?;
3788 self.record(attempt)?;
3789 let kind = OutcomeKind::of(&outcome);
3790 self.ports.events.emit(AttemptEvent::Cleaned {
3791 attempt: attempt.id,
3792 outcome: kind,
3793 });
3794 self.ports
3795 .reconcile_events
3796 .emit(LifecycleEvent::AttemptCleaned {
3797 policy: attempt.policy_id,
3798 attempt: attempt.id,
3799 outcome: kind,
3800 });
3801 Ok(())
3802 }
3803
3804 fn clean_isolated_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
3805 if attempt.state() != AttemptState::Destroying {
3806 attempt
3807 .begin_destroy(self.ports.clock.now())
3808 .map_err(|_| LifecycleError::Transition)?;
3809 self.record(attempt)?;
3810 }
3811 let provider = self.ports.processes.as_ref();
3812 let allocation_resources = provider.enumerate_owned(self.host_id);
3813 let matching_allocation = allocation_resources
3814 .iter()
3815 .filter(|identity| identity_same_allocation(self.host_id, attempt, identity))
3816 .collect::<Vec<_>>();
3817 if matching_allocation.len() > 1
3818 || matching_allocation
3819 .iter()
3820 .any(|identity| !identity_matches_intent(self.host_id, attempt, identity))
3821 {
3822 self.ports
3823 .events
3824 .emit(AttemptEvent::QuarantinedOwnedResource {
3825 attempt: attempt.id,
3826 });
3827 tracing::warn!(attempt_id = %attempt.id, reason = "duplicate_or_mismatched_isolated_ownership", "provider resources quarantined for operator review");
3828 return Err(self.defer_environment_cleanup(attempt)?);
3829 }
3830 let has_identity = matches!(
3831 attempt.execution(),
3832 AttemptExecution::Isolated {
3833 environment_id: Some(_),
3834 ..
3835 }
3836 );
3837 if !has_identity
3838 && allocation_resources
3839 .iter()
3840 .any(|identity| identity_matches_intent(self.host_id, attempt, identity))
3841 {
3842 return Err(self.defer_environment_cleanup(attempt)?);
3843 }
3844 let current = if has_identity {
3845 provider.inspect(attempt)
3846 } else {
3847 provider.recover(attempt)
3850 };
3851 let current = match current {
3852 Ok(state) => state,
3853 Err(_) => return Err(self.defer_environment_cleanup(attempt)?),
3854 };
3855 if !has_identity && current != EnvironmentState::Missing {
3856 return Err(self.defer_environment_cleanup(attempt)?);
3857 }
3858 if current != EnvironmentState::Missing {
3859 if !matches!(provider.owns(attempt), Ok(true)) {
3860 self.ports
3861 .events
3862 .emit(AttemptEvent::QuarantinedOwnedResource {
3863 attempt: attempt.id,
3864 });
3865 tracing::warn!(attempt_id = %attempt.id, reason = "isolated_resource_ownership_mismatch", "provider resource quarantined for operator review");
3866 return Err(self.defer_environment_cleanup(attempt)?);
3867 }
3868 if matches!(
3869 current,
3870 EnvironmentState::Starting | EnvironmentState::Running
3871 ) && !matches!(provider.stop(attempt), Ok(ProviderStop::Stopped))
3872 {
3873 return Err(self.defer_environment_cleanup(attempt)?);
3874 }
3875 if !matches!(provider.destroy(attempt), Ok(ProviderDestroy::Destroyed)) {
3876 return Err(self.defer_environment_cleanup(attempt)?);
3877 }
3878 if !matches!(provider.inspect(attempt), Ok(EnvironmentState::Missing)) {
3879 return Err(self.defer_environment_cleanup(attempt)?);
3880 }
3881 }
3882 if provider
3883 .enumerate_owned(self.host_id)
3884 .iter()
3885 .any(|identity| identity_same_allocation(self.host_id, attempt, identity))
3886 {
3887 return Err(self.defer_environment_cleanup(attempt)?);
3888 }
3889 let outcome = attempt
3890 .outcome()
3891 .cloned()
3892 .ok_or(LifecycleError::Transition)?;
3893 for diagnostic in provider.diagnostics(attempt).into_iter().take(32) {
3896 tracing::debug!(attempt_id = %attempt.id, provider_diagnostic = ?diagnostic, "provider lifecycle diagnostic");
3897 }
3898 self.preserve_diagnostics(attempt, &outcome)?;
3899 self.scrub_workspace(attempt)?;
3900 self.ports
3901 .packages
3902 .release(attempt.id)
3903 .map_err(LifecycleError::Failed)?;
3904 attempt
3905 .clean_isolated(self.ports.clock.now())
3906 .map_err(|_| LifecycleError::Transition)?;
3907 self.record(attempt)?;
3908 self.ports.events.emit(AttemptEvent::Cleaned {
3909 attempt: attempt.id,
3910 outcome: OutcomeKind::of(&outcome),
3911 });
3912 self.ports
3913 .reconcile_events
3914 .emit(LifecycleEvent::AttemptCleaned {
3915 policy: attempt.policy_id,
3916 attempt: attempt.id,
3917 outcome: OutcomeKind::of(&outcome),
3918 });
3919 Ok(())
3920 }
3921
3922 fn defer_environment_cleanup(
3923 &self,
3924 attempt: &mut RunnerAttempt,
3925 ) -> Result<LifecycleError, LifecycleError> {
3926 attempt
3927 .defer_cleanup(self.ports.clock.now())
3928 .map_err(|_| LifecycleError::Transition)?;
3929 self.record(attempt)?;
3930 Ok(LifecycleError::EnvironmentCleanupDeferred)
3931 }
3932
3933 fn scrub_workspace(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
3943 #[cfg(test)]
3944 {
3945 if matches!(
3949 std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref(),
3950 Ok("skip_workspace_cleanup" | "reuse_job_workspace")
3951 ) {
3952 return Ok(());
3953 }
3954 }
3955 match attempt.workspace() {
3956 AttemptWorkspace::Ephemeral => match remove_runtime_tree(attempt.runtime_path()) {
3957 Ok(()) => Ok(()),
3958 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3959 Err(_) => Err(LifecycleError::WorkspaceCleanupDeferred),
3960 },
3961 AttemptWorkspace::PersistentSlot { slot } => self.scrub_persistent_slot(attempt, slot),
3962 }
3963 }
3964
3965 fn scrub_persistent_slot(
3978 &self,
3979 attempt: &RunnerAttempt,
3980 slot: NonZeroU16,
3981 ) -> Result<(), LifecycleError> {
3982 let configured = self
3987 .ports
3988 .store
3989 .policy(attempt.policy_id)
3990 .map_err(|_| LifecycleError::Journal)?
3991 .and_then(|policy| match policy.workspace_policy() {
3992 WorkspacePolicy::Persistent { root } => Some(root.clone()),
3993 WorkspacePolicy::Ephemeral => None,
3994 });
3995 let runtime = attempt.runtime_path();
3996 self.quarantine_on_refusal(
3997 attempt,
3998 verify_journalled_slot(runtime, slot, configured.as_ref())
3999 .and_then(|()| slot_is_present(runtime))
4000 .and_then(|present| {
4001 if present {
4002 scrub_slot_entries(runtime).and_then(|()| verify_slot_scrubbed(runtime))
4003 } else {
4004 Ok(())
4005 }
4006 }),
4007 )
4008 }
4009
4010 fn quarantine_on_refusal(
4019 &self,
4020 attempt: &RunnerAttempt,
4021 outcome: Result<(), SlotQuarantine>,
4022 ) -> Result<(), LifecycleError> {
4023 let Err(quarantine) = outcome else {
4024 return Ok(());
4025 };
4026 let detail = quarantine.to_string();
4027 tracing::warn!(
4028 attempt = %attempt.id,
4029 policy = %attempt.policy_id,
4030 slot = attempt.workspace().slot_number(),
4031 refusal = quarantine.refusal.class(),
4032 "{detail}"
4033 );
4034 Err(LifecycleError::SlotQuarantined {
4035 class: quarantine.refusal.class(),
4036 detail,
4037 })
4038 }
4039
4040 fn preserve_diagnostics(
4041 &self,
4042 attempt: &RunnerAttempt,
4043 outcome: &AttemptOutcome,
4044 ) -> Result<(), LifecycleError> {
4045 fs::create_dir_all(&self.diagnostics_root).map_err(|_| {
4046 LifecycleError::Failed(FailureReason::Other(
4047 "diagnostics directory could not be created".into(),
4048 ))
4049 })?;
4050 let reason_kind = match outcome {
4053 AttemptOutcome::Failed { reason } => failure_reason_kind(reason),
4054 _ => "none",
4055 };
4056 let diagnostic = format!(
4057 "attempt_id={}\npolicy_id={}\noutcome={}\nreason={}\n",
4058 attempt.id,
4059 attempt.policy_id,
4060 OutcomeKind::of(outcome).as_str(),
4061 reason_kind,
4062 );
4063 fs::write(
4064 self.diagnostics_root.join(format!("{}.log", attempt.id)),
4065 diagnostic,
4066 )
4067 .map_err(|_| {
4068 LifecycleError::Failed(FailureReason::Other(
4069 "redacted diagnostics could not be preserved".into(),
4070 ))
4071 })
4072 }
4073
4074 async fn materialize_with_retry(
4075 &self,
4076 policy: &ScalePolicy,
4077 attempt: &RunnerAttempt,
4078 ) -> Result<RunnerVersion, FailureReason> {
4079 let mut issued = 0_u32;
4080 loop {
4081 issued = issued.saturating_add(1);
4082 match self.ports.packages.materialize(attempt).await {
4083 Ok(version) => return Ok(version),
4084 Err(reason)
4085 if package_failure_is_terminal(&reason)
4086 || issued >= self.retry.max_attempts.max(1) =>
4087 {
4088 return Err(reason);
4089 }
4090 Err(reason) => {
4091 if !self.ports.demand.persists(policy.id).await {
4092 return Err(reason);
4093 }
4094 let delay = self.retry.delay(issued);
4095 self.ports.events.emit(AttemptEvent::Retry {
4096 attempt: attempt.id,
4097 operation: "package_materialization",
4098 delay,
4099 });
4100 self.ports.delay.wait(delay).await;
4101 if !self.ports.demand.persists(policy.id).await {
4102 return Err(reason);
4103 }
4104 }
4105 }
4106 }
4107 }
4108
4109 async fn register_with_retry(
4110 &self,
4111 policy: &ScalePolicy,
4112 attempt: AttemptId,
4113 request: &JitRunnerRequest,
4114 ) -> Result<JitRegistration, LifecycleError> {
4115 let mut issued = 0_u32;
4116 loop {
4117 issued = issued.saturating_add(1);
4118 match self
4119 .ports
4120 .github
4121 .register(&policy.target, request, &self.cancel)
4122 .await
4123 {
4124 Ok(registration) => return Ok(registration),
4125 Err(error) if error.terminal => {
4126 return Err(LifecycleError::Failed(error.reason));
4127 }
4128 Err(error) => {
4129 if issued >= self.retry.max_attempts.max(1)
4130 || !self.ports.demand.persists(policy.id).await
4131 {
4132 return Err(LifecycleError::Failed(error.reason));
4133 }
4134 let delay = error
4135 .retry_after
4136 .unwrap_or_else(|| self.retry.delay(issued));
4137 self.ports.events.emit(AttemptEvent::Retry {
4138 attempt,
4139 operation: "jit_request",
4140 delay,
4141 });
4142 self.ports.delay.wait(delay).await;
4143 if !self.ports.demand.persists(policy.id).await {
4144 return Err(LifecycleError::Failed(error.reason));
4145 }
4146 }
4147 }
4148 }
4149 }
4150
4151 fn allocate_workspace(
4161 &self,
4162 policy: &ScalePolicy,
4163 id: AttemptId,
4164 ) -> Result<Placement, LifecycleError> {
4165 let placement = match policy.workspace_policy() {
4166 WorkspacePolicy::Persistent { root } => self.allocate_persistent_slot(policy, root),
4171 WorkspacePolicy::Ephemeral => self.allocate_disposable(policy, id),
4172 };
4173 if placement.is_ok() {
4176 self.root_accepted(policy.id);
4177 }
4178 placement
4179 }
4180
4181 fn configured_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
4188 let host = self
4189 .ports
4190 .store
4191 .host(self.host_id)
4192 .map_err(|_| LifecycleError::Journal)?
4193 .ok_or_else(|| LifecycleError::Failed(FailureReason::Other("host not found".into())))?;
4194 Ok(host.runner_root_override.clone())
4195 }
4196
4197 fn resolvable_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
4201 Ok(self
4202 .configured_host_root()?
4203 .or_else(|| default_runner_root(&self.app_paths).ok()))
4204 }
4205
4206 fn effective_host_root(
4211 &self,
4212 policy: &ScalePolicy,
4213 ) -> Result<LocalAbsolutePath, LifecycleError> {
4214 match self.configured_host_root()? {
4215 Some(configured) => Ok(configured),
4216 None => default_runner_root(&self.app_paths).map_err(|error| {
4217 self.root_refused(policy.id, "the platform default runner root", error)
4220 }),
4221 }
4222 }
4223
4224 fn root_refused(&self, policy: PolicyId, root: &str, error: RunnerRootError) -> LifecycleError {
4237 let _ = runner_manager_platform::service::record_runner_root_refusal(
4238 &self.app_paths,
4239 &policy.to_string(),
4240 self.ports.clock.now(),
4241 error.kind(),
4242 root,
4243 &error.to_string(),
4244 );
4245 root_failure(error)
4246 }
4247
4248 fn root_accepted(&self, policy: PolicyId) {
4255 let _ = runner_manager_platform::service::clear_runner_root_refusal(
4256 &self.app_paths,
4257 &policy.to_string(),
4258 );
4259 }
4260
4261 fn allocate_disposable(
4264 &self,
4265 policy: &ScalePolicy,
4266 id: AttemptId,
4267 ) -> Result<Placement, LifecycleError> {
4268 let effective_root = self.effective_host_root(policy)?;
4269 RootPreflight::new(&self.app_paths)
4270 .check(&RootOwner::Host, &effective_root)
4271 .map_err(|error| self.root_refused(policy.id, effective_root.as_str(), error))?;
4272 let runtime = effective_root.as_path().join({
4273 #[cfg(test)]
4274 {
4275 if std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref()
4276 == Ok("reuse_job_workspace")
4277 {
4278 "mutant-shared-workspace".to_owned()
4279 } else {
4280 workspace_name(id)
4281 }
4282 }
4283 #[cfg(not(test))]
4284 {
4285 workspace_name(id)
4286 }
4287 });
4288 fs::create_dir_all(&runtime)
4289 .map_err(|_| LifecycleError::Failed(FailureReason::ProcessStartFailed))?;
4290 Ok(Placement {
4291 runtime,
4292 workspace: AttemptWorkspace::Ephemeral,
4293 })
4294 }
4295
4296 fn allocate_persistent_slot(
4308 &self,
4309 policy: &ScalePolicy,
4310 root: &LocalAbsolutePath,
4311 ) -> Result<Placement, LifecycleError> {
4312 let ceiling = policy.max_capacity().ok_or_else(|| {
4315 LifecycleError::Failed(FailureReason::Other(
4316 "a persistent workspace needs the policy's max_capacity to bound its slots"
4317 .to_string(),
4318 ))
4319 })?;
4320 let leases = self
4321 .ports
4322 .store
4323 .slot_leases_for_policy(policy.id)
4324 .map_err(|_| LifecycleError::Journal)?;
4325 let slot = lowest_free_slot(&leases, ceiling).ok_or_else(|| {
4326 LifecycleError::Failed(FailureReason::Other(format!(
4327 "every persistent slot s1 to s{ceiling} for {} is leased by an attempt that has \
4328 not been cleaned, so no slot is free; raise the repository's max capacity, or \
4329 finish cleaning a concluded attempt",
4330 policy.target
4331 )))
4332 })?;
4333 let workspace = AttemptWorkspace::persistent_slot(slot);
4334 let name = workspace
4335 .slot_directory_name()
4336 .expect("a persistent allocation names its slot directory");
4337
4338 let host_root = self.resolvable_host_root()?;
4350 let mut preflight = RootPreflight::new(&self.app_paths);
4351 if let Some(host_root) = host_root {
4352 preflight = preflight.against(RootOwner::Host, host_root);
4353 }
4354 let checked = preflight
4355 .check(&RootOwner::Repository(policy.target.to_string()), root)
4356 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4357 if let Some(leaf) = checked.leaf_to_create() {
4358 fs::create_dir(leaf).map_err(|source| {
4359 LifecycleError::Failed(FailureReason::Other(format!(
4360 "the persistent workspace root {} could not be created: {source}",
4361 leaf.display()
4362 )))
4363 })?;
4364 }
4365
4366 let slot_path = runner_root::derive_child(root, &name)
4370 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4371 create_or_validate_slot(slot_path.as_path())?;
4372 runner_root::verify_containment(root, &slot_path)
4373 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4374 accept_reusable_slot(slot_path.as_path())?;
4375 Ok(Placement {
4376 runtime: slot_path.as_path().to_path_buf(),
4377 workspace,
4378 })
4379 }
4380
4381 fn record_allocation(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
4393 match self.ports.store.record_attempt(attempt) {
4394 Ok(()) => {
4395 self.ports.events.emit(AttemptEvent::State {
4396 attempt: attempt.id,
4397 state: attempt.state(),
4398 });
4399 Ok(())
4400 }
4401 Err(error @ StoreError::SlotAlreadyLeased { .. }) => Err(LifecycleError::Failed(
4402 FailureReason::Other(error.to_string()),
4403 )),
4404 Err(_) => Err(LifecycleError::Journal),
4405 }
4406 }
4407
4408 async fn launch_attempt(
4409 &self,
4410 policy: &ScalePolicy,
4411 allocation_guard: &AllocationGuard,
4412 lock_wait: Duration,
4413 ) -> Result<RunnerAttempt, LifecycleError> {
4414 let (attempt, labels) = self.allocate_attempt(policy)?;
4415 let id = attempt.id;
4416 let mut stopwatch = LaunchStopwatch::new(lock_wait);
4417 let result = self
4418 .launch_steps(policy, allocation_guard, attempt, labels, &mut stopwatch)
4419 .await;
4420 stopwatch.finish();
4421 self.ports.events.emit(AttemptEvent::Launched {
4422 attempt: id,
4423 started: result.is_ok(),
4424 timings: stopwatch.timings,
4425 });
4426 result
4427 }
4428
4429 fn allocate_attempt<'p>(
4432 &self,
4433 policy: &'p ScalePolicy,
4434 ) -> Result<(RunnerAttempt, &'p RoutingLabels), LifecycleError> {
4435 if !*self
4436 .recovery_complete
4437 .lock()
4438 .map_err(|_| LifecycleError::Journal)?
4439 {
4440 return Err(LifecycleError::RecoveryIncomplete);
4441 }
4442 let capability = self.ports.processes.probe(policy);
4443 if capability != ProviderCapability::Ready {
4444 return Err(LifecycleError::Failed(capability.refusal()));
4445 }
4446 let resolved = self.ports.processes.resolve(policy).map_err(|reason| {
4447 LifecycleError::Failed(if policy.execution_policy().is_native() {
4448 reason
4449 } else {
4450 safe_isolation_reason(reason, IsolationProviderFailure::RuntimeOperationFailed)
4451 })
4452 })?;
4453 if policy.execution_policy().is_native() != resolved.is_none() {
4454 return Err(LifecycleError::Failed(FailureReason::Other(
4455 "provider resolution mismatch".into(),
4456 )));
4457 }
4458 if let Some(resolution) = &resolved {
4459 if resolution.provider_kind == Backend::Auto {
4460 return Err(LifecycleError::Failed(FailureReason::Other(
4461 "provider resolution mismatch".into(),
4462 )));
4463 }
4464 AttemptExecution::Isolated {
4465 provider_kind: resolution.provider_kind,
4466 environment_id: None,
4467 resolved_image: resolution.image.clone(),
4468 generation: "generation-check".into(),
4469 }
4470 .validate()
4471 .map_err(|_| {
4472 LifecycleError::Failed(FailureReason::Other(
4473 "resolved image is incompatible".into(),
4474 ))
4475 })?;
4476 }
4477 let labels = policy
4478 .routing_labels()
4479 .ok_or(LifecycleError::Failed(FailureReason::JitRequestFailed))?;
4480 let id = AttemptId::new_random();
4481 let placement = self.allocate_workspace(policy, id)?;
4482 let mut attempt = RunnerAttempt::allocate_in(
4483 id,
4484 policy.id,
4485 placement.runtime,
4486 placement.workspace,
4487 self.ports.clock.now(),
4488 );
4489 if let Some(resolution) = &resolved {
4490 attempt
4491 .allocate_execution(AttemptExecution::Isolated {
4492 provider_kind: resolution.provider_kind,
4493 environment_id: None,
4494 resolved_image: resolution.image.clone(),
4495 generation: uuid::Uuid::new_v4().to_string(),
4496 })
4497 .map_err(|_| LifecycleError::Transition)?;
4498 }
4499 self.record_allocation(&attempt)?;
4504 Ok((attempt, labels))
4505 }
4506
4507 async fn launch_steps(
4508 &self,
4509 policy: &ScalePolicy,
4510 allocation_guard: &AllocationGuard,
4511 mut attempt: RunnerAttempt,
4512 labels: &RoutingLabels,
4513 stopwatch: &mut LaunchStopwatch,
4514 ) -> Result<RunnerAttempt, LifecycleError> {
4515 let id = attempt.id;
4516 let isolated = !attempt.execution().is_native();
4517 stopwatch.begin(LaunchStep::Materialize);
4518 let version = match self.materialize_with_retry(policy, &attempt).await {
4519 Ok(version) => version,
4520 Err(reason) => return self.fail_launch(&mut attempt, reason),
4521 };
4522 self.prune_under_allocation_lock(allocation_guard, &version)?;
4523 self.versions
4524 .lock()
4525 .map_err(|_| LifecycleError::Journal)?
4526 .insert(id, version);
4527
4528 if isolated {
4529 attempt
4530 .begin_prepare(self.ports.clock.now())
4531 .map_err(|_| LifecycleError::Transition)?;
4532 self.record(&attempt)?;
4533 }
4534 let prepared = match self.ports.processes.prepare(&attempt, policy) {
4535 Ok(prepared) => prepared,
4536 Err(reason) => {
4537 let safe_reason = if isolated {
4538 safe_isolation_reason(reason, IsolationProviderFailure::RuntimeOperationFailed)
4539 } else {
4540 reason
4541 };
4542 return self.fail_launch(&mut attempt, safe_reason);
4543 }
4544 };
4545 if isolated {
4546 let Some(identity) = prepared.identity() else {
4547 return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
4548 IsolationProviderFailure::OwnershipMismatch,
4549 )));
4550 };
4551 if !identity_matches_intent(self.host_id, &attempt, identity) {
4552 return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
4553 IsolationProviderFailure::OwnershipMismatch,
4554 )));
4555 }
4556 let EnvironmentIdentity::Isolated { environment_id, .. } = identity else {
4557 unreachable!()
4558 };
4559 attempt
4560 .prepared_environment(environment_id.clone())
4561 .map_err(|_| LifecycleError::Transition)?;
4562 attempt
4563 .mark_prepared(self.ports.clock.now())
4564 .map_err(|_| LifecycleError::Transition)?;
4565 self.record(&attempt)?;
4566 }
4567
4568 stopwatch.begin(LaunchStep::Register);
4569 let jit_request =
4570 JitRunnerRequest::for_policy(runner_name(id), self.runner_group_id, labels);
4571 let registration = match self.register_with_retry(policy, id, &jit_request).await {
4572 Ok(registration) => registration,
4573 Err(error) => return self.fail_launch(&mut attempt, error.reason()),
4574 };
4575 stopwatch.begin(LaunchStep::Spawn);
4576 let runner_id = registration.runner().id;
4577 write_runner_id(attempt.runtime_path(), runner_id)?;
4578 attempt
4579 .jit_received(self.ports.clock.now())
4580 .map_err(|_| LifecycleError::Transition)?;
4581 self.record(&attempt)?;
4582 let config = registration.into_config();
4583 if isolated {
4584 let started = self
4585 .ports
4586 .processes
4587 .start(prepared, &attempt, OneTimeJitHandoff::new(&config))
4588 .map_err(|failure| {
4589 LifecycleError::Failed(safe_isolation_reason(
4590 failure.reason,
4591 IsolationProviderFailure::RuntimeOperationFailed,
4592 ))
4593 })?;
4594 if !identity_matches_intent(self.host_id, &attempt, &started) {
4595 return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
4596 IsolationProviderFailure::OwnershipMismatch,
4597 )));
4598 }
4599 attempt
4600 .started_isolated(self.ports.clock.now())
4601 .map_err(|_| LifecycleError::Transition)?;
4602 self.record(&attempt)?;
4603 return Ok(attempt);
4604 }
4605 let mut issued = 0_u32;
4606 let pid = loop {
4607 issued = issued.saturating_add(1);
4608 match self.ports.processes.start(
4609 prepared.clone(),
4610 &attempt,
4611 OneTimeJitHandoff::new(&config),
4612 ) {
4613 Ok(EnvironmentIdentity::NativeProcess(pid)) => break pid,
4614 Ok(EnvironmentIdentity::Isolated { .. }) => {
4615 return Err(LifecycleError::Failed(FailureReason::Other(
4619 "unexpected isolated environment identity".into(),
4620 )));
4621 }
4622 Err(error) => {
4623 if let Some(pid) = error.live_pid {
4624 attempt
4625 .started(pid, self.ports.clock.now())
4626 .map_err(|_| LifecycleError::Transition)?;
4627 self.record(&attempt)?;
4628 return Err(LifecycleError::Failed(error.reason));
4629 }
4630 if !error.retryable
4631 || issued >= self.retry.max_attempts.max(1)
4632 || !self.ports.demand.persists(policy.id).await
4633 {
4634 return self.fail_launch(&mut attempt, error.reason);
4635 }
4636 let delay = self.retry.delay(issued);
4637 self.ports.events.emit(AttemptEvent::Retry {
4638 attempt: attempt.id,
4639 operation: "process_start",
4640 delay,
4641 });
4642 self.ports.delay.wait(delay).await;
4643 if !self.ports.demand.persists(policy.id).await {
4644 return self.fail_launch(&mut attempt, error.reason);
4645 }
4646 }
4647 }
4648 };
4649 attempt
4650 .started(pid, self.ports.clock.now())
4651 .map_err(|_| LifecycleError::Transition)?;
4652 self.record(&attempt)?;
4653 Ok(attempt)
4654 }
4655
4656 fn fail_launch<T>(
4657 &self,
4658 attempt: &mut RunnerAttempt,
4659 reason: FailureReason,
4660 ) -> Result<T, LifecycleError> {
4661 self.conclude(attempt, AttemptOutcome::failed(reason.clone()))?;
4662 Err(LifecycleError::Failed(reason))
4663 }
4664
4665 fn prune_under_allocation_lock(
4668 &self,
4669 guard: &AllocationGuard,
4670 version: &RunnerVersion,
4671 ) -> Result<(), LifecycleError> {
4672 let attempts = self
4673 .ports
4674 .store
4675 .attempts()
4676 .map_err(|_| LifecycleError::Journal)?;
4677 self.ports
4678 .packages
4679 .prune_obsolete_guarded(
4680 PruneAuthority::from_launch_request(guard),
4681 version,
4682 &attempts,
4683 )
4684 .map_err(LifecycleError::Failed)?;
4685 #[cfg(target_os = "macos")]
4686 self.prune_abandoned_seeds(&attempts);
4687 Ok(())
4688 }
4689
4690 #[cfg(target_os = "macos")]
4702 fn prune_abandoned_seeds(&self, attempts: &[RunnerAttempt]) {
4703 let Ok(policies) = self.ports.store.policies() else {
4704 return;
4705 };
4706 let Ok(Some(host_root)) = self.resolvable_host_root() else {
4707 return;
4708 };
4709 let in_use: BTreeSet<PathBuf> = policies
4710 .iter()
4711 .filter_map(|policy| match policy.workspace_policy() {
4712 WorkspacePolicy::Persistent { root } => Some(canonical_or_raw(root.as_path())),
4713 WorkspacePolicy::Ephemeral => None,
4714 })
4715 .chain(std::iter::once(canonical_or_raw(host_root.as_path())))
4716 .collect();
4717 let used: BTreeSet<&Path> = attempts
4719 .iter()
4720 .filter_map(|attempt| attempt.runtime_path().parent())
4721 .collect();
4722 for root in used.into_iter().map(canonical_or_raw) {
4723 if !in_use.contains(&root) {
4724 let _ = remove_runtime_tree(&root.join(PACKAGE_SEED_DIR));
4725 }
4726 }
4727 }
4728}
4729
4730#[cfg(target_os = "macos")]
4731fn canonical_or_raw(path: &Path) -> PathBuf {
4732 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
4733}
4734
4735#[async_trait]
4736impl RunnerLauncher for LifecycleLauncher {
4737 async fn host_capacity(
4738 &self,
4739 host: &runner_manager_domain::model::Host,
4740 ) -> Result<std::num::NonZeroU16, crate::reconcile::HostCapacityError> {
4741 self.ports
4742 .store
4743 .host(host.id)
4744 .map_err(|_| crate::reconcile::HostCapacityError::JournalUnreadable)?
4745 .map(|current| current.host_capacity)
4746 .ok_or(crate::reconcile::HostCapacityError::MissingHost)
4747 }
4748
4749 async fn supervise(
4750 &self,
4751 policy: &ScalePolicy,
4752 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
4753 LifecycleLauncher::supervise(self, policy)
4754 .await
4755 .map_err(|error| LaunchFailure::new(error.reason()))
4756 }
4757
4758 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
4759 self.ports.store.attempts().map_err(|_| {
4760 LaunchFailure::new(FailureReason::Other(
4761 "attempt journal could not be read".into(),
4762 ))
4763 })
4764 }
4765
4766 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
4767 self.launch_attempt(request.policy, request.allocation_guard, request.lock_wait)
4768 .await
4769 .map_err(|error| LaunchFailure::new(error.reason()))
4770 }
4771
4772 async fn clean(&self, id: AttemptId) -> Result<(), LaunchFailure> {
4773 let mut attempt = self
4774 .ports
4775 .store
4776 .attempt(id)
4777 .map_err(|_| {
4778 LaunchFailure::new(FailureReason::Other(
4779 "attempt journal could not be read".into(),
4780 ))
4781 })?
4782 .ok_or_else(|| {
4783 LaunchFailure::new(FailureReason::Other(
4784 "attempt disappeared from the journal".into(),
4785 ))
4786 })?;
4787 self.clean_attempt(&mut attempt)
4788 .map_err(|error| LaunchFailure::new(error.reason()))?;
4789 self.cleanup_retries().succeeded(id);
4790 Ok(())
4791 }
4792}
4793
4794#[must_use]
4797pub fn runner_name(attempt: AttemptId) -> String {
4798 format!("runner-manager-{attempt}")
4799}
4800
4801const EVIDENCE_DEADLINE: Duration = Duration::from_secs(2);
4803
4804#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4814enum JobEvidence {
4815 RanAJob,
4816 NoJob,
4817 Unknown,
4818}
4819
4820impl JobEvidence {
4821 fn of(runtime: &Path) -> Self {
4822 let diag = runtime.join("_diag");
4823 if runner_manager_platform::host_fitness::directory_responds(&diag, EVIDENCE_DEADLINE)
4827 != runner_manager_platform::host_fitness::Responsiveness::Responds
4828 {
4829 return Self::Unknown;
4830 }
4831 let Ok(entries) = fs::read_dir(diag) else {
4832 return Self::Unknown;
4833 };
4834 let mut listener = false;
4835 for entry in entries.flatten() {
4836 let name = entry.file_name();
4837 let name = name.to_string_lossy();
4838 if !name.ends_with(".log") {
4839 continue;
4840 }
4841 if name.starts_with("Worker_") {
4842 return Self::RanAJob;
4843 }
4844 listener |= name.starts_with("Runner_");
4845 }
4846 if listener { Self::NoJob } else { Self::Unknown }
4847 }
4848}
4849
4850fn known_runner_id(attempt: &RunnerAttempt, observed: Option<u64>) -> Option<u64> {
4853 attempt
4854 .github_runner_id()
4855 .or(observed)
4856 .or_else(|| read_runner_id(attempt.runtime_path()))
4857}
4858
4859fn read_runner_id(runtime: &Path) -> Option<u64> {
4860 fs::read_to_string(runtime.join(RUNNER_ID_FILE))
4861 .ok()?
4862 .trim()
4863 .parse()
4864 .ok()
4865}
4866
4867fn write_runner_id(runtime: &Path, runner_id: u64) -> Result<(), LifecycleError> {
4868 let target = runtime.join(RUNNER_ID_FILE);
4869 if let Some(existing) = read_runner_id(runtime) {
4870 return (existing == runner_id)
4871 .then_some(())
4872 .ok_or(LifecycleError::Journal);
4873 }
4874 let temporary = runtime.join(format!("{RUNNER_ID_FILE}.{}.tmp", uuid::Uuid::new_v4()));
4875 write_durable_file(&temporary, runner_id.to_string().as_bytes())
4876 .map_err(|_| LifecycleError::Journal)?;
4877 match fs::rename(&temporary, &target) {
4878 Ok(()) => sync_directory(runtime).map_err(|_| LifecycleError::Journal),
4879 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
4880 let _ = fs::remove_file(&temporary);
4881 (read_runner_id(runtime) == Some(runner_id))
4882 .then_some(())
4883 .ok_or(LifecycleError::Journal)
4884 }
4885 Err(_) => {
4886 let _ = fs::remove_file(&temporary);
4887 Err(LifecycleError::Journal)
4888 }
4889 }
4890}
4891
4892fn write_durable_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
4893 let mut file = fs::OpenOptions::new()
4894 .create(true)
4895 .truncate(true)
4896 .write(true)
4897 .open(path)?;
4898 file.write_all(bytes)?;
4899 file.sync_all()?;
4900 let parent = path.parent().ok_or_else(|| {
4901 std::io::Error::new(
4902 std::io::ErrorKind::InvalidInput,
4903 "file has no parent directory",
4904 )
4905 })?;
4906 sync_directory(parent)
4907}
4908
4909#[cfg(unix)]
4910fn sync_directory(path: &Path) -> std::io::Result<()> {
4911 fs::File::open(path)?.sync_all()
4912}
4913
4914#[cfg(windows)]
4915fn sync_directory(path: &Path) -> std::io::Result<()> {
4916 use std::os::windows::fs::OpenOptionsExt;
4917
4918 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
4919 const FILE_SHARE_ALL: u32 = 0x0000_0007;
4920 const GENERIC_WRITE: u32 = 0x4000_0000;
4921 fs::OpenOptions::new()
4922 .access_mode(GENERIC_WRITE)
4923 .share_mode(FILE_SHARE_ALL)
4924 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
4925 .open(path)?
4926 .sync_all()
4927}
4928
4929#[cfg(test)]
4930mod tests {
4931 use super::*;
4932 use std::collections::BTreeSet;
4933 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
4934
4935 use crate::reconcile::{AllocationLock, InProcessAllocationLock};
4936 use runner_manager_domain::model::{Elapsed, TargetScope};
4937 use runner_manager_domain::store::SqliteStore;
4938 use runner_manager_github::jit::JitRunner;
4939 use runner_manager_testkit::clock::FakeClock;
4940 use runner_manager_testkit::fixtures;
4941
4942 #[test]
4943 fn production_provider_set_routes_every_implemented_isolated_backend() {
4944 let providers = PlatformExecutionProvider::new(HostId::from_u128(1));
4945 assert!(providers.isolated_provider(Backend::Auto).is_some());
4946 assert!(providers.isolated_provider(Backend::Oci).is_some());
4947 assert!(
4948 providers
4949 .isolated_provider(Backend::WindowsHyperVContainer)
4950 .is_some()
4951 );
4952 assert!(
4953 providers
4954 .isolated_provider(Backend::VirtualMachine)
4955 .is_none()
4956 );
4957 }
4958
4959 type CapturedFields = Vec<(String, String)>;
4961
4962 #[derive(Clone, Default)]
4970 struct CapturedEvents(std::sync::Arc<std::sync::Mutex<Vec<CapturedFields>>>);
4971
4972 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedEvents {
4973 fn on_event(
4974 &self,
4975 event: &tracing::Event<'_>,
4976 _context: tracing_subscriber::layer::Context<'_, S>,
4977 ) {
4978 struct Collect(Vec<(String, String)>);
4979 impl tracing::field::Visit for Collect {
4980 fn record_debug(
4981 &mut self,
4982 field: &tracing::field::Field,
4983 value: &dyn std::fmt::Debug,
4984 ) {
4985 self.0.push((
4988 field.name().to_owned(),
4989 format!("{value:?}").trim_matches('"').to_owned(),
4990 ));
4991 }
4992 }
4993 let mut collected = Collect(Vec::new());
4994 event.record(&mut collected);
4995 self.0
4996 .lock()
4997 .expect("the capture mutex is not poisoned")
4998 .push(collected.0);
4999 }
5000 }
5001
5002 #[test]
5020 fn a_launch_the_runner_root_refused_names_the_cause_in_the_log_that_ships() {
5021 use runner_manager_platform::logging;
5022 use tracing_subscriber::layer::SubscriberExt as _;
5023
5024 let captured = CapturedEvents::default();
5025 let error = RunnerRootError::DeniedByPrivacyPolicy {
5026 requested: PathBuf::from("/Volumes/NVME/runners"),
5027 refused: PathBuf::from("/Volumes/NVME"),
5028 remediation: RootOwner::Host.remediation(),
5029 };
5030 let kind = error.kind();
5031
5032 let failure = tracing::subscriber::with_default(
5033 tracing_subscriber::registry().with(captured.clone()),
5034 || root_failure(error),
5035 );
5036
5037 assert!(
5042 matches!(
5043 &failure,
5044 LifecycleError::Failed(FailureReason::Other(detail))
5045 if detail.contains("/Volumes/NVME/runners")
5046 && detail.contains("Full Disk Access")
5047 ),
5048 "the reason must still carry the detail: {failure:?}"
5049 );
5050
5051 let events = captured
5052 .0
5053 .lock()
5054 .expect("the capture mutex is not poisoned")
5055 .clone();
5056 let event = events
5057 .iter()
5058 .find(|fields| fields.iter().any(|(_, value)| value.contains(kind)))
5059 .unwrap_or_else(|| panic!("the refusal did not name its cause: {events:?}"));
5060
5061 for (name, value) in event {
5066 assert!(
5067 logging::is_field_allowed(name),
5068 "`{name}` is not allow-listed, so it ships as `{}`: {event:?}",
5069 logging::REDACTION
5070 );
5071 assert_eq!(
5072 &logging::redact(value),
5073 value,
5074 "`{name}` does not survive value-shape scrubbing: {event:?}"
5075 );
5076 }
5077 }
5078
5079 fn nz(slot: u16) -> NonZeroU16 {
5081 NonZeroU16::new(slot).expect("a positive slot")
5082 }
5083
5084 const JIT: &str = "eyJzZWNyZXQiOiJnaHBfRE9fTk9UX0xFQUsifQ==";
5085
5086 #[derive(Debug, Default)]
5087 struct FakeGithubLifecycle {
5088 registration_failures: Mutex<VecDeque<bool>>,
5089 observations: Mutex<VecDeque<LifecycleGithubObservation>>,
5090 registrations: AtomicUsize,
5091 remaining_runners: AtomicUsize,
5092 deregistrations: Mutex<Vec<u64>>,
5096 deregistration_fails: AtomicBool,
5099 journal: Mutex<Option<Arc<SqliteStore>>>,
5104 registration_facts: Mutex<Vec<RegistrationFact>>,
5106 }
5107
5108 #[derive(Debug, Clone)]
5115 struct RegistrationFact {
5116 leased_slots: Vec<u16>,
5118 work_folder: String,
5120 runner_name: String,
5123 journal_states: Vec<AttemptState>,
5124 }
5125
5126 impl FakeGithubLifecycle {
5127 fn fail(mut self, terminal: bool) -> Self {
5128 self.registration_failures
5129 .get_mut()
5130 .expect("unpoisoned")
5131 .push_back(terminal);
5132 self
5133 }
5134
5135 fn watch_journal(&self, store: Arc<SqliteStore>) {
5136 *self.journal.lock().unwrap() = Some(store);
5137 }
5138
5139 fn registration_facts(&self) -> Vec<RegistrationFact> {
5140 self.registration_facts.lock().unwrap().clone()
5141 }
5142
5143 fn observe(&self, observation: GithubRunnerObservation) {
5144 let observation = match observation {
5145 GithubRunnerObservation::Unreachable => LifecycleGithubObservation::unreachable(),
5146 GithubRunnerObservation::NotRegistered => {
5147 LifecycleGithubObservation::not_registered()
5148 }
5149 GithubRunnerObservation::Registered { busy } => {
5150 LifecycleGithubObservation::registered(73, busy)
5151 }
5152 };
5153 self.observations.lock().unwrap().push_back(observation);
5154 }
5155 }
5156
5157 #[async_trait]
5158 impl LifecycleGithub for FakeGithubLifecycle {
5159 async fn register(
5160 &self,
5161 _target: &ScaleTarget,
5162 request: &JitRunnerRequest,
5163 _cancel: &CancelToken,
5164 ) -> Result<JitRegistration, JitRequestFailure> {
5165 self.registrations.fetch_add(1, Ordering::SeqCst);
5166 if let Some(store) = self.journal.lock().unwrap().as_ref() {
5167 let attempts = store.attempts().expect("the journal is readable");
5168 let slots = attempts
5169 .iter()
5170 .filter_map(|attempt| attempt.workspace().slot_number())
5171 .collect();
5172 self.registration_facts
5173 .lock()
5174 .unwrap()
5175 .push(RegistrationFact {
5176 leased_slots: slots,
5177 work_folder: request.work_folder().to_string(),
5178 runner_name: request.name().to_string(),
5179 journal_states: attempts.iter().map(RunnerAttempt::state).collect(),
5180 });
5181 }
5182 if let Some(terminal) = self.registration_failures.lock().unwrap().pop_front() {
5183 return Err(JitRequestFailure {
5184 terminal,
5185 reason: if terminal {
5186 FailureReason::Other("GitHub refused JIT registration with 403".into())
5187 } else {
5188 FailureReason::JitRequestFailed
5189 },
5190 retry_after: None,
5191 });
5192 }
5193 self.remaining_runners.store(1, Ordering::SeqCst);
5194 Ok(JitRegistration::new(
5195 EncodedJitConfig::new(JIT),
5196 JitRunner {
5197 id: 73,
5198 name: request.name().to_string(),
5199 os: "windows".into(),
5200 status: "offline".into(),
5201 busy: false,
5202 runner_group_id: Some(1),
5203 labels: request.labels().to_vec(),
5204 },
5205 ))
5206 }
5207
5208 async fn observe(
5209 &self,
5210 _target: &ScaleTarget,
5211 _attempt: AttemptId,
5212 _cancel: &CancelToken,
5213 ) -> LifecycleGithubObservation {
5214 let observation = self
5215 .observations
5216 .lock()
5217 .unwrap()
5218 .pop_front()
5219 .unwrap_or(LifecycleGithubObservation::not_registered());
5220 if observation.status == GithubRunnerObservation::NotRegistered {
5221 self.remaining_runners.store(0, Ordering::SeqCst);
5222 }
5223 observation
5224 }
5225
5226 async fn deregister(
5227 &self,
5228 _target: &ScaleTarget,
5229 runner_id: u64,
5230 _cancel: &CancelToken,
5231 ) -> bool {
5232 self.deregistrations.lock().unwrap().push(runner_id);
5233 if self.deregistration_fails.load(Ordering::SeqCst) {
5234 return false;
5235 }
5236 self.remaining_runners.store(0, Ordering::SeqCst);
5237 true
5238 }
5239 }
5240
5241 #[derive(Debug)]
5242 struct FakePackages {
5243 version: RunnerVersion,
5244 leases: Mutex<BTreeSet<AttemptId>>,
5245 materializations: AtomicUsize,
5246 materialization_failures: AtomicUsize,
5247 release_failures: AtomicUsize,
5248 releases: AtomicUsize,
5249 prunes: AtomicUsize,
5250 prune_currents: Mutex<Vec<RunnerVersion>>,
5251 }
5252
5253 impl Default for FakePackages {
5254 fn default() -> Self {
5255 Self {
5256 version: RunnerVersion::parse("2.330.0").unwrap(),
5257 leases: Mutex::new(BTreeSet::new()),
5258 materializations: AtomicUsize::new(0),
5259 materialization_failures: AtomicUsize::new(0),
5260 release_failures: AtomicUsize::new(0),
5261 releases: AtomicUsize::new(0),
5262 prunes: AtomicUsize::new(0),
5263 prune_currents: Mutex::new(Vec::new()),
5264 }
5265 }
5266 }
5267
5268 impl FakePackages {
5269 fn fail_materializations(&self, count: usize) {
5270 self.materialization_failures.store(count, Ordering::SeqCst);
5271 }
5272
5273 fn fail_releases(&self, count: usize) {
5274 self.release_failures.store(count, Ordering::SeqCst);
5275 }
5276 }
5277
5278 #[async_trait]
5279 impl RuntimePackages for FakePackages {
5280 async fn materialize(
5281 &self,
5282 attempt: &RunnerAttempt,
5283 ) -> Result<RunnerVersion, FailureReason> {
5284 self.materializations.fetch_add(1, Ordering::SeqCst);
5285 if self
5286 .materialization_failures
5287 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5288 if left > 0 { Some(left - 1) } else { None }
5289 })
5290 .is_ok()
5291 {
5292 return Err(FailureReason::Other(
5293 "runner package materialization failed transiently".into(),
5294 ));
5295 }
5296 fs::create_dir_all(attempt.runtime_path()).unwrap();
5297 fs::write(attempt.runtime_path().join("runner-package"), b"verified").unwrap();
5298 self.leases.lock().unwrap().insert(attempt.id);
5299 Ok(self.version.clone())
5300 }
5301
5302 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
5303 if self
5304 .release_failures
5305 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5306 if left > 0 { Some(left - 1) } else { None }
5307 })
5308 .is_ok()
5309 {
5310 return Err(FailureReason::Other(
5311 "runner package lease could not be released".into(),
5312 ));
5313 }
5314 self.leases.lock().unwrap().remove(&attempt);
5315 self.releases.fetch_add(1, Ordering::SeqCst);
5316 Ok(())
5317 }
5318
5319 fn prune_obsolete_guarded(
5320 &self,
5321 _authority: PruneAuthority<'_>,
5322 current: &RunnerVersion,
5323 _attempts: &[RunnerAttempt],
5324 ) -> Result<(), FailureReason> {
5325 self.prunes.fetch_add(1, Ordering::SeqCst);
5326 self.prune_currents.lock().unwrap().push(current.clone());
5327 Ok(())
5328 }
5329 }
5330
5331 #[derive(Debug, Default)]
5332 struct FakeProcesses {
5333 alive: AtomicBool,
5334 completed_successfully: AtomicBool,
5335 spawns: AtomicUsize,
5336 spawn_failures: AtomicUsize,
5337 live_spawn_failure: AtomicBool,
5338 terminations: AtomicUsize,
5339 intent: AtomicBool,
5340 intent_failure: AtomicBool,
5341 actions: Mutex<Vec<&'static str>>,
5342 saw_secret: AtomicBool,
5343 }
5344
5345 impl FakeProcesses {
5346 fn fail_spawns(&self, count: usize) {
5347 self.spawn_failures.store(count, Ordering::SeqCst);
5348 }
5349
5350 fn fail_spawn_with_live_child(&self) {
5351 self.live_spawn_failure.store(true, Ordering::SeqCst);
5352 }
5353
5354 fn set_alive(&self, alive: bool) {
5355 self.alive.store(alive, Ordering::SeqCst);
5356 }
5357
5358 fn finish_successfully(&self) {
5359 self.completed_successfully.store(true, Ordering::SeqCst);
5360 self.alive.store(false, Ordering::SeqCst);
5361 }
5362
5363 fn fail_intent(&self) {
5364 self.intent_failure.store(true, Ordering::SeqCst);
5365 }
5366 }
5367
5368 impl ProcessSupervisor for FakeProcesses {
5369 fn spawn(
5370 &self,
5371 attempt: &RunnerAttempt,
5372 config: &EncodedJitConfig,
5373 ) -> Result<u32, ProcessStartFailure> {
5374 self.spawns.fetch_add(1, Ordering::SeqCst);
5375 let handoff = RestrictiveHandoff::create(
5378 attempt.runtime_path(),
5379 SecretString::from(config.expose().to_owned()),
5380 )
5381 .unwrap();
5382 self.saw_secret
5383 .store(config.expose() == JIT, Ordering::SeqCst);
5384 let handoff_path = handoff.path().to_path_buf();
5385 let failing = self
5386 .spawn_failures
5387 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5388 if left > 0 { Some(left - 1) } else { None }
5389 })
5390 .is_ok();
5391 drop(handoff);
5392 assert!(!handoff_path.exists(), "handoff must be absent on return");
5393 if self.live_spawn_failure.swap(false, Ordering::SeqCst) {
5394 self.alive.store(true, Ordering::SeqCst);
5395 return Err(ProcessStartFailure::after_spawn_live(4242));
5396 }
5397 if failing {
5398 return Err(ProcessStartFailure::before_spawn(
5399 FailureReason::ProcessStartFailed,
5400 ));
5401 }
5402 self.alive.store(true, Ordering::SeqCst);
5403 Ok(4242)
5404 }
5405
5406 fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
5407 self.actions.lock().unwrap().push("observe_process");
5408 Ok(self.alive.load(Ordering::SeqCst))
5409 }
5410
5411 fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
5412 Ok(self.alive.load(Ordering::SeqCst).then_some(4242))
5413 }
5414
5415 fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
5416 self.completed_successfully.load(Ordering::SeqCst)
5417 }
5418
5419 fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5420 self.actions.lock().unwrap().push("terminate_intent");
5421 if self.intent_failure.load(Ordering::SeqCst) {
5422 return Err(FailureReason::Other(
5423 "terminate intent directory sync failed".into(),
5424 ));
5425 }
5426 self.intent.store(true, Ordering::SeqCst);
5427 Ok(())
5428 }
5429
5430 fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
5431 self.intent.load(Ordering::SeqCst)
5432 }
5433
5434 fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5435 assert!(
5436 self.intent.load(Ordering::SeqCst),
5437 "the durable intent must exist before signalling"
5438 );
5439 self.actions.lock().unwrap().push("terminate");
5440 self.terminations.fetch_add(1, Ordering::SeqCst);
5441 self.alive.store(false, Ordering::SeqCst);
5442 Ok(())
5443 }
5444 }
5445
5446 #[derive(Debug, Default)]
5447 struct FakeIsolatedProvider {
5448 resources: Mutex<BTreeMap<AttemptId, (EnvironmentIdentity, EnvironmentState)>>,
5449 extra_resources: Mutex<Vec<EnvironmentIdentity>>,
5450 unsupported_host: AtomicBool,
5451 permission_denied: AtomicBool,
5452 incompatible_image: AtomicBool,
5453 prepare_failure: AtomicBool,
5454 recovery_unavailable: AtomicBool,
5455 destroy_deferred: AtomicBool,
5456 handoffs: AtomicUsize,
5457 completed_successfully: AtomicBool,
5458 native_calls: AtomicUsize,
5459 actions: Mutex<Vec<&'static str>>,
5460 }
5461
5462 impl FakeIsolatedProvider {
5463 fn identity(attempt: &RunnerAttempt, host: HostId) -> EnvironmentIdentity {
5464 let AttemptExecution::Isolated {
5465 provider_kind,
5466 resolved_image,
5467 generation,
5468 ..
5469 } = attempt.execution()
5470 else {
5471 panic!("isolated intent required")
5472 };
5473 EnvironmentIdentity::Isolated {
5474 host,
5475 attempt: attempt.id,
5476 provider_kind: *provider_kind,
5477 environment_id: format!("environment-{}", attempt.id),
5478 resolved_image: resolved_image.clone(),
5479 generation: generation.clone(),
5480 }
5481 }
5482
5483 fn resource_count(&self) -> usize {
5484 self.resources.lock().unwrap().len()
5485 }
5486 }
5487
5488 impl ExecutionProvider for FakeIsolatedProvider {
5489 fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
5490 if self.unsupported_host.load(Ordering::SeqCst) {
5491 ProviderCapability::Unsupported
5492 } else if self.permission_denied.load(Ordering::SeqCst) {
5493 ProviderCapability::PermissionDenied
5494 } else if policy.execution_policy().is_native() {
5495 ProviderCapability::Unsupported
5496 } else {
5497 ProviderCapability::Ready
5498 }
5499 }
5500
5501 fn resolve(
5502 &self,
5503 policy: &ScalePolicy,
5504 ) -> Result<Option<ResolvedEnvironment>, FailureReason> {
5505 let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
5506 policy.execution_policy()
5507 else {
5508 return Err(FailureReason::Other("isolated policy required".into()));
5509 };
5510 Ok(Some(ResolvedEnvironment {
5511 provider_kind: Backend::Oci,
5512 image: if self.incompatible_image.load(Ordering::SeqCst) {
5513 ImageReference::new("vm-version:wrong-provider")
5514 .expect("valid but incompatible")
5515 } else {
5516 image.clone()
5517 },
5518 }))
5519 }
5520
5521 fn prepare(
5522 &self,
5523 attempt: &RunnerAttempt,
5524 policy: &ScalePolicy,
5525 ) -> Result<PreparedEnvironment, FailureReason> {
5526 assert_eq!(
5527 attempt.state(),
5528 AttemptState::Preparing,
5529 "intent must be durable before provider effect"
5530 );
5531 self.actions.lock().unwrap().push("prepare");
5532 let identity = Self::identity(attempt, policy.host_id);
5533 self.resources
5534 .lock()
5535 .unwrap()
5536 .insert(attempt.id, (identity.clone(), EnvironmentState::Starting));
5537 if self.prepare_failure.swap(false, Ordering::SeqCst) {
5538 return Err(FailureReason::Other("provider-secret-needle".into()));
5539 }
5540 Ok(PreparedEnvironment::isolated(attempt.id, identity))
5541 }
5542
5543 fn start(
5544 &self,
5545 prepared: PreparedEnvironment,
5546 attempt: &RunnerAttempt,
5547 handoff: OneTimeJitHandoff<'_>,
5548 ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
5549 assert_eq!(prepared.attempt, attempt.id);
5550 assert_eq!(attempt.state(), AttemptState::JitReceived);
5551 assert_eq!(handoff.consume().expose(), JIT);
5552 self.handoffs.fetch_add(1, Ordering::SeqCst);
5553 self.actions.lock().unwrap().push("start");
5554 let mut resources = self.resources.lock().unwrap();
5555 let (identity, state) = resources.get_mut(&attempt.id).expect("prepared resource");
5556 *state = EnvironmentState::Running;
5557 Ok(identity.clone())
5558 }
5559
5560 fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
5561 self.actions.lock().unwrap().push("inspect");
5562 Ok(self
5563 .resources
5564 .lock()
5565 .unwrap()
5566 .get(&attempt.id)
5567 .map_or(EnvironmentState::Missing, |(_, state)| *state))
5568 }
5569
5570 fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
5571 if self.recovery_unavailable.load(Ordering::SeqCst) {
5572 return Err(FailureReason::Other(
5573 "provider discovery unavailable".into(),
5574 ));
5575 }
5576 self.inspect(attempt)
5577 }
5578
5579 fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
5580 Ok(self
5581 .resources
5582 .lock()
5583 .unwrap()
5584 .get(&attempt.id)
5585 .is_some_and(|(identity, _)| {
5586 identity_matches_intent(attempt_host(identity), attempt, identity)
5587 }))
5588 }
5589
5590 fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
5591 assert!(self.owns(attempt)?);
5592 self.actions.lock().unwrap().push("stop");
5593 if let Some((_, state)) = self.resources.lock().unwrap().get_mut(&attempt.id) {
5594 *state = EnvironmentState::Exited;
5595 }
5596 Ok(ProviderStop::Stopped)
5597 }
5598
5599 fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
5600 assert!(self.owns(attempt)?);
5601 self.actions.lock().unwrap().push("destroy");
5602 if self.destroy_deferred.load(Ordering::SeqCst) {
5603 return Ok(ProviderDestroy::Deferred("provider busy"));
5604 }
5605 self.resources.lock().unwrap().remove(&attempt.id);
5606 Ok(ProviderDestroy::Destroyed)
5607 }
5608
5609 fn enumerate_owned(&self, host: HostId) -> Vec<EnvironmentIdentity> {
5610 let mut owned = self
5611 .resources
5612 .lock()
5613 .unwrap()
5614 .values()
5615 .filter(|(identity, _)| attempt_host(identity) == host)
5616 .map(|(identity, _)| identity.clone())
5617 .collect::<Vec<_>>();
5618 owned.extend(
5619 self.extra_resources
5620 .lock()
5621 .unwrap()
5622 .iter()
5623 .filter(|identity| attempt_host(identity) == host)
5624 .cloned(),
5625 );
5626 owned
5627 }
5628
5629 fn diagnostics(&self, _attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
5630 vec![ProviderDiagnostic::CleanupDeferred; 40]
5631 }
5632
5633 fn spawn(
5634 &self,
5635 _attempt: &RunnerAttempt,
5636 _config: &EncodedJitConfig,
5637 ) -> Result<u32, ProcessStartFailure> {
5638 self.native_calls.fetch_add(1, Ordering::SeqCst);
5639 panic!("isolated provider must never spawn natively")
5640 }
5641 fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
5642 panic!("native fallback")
5643 }
5644 fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
5645 panic!("native fallback")
5646 }
5647 fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
5648 self.completed_successfully.load(Ordering::SeqCst)
5649 }
5650 fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5651 panic!("native fallback")
5652 }
5653 fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
5654 false
5655 }
5656 fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5657 panic!("native fallback")
5658 }
5659 }
5660
5661 fn attempt_host(identity: &EnvironmentIdentity) -> HostId {
5662 let EnvironmentIdentity::Isolated { host, .. } = identity else {
5663 panic!("isolated identity")
5664 };
5665 *host
5666 }
5667
5668 #[derive(Debug, Default)]
5669 struct FakeDemand {
5670 answers: Mutex<VecDeque<bool>>,
5671 }
5672
5673 impl FakeDemand {
5674 fn answering(answers: impl IntoIterator<Item = bool>) -> Self {
5675 Self {
5676 answers: Mutex::new(answers.into_iter().collect()),
5677 }
5678 }
5679 }
5680
5681 #[async_trait]
5682 impl DemandPersistence for FakeDemand {
5683 async fn persists(&self, _policy: PolicyId) -> bool {
5684 self.answers.lock().unwrap().pop_front().unwrap_or(true)
5685 }
5686 }
5687
5688 #[derive(Debug, Default)]
5689 struct FakeDelay(Mutex<Vec<Duration>>);
5690
5691 #[async_trait]
5692 impl RetryDelay for FakeDelay {
5693 async fn wait(&self, duration: Duration) {
5694 self.0.lock().unwrap().push(duration);
5695 }
5696 }
5697
5698 #[tokio::test]
5699 async fn live_capacity_reads_the_updated_sqlite_host_and_rejects_a_missing_host() {
5700 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5701 let path = harness._root.path().join("live-capacity.sqlite3");
5702 harness.store = Arc::new(SqliteStore::open(&path).unwrap());
5703 harness.store.put_host(&harness.host).unwrap();
5704 harness.launcher = harness.restart();
5705 assert_eq!(
5706 harness.launcher.host_capacity(&harness.host).await.unwrap(),
5707 harness.host.host_capacity
5708 );
5709 let writer = SqliteStore::open(&path).unwrap();
5712 let mut updated = harness.host.clone();
5713 updated.host_capacity = std::num::NonZeroU16::new(12).unwrap();
5714 writer.put_host(&updated).unwrap();
5715 assert_eq!(
5716 harness
5717 .launcher
5718 .host_capacity(&harness.host)
5719 .await
5720 .unwrap()
5721 .get(),
5722 12
5723 );
5724
5725 updated.host_capacity = std::num::NonZeroU16::new(2).unwrap();
5726 writer.put_host(&updated).unwrap();
5727 assert_eq!(
5728 harness
5729 .launcher
5730 .host_capacity(&harness.host)
5731 .await
5732 .unwrap()
5733 .get(),
5734 2
5735 );
5736
5737 let mut missing = harness.host.clone();
5738 missing.id = HostId::from_u128(999);
5739 assert_eq!(
5740 harness.launcher.host_capacity(&missing).await,
5741 Err(crate::reconcile::HostCapacityError::MissingHost)
5742 );
5743 }
5744
5745 struct Harness {
5746 _root: tempfile::TempDir,
5747 app_paths: runner_manager_platform::paths::AppPaths,
5748 launcher: LifecycleLauncher,
5749 demand: Arc<dyn DemandPersistence>,
5750 store: Arc<SqliteStore>,
5751 github: Arc<FakeGithubLifecycle>,
5752 packages: Arc<FakePackages>,
5753 processes: Arc<FakeProcesses>,
5754 clock: Arc<FakeClock>,
5755 events: Arc<AttemptEventLog>,
5756 reconcile_events: Arc<crate::reconcile::EventLog>,
5757 delay: Arc<FakeDelay>,
5758 host: runner_manager_domain::model::Host,
5759 policy: ScalePolicy,
5760 allocation_lock: InProcessAllocationLock,
5761 workspace_root: Option<LocalAbsolutePath>,
5763 }
5764
5765 impl Harness {
5766 fn new(github: FakeGithubLifecycle, demand: Arc<dyn DemandPersistence>) -> Self {
5767 let root = tempfile::tempdir().unwrap();
5768 let paths = runner_manager_platform::paths::AppPaths::rooted_at(root.path());
5769 paths.create_all().unwrap();
5770 let policy = fixtures::policy()
5771 .repository("octo/repo")
5772 .autoscale("home", 2)
5773 .active()
5774 .build();
5775 let host_root = root.path().join("host-root");
5781 fs::create_dir_all(&host_root).unwrap();
5782 let mut host = fixtures::host().build();
5783 host.runner_root_override = Some(
5784 LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
5785 .expect("a local absolute host root"),
5786 );
5787 let store = Arc::new(SqliteStore::open_in_memory().unwrap());
5788 store.put_host(&host).unwrap();
5789 let github = Arc::new(github);
5790 let packages = Arc::new(FakePackages::default());
5791 let processes = Arc::new(FakeProcesses::default());
5792 let clock = Arc::new(FakeClock::default());
5793 let events = Arc::new(AttemptEventLog::default());
5794 let reconcile_events = Arc::new(crate::reconcile::EventLog::new());
5795 let delay = Arc::new(FakeDelay::default());
5796 let ports = LifecyclePorts {
5797 store: Arc::clone(&store) as Arc<dyn Store>,
5798 github: Arc::clone(&github) as Arc<dyn LifecycleGithub>,
5799 packages: Arc::clone(&packages) as Arc<dyn RuntimePackages>,
5800 processes: Arc::clone(&processes) as Arc<dyn ProcessSupervisor>,
5801 clock: Arc::clone(&clock) as Arc<dyn Clock>,
5802 demand: Arc::clone(&demand),
5803 delay: Arc::clone(&delay) as Arc<dyn RetryDelay>,
5804 events: Arc::clone(&events) as Arc<dyn AttemptEventSink>,
5805 reconcile_events: Arc::clone(&reconcile_events) as Arc<dyn EventSink>,
5806 };
5807 let launcher = Self::launcher_over(policy.host_id, &paths, ports);
5808 Self {
5809 _root: root,
5810 app_paths: paths,
5811 launcher,
5812 demand,
5813 store,
5814 github,
5815 packages,
5816 processes,
5817 clock,
5818 events,
5819 reconcile_events,
5820 delay,
5821 host,
5822 policy,
5823 allocation_lock: InProcessAllocationLock::new(),
5824 workspace_root: None,
5825 }
5826 }
5827
5828 fn with_host_runner_root(mut self) -> Self {
5835 let host_root = self.host_root();
5836 fs::create_dir_all(&host_root).unwrap();
5837 self.host.runner_root_override = Some(
5838 LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
5839 .expect("a local absolute host root"),
5840 );
5841 self.store.put_host(&self.host).unwrap();
5842 self
5843 }
5844
5845 fn with_persistent_workspace(mut self, capacity: u16) -> Self {
5847 self = self.with_host_runner_root();
5848 let root = self._root.path().join("persist");
5849 let root = LocalAbsolutePath::new(root.to_str().expect("a UTF-8 temporary path"))
5850 .expect("a local absolute workspace root");
5851 self.policy = fixtures::policy()
5852 .repository("octo/repo")
5853 .autoscale("home", capacity)
5854 .active()
5855 .build();
5856 self.policy
5857 .set_workspace_policy(
5858 WorkspacePolicy::persistent(root.clone(), TargetScope::Repository)
5859 .expect("a repository may be persistent"),
5860 )
5861 .expect("a repository may be persistent");
5862 self.workspace_root = Some(root);
5863 self.store.insert_policy(&self.policy).unwrap();
5866 self
5867 }
5868
5869 fn workspace_root(&self) -> &LocalAbsolutePath {
5870 self.workspace_root
5871 .as_ref()
5872 .expect("this harness configured a persistent workspace")
5873 }
5874
5875 fn slot_path(&self, slot: u16) -> PathBuf {
5876 self.workspace_root().as_path().join(format!("s{slot}"))
5877 }
5878
5879 fn host_root(&self) -> PathBuf {
5880 self._root.path().join("host-root")
5881 }
5882
5883 fn attempt(&self, id: AttemptId) -> RunnerAttempt {
5884 self.store
5885 .attempt(id)
5886 .unwrap()
5887 .expect("the attempt is journalled")
5888 }
5889
5890 fn conclude(&self, id: AttemptId) -> RunnerAttempt {
5892 let mut attempt = self.attempt(id);
5893 attempt
5894 .conclude(
5895 AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly),
5896 self.clock.now(),
5897 )
5898 .unwrap();
5899 self.store.record_attempt(&attempt).unwrap();
5900 attempt
5901 }
5902
5903 async fn cleanup_retaining_work(&self, id: AttemptId) {
5904 self.conclude(id);
5905 self.launcher
5906 .clean(id)
5907 .await
5908 .expect("the slot is scrubbed and the lease released");
5909 }
5910
5911 fn launcher_over(
5914 host: HostId,
5915 paths: &runner_manager_platform::paths::AppPaths,
5916 ports: LifecyclePorts,
5917 ) -> LifecycleLauncher {
5918 LifecycleLauncher::new(
5919 host,
5920 paths.clone(),
5921 paths.logs_dir(),
5922 1,
5923 RecoveryTimeouts::new(
5924 Elapsed::seconds(10),
5925 Elapsed::seconds(10),
5926 Elapsed::seconds(10),
5927 ),
5928 RetryPolicy::bounded(3, Duration::from_millis(10), Duration::from_millis(25)),
5929 ports,
5930 )
5931 }
5932
5933 fn restart(&self) -> LifecycleLauncher {
5936 Self::launcher_over(
5937 self.policy.host_id,
5938 &self.app_paths,
5939 LifecyclePorts {
5940 store: Arc::clone(&self.store) as Arc<dyn Store>,
5941 github: Arc::clone(&self.github) as Arc<dyn LifecycleGithub>,
5942 packages: Arc::clone(&self.packages) as Arc<dyn RuntimePackages>,
5943 processes: Arc::clone(&self.processes) as Arc<dyn ProcessSupervisor>,
5944 clock: Arc::clone(&self.clock) as Arc<dyn Clock>,
5945 demand: Arc::clone(&self.demand),
5946 delay: Arc::clone(&self.delay) as Arc<dyn RetryDelay>,
5947 events: Arc::clone(&self.events) as Arc<dyn AttemptEventSink>,
5948 reconcile_events: Arc::clone(&self.reconcile_events) as Arc<dyn EventSink>,
5949 },
5950 )
5951 }
5952
5953 async fn ready(&self) {
5954 self.launcher
5955 .recover_startup(std::slice::from_ref(&self.policy))
5956 .await
5957 .unwrap();
5958 }
5959
5960 async fn launch(&self) -> RunnerAttempt {
5961 self.launch_result().await.unwrap()
5962 }
5963
5964 async fn launch_result(&self) -> Result<RunnerAttempt, LaunchFailure> {
5965 self.launch_after_waiting(Duration::ZERO).await
5966 }
5967
5968 async fn launch_after_waiting(
5969 &self,
5970 lock_wait: Duration,
5971 ) -> Result<RunnerAttempt, LaunchFailure> {
5972 let guard = self.allocation_lock.acquire().await.unwrap();
5973 self.launcher
5974 .launch(LaunchRequest {
5975 host: &self.host,
5976 policy: &self.policy,
5977 allocation_guard: &guard,
5978 lock_wait,
5979 })
5980 .await
5981 }
5982
5983 fn only_attempt(&self) -> RunnerAttempt {
5984 self.store.attempts().unwrap().into_iter().next().unwrap()
5985 }
5986 }
5987
5988 fn isolated_harness() -> Harness {
5989 use runner_manager_domain::execution::{ExecutionPolicy, ResourceLimits};
5990 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5991 .with_host_runner_root();
5992 let image =
5993 ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
5994 .expect("pinned image");
5995 harness
5996 .policy
5997 .set_execution_policy(ExecutionPolicy::Isolated {
5998 backend: Backend::Oci,
5999 image,
6000 resources: ResourceLimits {
6001 cpu_millis: 1000,
6002 memory_mib: 1024,
6003 disk_mib: 4096,
6004 },
6005 })
6006 .expect("isolated policy");
6007 harness.github.watch_journal(Arc::clone(&harness.store));
6008 harness
6009 }
6010
6011 fn launcher_with_isolated(
6012 harness: &Harness,
6013 provider: Arc<FakeIsolatedProvider>,
6014 ) -> LifecycleLauncher {
6015 Harness::launcher_over(
6016 harness.policy.host_id,
6017 &harness.app_paths,
6018 LifecyclePorts {
6019 store: Arc::clone(&harness.store) as Arc<dyn Store>,
6020 github: Arc::clone(&harness.github) as Arc<dyn LifecycleGithub>,
6021 packages: Arc::clone(&harness.packages) as Arc<dyn RuntimePackages>,
6022 processes: provider as Arc<dyn ExecutionProvider>,
6023 clock: Arc::clone(&harness.clock) as Arc<dyn Clock>,
6024 demand: Arc::clone(&harness.demand),
6025 delay: Arc::clone(&harness.delay) as Arc<dyn RetryDelay>,
6026 events: Arc::clone(&harness.events) as Arc<dyn AttemptEventSink>,
6027 reconcile_events: Arc::clone(&harness.reconcile_events) as Arc<dyn EventSink>,
6028 },
6029 )
6030 }
6031
6032 #[tokio::test]
6033 async fn isolated_policy_and_journal_never_enter_native_provider_or_jit() {
6034 use runner_manager_domain::execution::{
6035 AttemptExecution, Backend, ExecutionPolicy, ImageReference, ResourceLimits,
6036 };
6037
6038 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6039 .with_host_runner_root();
6040 let image =
6041 ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
6042 .expect("pinned image");
6043 harness
6044 .policy
6045 .set_execution_policy(ExecutionPolicy::Isolated {
6046 backend: Backend::Oci,
6047 image: image.clone(),
6048 resources: ResourceLimits {
6049 cpu_millis: 1000,
6050 memory_mib: 1024,
6051 disk_mib: 4096,
6052 },
6053 })
6054 .expect("valid isolated profile");
6055
6056 harness.ready().await;
6057 assert_eq!(
6058 harness.processes.probe(&harness.policy),
6059 ProviderCapability::Unsupported
6060 );
6061 let failure = harness
6062 .launch_result()
6063 .await
6064 .expect_err("provider is unavailable");
6065 assert_eq!(
6066 failure.reason,
6067 FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
6068 );
6069
6070 let runtime = harness.host_root().join("isolated-owned-environment");
6071 fs::create_dir_all(&runtime).expect("provider resource fixture");
6072 fs::write(runtime.join("owned-marker"), "non-secret").expect("fixture marker");
6073 let mut attempt = RunnerAttempt::allocate(
6074 AttemptId::from_u128(0x123),
6075 harness.policy.id,
6076 runtime.clone(),
6077 harness.clock.now(),
6078 );
6079 attempt
6080 .allocate_execution(AttemptExecution::Isolated {
6081 provider_kind: Backend::Oci,
6082 environment_id: None,
6083 resolved_image: image,
6084 generation: "generation-123".into(),
6085 })
6086 .expect("isolated intent");
6087 attempt
6088 .prepared_environment("environment-123".into())
6089 .expect("prepared identity");
6090 harness
6091 .store
6092 .record_attempt(&attempt)
6093 .expect("durable isolated journal");
6094
6095 harness
6096 .launcher
6097 .recover_startup(std::slice::from_ref(&harness.policy))
6098 .await
6099 .expect("unsupported isolated cleanup is profile-local");
6100 let failure = harness
6101 .launch_result()
6102 .await
6103 .expect_err("provider unavailable for this policy");
6104 assert_eq!(
6105 failure.reason,
6106 FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
6107 );
6108 harness
6109 .launcher
6110 .supervise(&harness.policy)
6111 .await
6112 .expect("unavailable attempt is deferred");
6113 assert!(harness.launcher.clean(attempt.id).await.is_err());
6114 assert_eq!(
6115 harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6116 AttemptState::CleanupDeferred
6117 );
6118 assert!(matches!(
6119 harness.launcher.recover_startup(&[]).await,
6120 Err(LifecycleError::RecoveryIncomplete)
6121 ));
6122 assert!(
6123 runtime.join("owned-marker").is_file(),
6124 "native cleanup touched the isolated environment"
6125 );
6126 assert!(
6127 harness.processes.actions.lock().unwrap().is_empty(),
6128 "native recovery inspected an isolated identity"
6129 );
6130 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
6131 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6132 assert_eq!(harness.packages.materializations.load(Ordering::SeqCst), 0);
6133 }
6134
6135 #[tokio::test]
6136 async fn isolated_prepare_is_durable_before_jit_and_start_uses_one_handoff() {
6137 let harness = isolated_harness();
6138 let provider = Arc::new(FakeIsolatedProvider::default());
6139 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6140 launcher
6141 .recover_startup(std::slice::from_ref(&harness.policy))
6142 .await
6143 .expect("ready");
6144 let guard = harness.allocation_lock.acquire().await.unwrap();
6145 let attempt = launcher
6146 .launch(LaunchRequest {
6147 host: &harness.host,
6148 policy: &harness.policy,
6149 allocation_guard: &guard,
6150 lock_wait: Duration::ZERO,
6151 })
6152 .await
6153 .expect("isolated launch");
6154 assert_eq!(attempt.state(), AttemptState::Starting);
6155 assert_eq!(provider.resource_count(), 1);
6156 assert_eq!(provider.handoffs.load(Ordering::SeqCst), 1);
6157 assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6158 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
6159 let fact = harness
6160 .github
6161 .registration_facts()
6162 .pop()
6163 .expect("JIT registration");
6164 assert_eq!(
6165 fact.journal_states,
6166 vec![AttemptState::Prepared],
6167 "provider readiness preceded GitHub JIT"
6168 );
6169 assert_eq!(
6170 harness
6171 .events
6172 .events()
6173 .into_iter()
6174 .filter_map(|event| match event {
6175 AttemptEvent::State { state, .. } => Some(state),
6176 _ => None,
6177 })
6178 .collect::<Vec<_>>(),
6179 vec![
6180 AttemptState::Allocated,
6181 AttemptState::Preparing,
6182 AttemptState::Prepared,
6183 AttemptState::JitReceived,
6184 AttemptState::Starting
6185 ]
6186 );
6187 let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
6188 restarted
6189 .recover_startup(std::slice::from_ref(&harness.policy))
6190 .await
6191 .expect("adopt live sandbox");
6192 assert_eq!(provider.resource_count(), 1);
6193 assert_eq!(
6194 harness.github.registrations.load(Ordering::SeqCst),
6195 1,
6196 "restart cannot issue duplicate JIT"
6197 );
6198 harness
6199 .github
6200 .observe(GithubRunnerObservation::Registered { busy: true });
6201 restarted
6202 .supervise(&harness.policy)
6203 .await
6204 .expect("job observed");
6205 assert_eq!(
6206 harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6207 AttemptState::Busy
6208 );
6209 provider
6210 .resources
6211 .lock()
6212 .unwrap()
6213 .get_mut(&attempt.id)
6214 .unwrap()
6215 .1 = EnvironmentState::Exited;
6216 harness
6217 .github
6218 .observe(GithubRunnerObservation::NotRegistered);
6219 restarted
6220 .supervise(&harness.policy)
6221 .await
6222 .expect("terminal cleanup");
6223 assert_eq!(provider.resource_count(), 0);
6224 assert_eq!(
6225 harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6226 AttemptState::Cleaned
6227 );
6228 }
6229
6230 #[tokio::test]
6231 async fn attached_isolated_job_exit_is_recorded_as_completed() {
6232 let harness = isolated_harness();
6233 let provider = Arc::new(FakeIsolatedProvider::default());
6234 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6235 launcher
6236 .recover_startup(std::slice::from_ref(&harness.policy))
6237 .await
6238 .unwrap();
6239 let guard = harness.allocation_lock.acquire().await.unwrap();
6240 let attempt = launcher
6241 .launch(LaunchRequest {
6242 host: &harness.host,
6243 policy: &harness.policy,
6244 allocation_guard: &guard,
6245 lock_wait: Duration::ZERO,
6246 })
6247 .await
6248 .unwrap();
6249 harness
6250 .github
6251 .observe(GithubRunnerObservation::Registered { busy: true });
6252 launcher.supervise(&harness.policy).await.unwrap();
6253 provider
6254 .resources
6255 .lock()
6256 .unwrap()
6257 .get_mut(&attempt.id)
6258 .unwrap()
6259 .1 = EnvironmentState::Exited;
6260 provider
6261 .completed_successfully
6262 .store(true, Ordering::SeqCst);
6263 harness
6264 .github
6265 .observe(GithubRunnerObservation::NotRegistered);
6266 launcher.supervise(&harness.policy).await.unwrap();
6267 let cleaned = harness.store.attempt(attempt.id).unwrap().unwrap();
6268 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
6269 assert_eq!(cleaned.state(), AttemptState::Cleaned);
6270 }
6271
6272 #[tokio::test]
6273 async fn isolated_prepare_failure_stays_local_and_crash_gap_recovers_owned_resource() {
6274 let harness = isolated_harness();
6275 let provider = Arc::new(FakeIsolatedProvider::default());
6276 provider.prepare_failure.store(true, Ordering::SeqCst);
6277 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6278 launcher
6279 .recover_startup(std::slice::from_ref(&harness.policy))
6280 .await
6281 .expect("ready");
6282 let guard = harness.allocation_lock.acquire().await.unwrap();
6283 let failure = launcher
6284 .launch(LaunchRequest {
6285 host: &harness.host,
6286 policy: &harness.policy,
6287 allocation_guard: &guard,
6288 lock_wait: Duration::ZERO,
6289 })
6290 .await
6291 .expect_err("prepare failed after creating resource");
6292 assert_eq!(
6293 failure.reason,
6294 FailureReason::IsolationProvider(IsolationProviderFailure::RuntimeOperationFailed)
6295 );
6296 assert_eq!(provider.resource_count(), 1);
6297 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6298 assert!(
6299 !harness
6300 .store
6301 .dump_text()
6302 .unwrap()
6303 .contains("provider-secret-needle")
6304 );
6305 let id = harness.store.attempts().unwrap()[0].id;
6306 assert_eq!(
6307 harness.store.attempt(id).unwrap().unwrap().outcome(),
6308 Some(&AttemptOutcome::failed(FailureReason::IsolationProvider(
6309 IsolationProviderFailure::RuntimeOperationFailed
6310 )))
6311 );
6312 let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
6313 restarted
6314 .recover_startup(std::slice::from_ref(&harness.policy))
6315 .await
6316 .expect("profile-local recovery");
6317 assert_eq!(
6318 provider.resource_count(),
6319 0,
6320 "the exact owned resource was destroyed once"
6321 );
6322 assert_eq!(
6323 harness.store.attempt(id).unwrap().unwrap().state(),
6324 AttemptState::Cleaned
6325 );
6326 let diagnostic =
6327 fs::read_to_string(restarted.diagnostics_root.join(format!("{id}.log"))).unwrap();
6328 assert!(diagnostic.contains("reason=isolation_runtime_operation_failed"));
6329 assert!(!diagnostic.contains("provider-secret-needle"));
6330 assert_eq!(
6331 provider
6332 .actions
6333 .lock()
6334 .unwrap()
6335 .iter()
6336 .filter(|action| **action == "destroy")
6337 .count(),
6338 1
6339 );
6340 assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6341 }
6342
6343 #[test]
6344 fn isolation_capability_and_adapter_failures_keep_closed_categories() {
6345 let cases = [
6346 (
6347 ProviderCapability::NotInstalled,
6348 IsolationProviderFailure::NotInstalled,
6349 ),
6350 (
6351 ProviderCapability::PermissionDenied,
6352 IsolationProviderFailure::PermissionDenied,
6353 ),
6354 (
6355 ProviderCapability::ImageUnavailableOrIncompatible,
6356 IsolationProviderFailure::ImageUnavailableOrIncompatible,
6357 ),
6358 (
6359 ProviderCapability::DiskQuotaUnavailable,
6360 IsolationProviderFailure::DiskQuotaUnavailable,
6361 ),
6362 (
6363 ProviderCapability::Degraded,
6364 IsolationProviderFailure::Degraded,
6365 ),
6366 ];
6367 for (capability, category) in cases {
6368 assert_eq!(
6369 capability.refusal(),
6370 FailureReason::IsolationProvider(category)
6371 );
6372 }
6373 let raw = FailureReason::Other("ghp_secret_from_provider".into());
6374 let safe = safe_isolation_reason(raw, IsolationProviderFailure::RuntimeOperationFailed);
6375 assert_eq!(
6376 safe,
6377 FailureReason::IsolationProvider(IsolationProviderFailure::RuntimeOperationFailed)
6378 );
6379 assert!(!safe.to_string().contains("ghp_secret"));
6380 assert_eq!(
6381 safe_isolation_reason(
6382 FailureReason::IsolationProvider(IsolationProviderFailure::PermissionDenied),
6383 IsolationProviderFailure::RuntimeOperationFailed,
6384 ),
6385 FailureReason::IsolationProvider(IsolationProviderFailure::PermissionDenied)
6386 );
6387 }
6388
6389 #[tokio::test]
6390 async fn isolated_permission_and_image_failures_stop_before_jit_without_native_fallback() {
6391 for image_failure in [false, true] {
6392 let harness = isolated_harness();
6393 let provider = Arc::new(FakeIsolatedProvider::default());
6394 provider
6395 .permission_denied
6396 .store(!image_failure, Ordering::SeqCst);
6397 provider
6398 .incompatible_image
6399 .store(image_failure, Ordering::SeqCst);
6400 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6401 launcher
6402 .recover_startup(std::slice::from_ref(&harness.policy))
6403 .await
6404 .unwrap();
6405 let guard = harness.allocation_lock.acquire().await.unwrap();
6406 let error = launcher
6407 .launch(LaunchRequest {
6408 host: &harness.host,
6409 policy: &harness.policy,
6410 allocation_guard: &guard,
6411 lock_wait: Duration::ZERO,
6412 })
6413 .await
6414 .expect_err("pre-JIT refusal");
6415 let expected = if image_failure {
6416 "resolved image is incompatible"
6417 } else {
6418 "permission denied"
6419 };
6420 assert!(error.reason.to_string().contains(expected));
6421 assert!(
6422 harness.store.attempts().unwrap().is_empty(),
6423 "no allocation was journalled"
6424 );
6425 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6426 assert_eq!(provider.handoffs.load(Ordering::SeqCst), 0);
6427 assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6428 }
6429 }
6430
6431 #[tokio::test]
6432 async fn unsupported_host_stops_before_jit_without_allocation_or_native_fallback() {
6433 let harness = isolated_harness();
6434 let provider = Arc::new(FakeIsolatedProvider::default());
6435 provider.unsupported_host.store(true, Ordering::SeqCst);
6436 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6437 launcher
6438 .recover_startup(std::slice::from_ref(&harness.policy))
6439 .await
6440 .unwrap();
6441 let guard = harness.allocation_lock.acquire().await.unwrap();
6442 let error = launcher
6443 .launch(LaunchRequest {
6444 host: &harness.host,
6445 policy: &harness.policy,
6446 allocation_guard: &guard,
6447 lock_wait: Duration::ZERO,
6448 })
6449 .await
6450 .expect_err("unsupported host must be refused before JIT");
6451
6452 assert_eq!(
6453 error.reason,
6454 FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
6455 );
6456 assert!(harness.store.attempts().unwrap().is_empty());
6457 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6458 assert_eq!(provider.handoffs.load(Ordering::SeqCst), 0);
6459 assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6460 assert!(provider.actions.lock().unwrap().is_empty());
6461 }
6462
6463 #[tokio::test]
6464 async fn no_journalled_environment_id_waits_for_provider_absence_proof() {
6465 let harness = isolated_harness();
6466 let provider = Arc::new(FakeIsolatedProvider::default());
6467 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6468 let runtime = harness.host_root().join("prepare-without-identity");
6469 fs::create_dir_all(&runtime).unwrap();
6470 let mut attempt = RunnerAttempt::allocate(
6471 AttemptId::from_u128(0x9191),
6472 harness.policy.id,
6473 runtime,
6474 harness.clock.now(),
6475 );
6476 let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6477 harness.policy.execution_policy()
6478 else {
6479 unreachable!()
6480 };
6481 attempt
6482 .allocate_execution(AttemptExecution::Isolated {
6483 provider_kind: Backend::Oci,
6484 environment_id: None,
6485 resolved_image: image.clone(),
6486 generation: "prepare-generation".into(),
6487 })
6488 .unwrap();
6489 attempt.begin_prepare(harness.clock.now()).unwrap();
6490 attempt
6491 .conclude(
6492 AttemptOutcome::failed(FailureReason::Other("prepare interrupted".into())),
6493 harness.clock.now(),
6494 )
6495 .unwrap();
6496 harness.store.record_attempt(&attempt).unwrap();
6497
6498 provider.recovery_unavailable.store(true, Ordering::SeqCst);
6499 launcher
6500 .clean(attempt.id)
6501 .await
6502 .expect_err("absence unproven");
6503 let deferred = harness.store.attempt(attempt.id).unwrap().unwrap();
6504 assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
6505 assert!(deferred.counts_against_capacity());
6506
6507 provider.recovery_unavailable.store(false, Ordering::SeqCst);
6508 launcher.clean(attempt.id).await.expect("absence proven");
6509 assert_eq!(
6510 harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6511 AttemptState::Cleaned
6512 );
6513 }
6514
6515 #[tokio::test]
6516 async fn isolated_terminal_capacity_waits_for_confirmed_absence_and_generation_matches() {
6517 let harness = isolated_harness();
6518 let provider = Arc::new(FakeIsolatedProvider::default());
6519 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6520 launcher
6521 .recover_startup(std::slice::from_ref(&harness.policy))
6522 .await
6523 .expect("ready");
6524 let guard = harness.allocation_lock.acquire().await.unwrap();
6525 let mut attempt = launcher
6526 .launch(LaunchRequest {
6527 host: &harness.host,
6528 policy: &harness.policy,
6529 allocation_guard: &guard,
6530 lock_wait: Duration::ZERO,
6531 })
6532 .await
6533 .expect("launch");
6534 attempt
6535 .conclude(
6536 AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6537 harness.clock.now(),
6538 )
6539 .unwrap();
6540 harness.store.record_attempt(&attempt).unwrap();
6541 provider.destroy_deferred.store(true, Ordering::SeqCst);
6542 launcher
6543 .clean(attempt.id)
6544 .await
6545 .expect_err("provider deferred destroy");
6546 let deferred = harness.store.attempt(attempt.id).unwrap().unwrap();
6547 assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
6548 assert!(deferred.counts_against_capacity());
6549 assert_eq!(provider.resource_count(), 1);
6550 provider.destroy_deferred.store(false, Ordering::SeqCst);
6551 let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
6552 restarted
6553 .recover_startup(std::slice::from_ref(&harness.policy))
6554 .await
6555 .expect("retry cleanup");
6556 assert_eq!(provider.resource_count(), 0);
6557 assert!(
6558 !harness
6559 .store
6560 .attempt(attempt.id)
6561 .unwrap()
6562 .unwrap()
6563 .counts_against_capacity()
6564 );
6565
6566 let second_id = AttemptId::from_u128(0x9999);
6568 let second_runtime = harness.host_root().join("generation-mismatch");
6569 fs::create_dir_all(&second_runtime).unwrap();
6570 let mut terminal = RunnerAttempt::allocate(
6571 second_id,
6572 harness.policy.id,
6573 second_runtime,
6574 harness.clock.now(),
6575 );
6576 let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6577 harness.policy.execution_policy()
6578 else {
6579 unreachable!()
6580 };
6581 terminal
6582 .allocate_execution(AttemptExecution::Isolated {
6583 provider_kind: Backend::Oci,
6584 environment_id: None,
6585 resolved_image: image.clone(),
6586 generation: "expected-generation".into(),
6587 })
6588 .unwrap();
6589 let mut stranger = FakeIsolatedProvider::identity(&terminal, harness.policy.host_id);
6590 let EnvironmentIdentity::Isolated { environment_id, .. } = &stranger else {
6591 unreachable!()
6592 };
6593 terminal.begin_prepare(harness.clock.now()).unwrap();
6594 terminal
6595 .prepared_environment(environment_id.clone())
6596 .unwrap();
6597 terminal.mark_prepared(harness.clock.now()).unwrap();
6598 terminal.jit_received(harness.clock.now()).unwrap();
6599 terminal.started_isolated(harness.clock.now()).unwrap();
6600 terminal
6601 .conclude(
6602 AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6603 harness.clock.now(),
6604 )
6605 .unwrap();
6606 harness.store.record_attempt(&terminal).unwrap();
6607 let EnvironmentIdentity::Isolated { generation, .. } = &mut stranger else {
6608 unreachable!()
6609 };
6610 *generation = "another-generation".into();
6611 provider
6612 .resources
6613 .lock()
6614 .unwrap()
6615 .insert(second_id, (stranger, EnvironmentState::Running));
6616 restarted
6617 .clean(terminal.id)
6618 .await
6619 .expect_err("ownership mismatch quarantined");
6620 assert_eq!(provider.resource_count(), 1);
6621 assert_eq!(
6622 harness.store.attempt(terminal.id).unwrap().unwrap().state(),
6623 AttemptState::CleanupDeferred
6624 );
6625 assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6626 }
6627
6628 #[tokio::test]
6629 async fn isolated_restart_at_each_transition_adopts_or_destroys_one_resource() {
6630 for stage in [
6631 AttemptState::Allocated,
6632 AttemptState::Preparing,
6633 AttemptState::Prepared,
6634 AttemptState::JitReceived,
6635 AttemptState::Starting,
6636 AttemptState::Idle,
6637 AttemptState::Busy,
6638 AttemptState::Finished,
6639 AttemptState::Failed,
6640 AttemptState::Orphaned,
6641 AttemptState::Destroying,
6642 AttemptState::CleanupDeferred,
6643 ] {
6644 let harness = isolated_harness();
6645 let provider = Arc::new(FakeIsolatedProvider::default());
6646 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6647 let id = AttemptId::from_u128(0x1000 + stage as u128);
6648 let runtime = harness.host_root().join(format!("phase-{stage}"));
6649 fs::create_dir_all(&runtime).unwrap();
6650 let mut attempt =
6651 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
6652 let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6653 harness.policy.execution_policy()
6654 else {
6655 unreachable!()
6656 };
6657 attempt
6658 .allocate_execution(AttemptExecution::Isolated {
6659 provider_kind: Backend::Oci,
6660 environment_id: None,
6661 resolved_image: image.clone(),
6662 generation: format!("generation-{stage}"),
6663 })
6664 .unwrap();
6665 if stage != AttemptState::Allocated {
6666 attempt.begin_prepare(harness.clock.now()).unwrap();
6667 }
6668 let identity = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
6669 if stage != AttemptState::Allocated {
6670 let resource_state = if matches!(
6671 stage,
6672 AttemptState::Finished
6673 | AttemptState::Failed
6674 | AttemptState::Orphaned
6675 | AttemptState::Destroying
6676 | AttemptState::CleanupDeferred
6677 ) {
6678 EnvironmentState::Exited
6679 } else {
6680 EnvironmentState::Running
6681 };
6682 provider
6683 .resources
6684 .lock()
6685 .unwrap()
6686 .insert(id, (identity.clone(), resource_state));
6687 }
6688 if !matches!(stage, AttemptState::Allocated | AttemptState::Preparing) {
6689 let EnvironmentIdentity::Isolated { environment_id, .. } = &identity else {
6690 unreachable!()
6691 };
6692 attempt
6693 .prepared_environment(environment_id.clone())
6694 .unwrap();
6695 attempt.mark_prepared(harness.clock.now()).unwrap();
6696 }
6697 if matches!(
6698 stage,
6699 AttemptState::JitReceived
6700 | AttemptState::Starting
6701 | AttemptState::Idle
6702 | AttemptState::Busy
6703 | AttemptState::Finished
6704 | AttemptState::Failed
6705 | AttemptState::Orphaned
6706 | AttemptState::Destroying
6707 | AttemptState::CleanupDeferred
6708 ) {
6709 attempt.jit_received(harness.clock.now()).unwrap();
6710 }
6711 if matches!(
6712 stage,
6713 AttemptState::Starting
6714 | AttemptState::Idle
6715 | AttemptState::Busy
6716 | AttemptState::Finished
6717 | AttemptState::Failed
6718 | AttemptState::Orphaned
6719 | AttemptState::Destroying
6720 | AttemptState::CleanupDeferred
6721 ) {
6722 attempt.started_isolated(harness.clock.now()).unwrap();
6723 }
6724 if matches!(
6725 stage,
6726 AttemptState::Idle | AttemptState::Busy | AttemptState::Finished
6727 ) {
6728 attempt.registered_idle(73, harness.clock.now()).unwrap();
6729 }
6730 if matches!(stage, AttemptState::Busy | AttemptState::Finished) {
6731 attempt.assigned_job(73, harness.clock.now()).unwrap();
6732 }
6733 if matches!(
6734 stage,
6735 AttemptState::Finished
6736 | AttemptState::Failed
6737 | AttemptState::Orphaned
6738 | AttemptState::Destroying
6739 | AttemptState::CleanupDeferred
6740 ) {
6741 let outcome = match stage {
6742 AttemptState::Finished => AttemptOutcome::CompletedJob,
6743 AttemptState::Orphaned => AttemptOutcome::Orphaned,
6744 _ => AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6745 };
6746 attempt.conclude(outcome, harness.clock.now()).unwrap();
6747 }
6748 if matches!(
6749 stage,
6750 AttemptState::Destroying | AttemptState::CleanupDeferred
6751 ) {
6752 attempt.begin_destroy(harness.clock.now()).unwrap();
6753 }
6754 if stage == AttemptState::CleanupDeferred {
6755 attempt.defer_cleanup(harness.clock.now()).unwrap();
6756 }
6757 harness.store.record_attempt(&attempt).unwrap();
6758 launcher
6759 .recover_startup(std::slice::from_ref(&harness.policy))
6760 .await
6761 .unwrap_or_else(|error| panic!("restart from {stage}: {error}"));
6762 let resumed = harness.store.attempt(id).unwrap().unwrap();
6763 if matches!(
6764 stage,
6765 AttemptState::Allocated
6766 | AttemptState::Preparing
6767 | AttemptState::Prepared
6768 | AttemptState::Finished
6769 | AttemptState::Failed
6770 | AttemptState::Orphaned
6771 | AttemptState::Destroying
6772 | AttemptState::CleanupDeferred
6773 ) {
6774 assert_eq!(
6775 resumed.state(),
6776 AttemptState::Cleaned,
6777 "{stage} must retire after restart"
6778 );
6779 } else {
6780 assert!(resumed.counts_against_capacity(), "live {stage} is adopted");
6781 assert_eq!(provider.resource_count(), 1);
6782 provider.resources.lock().unwrap().get_mut(&id).unwrap().1 =
6783 EnvironmentState::Exited;
6784 launcher.supervise(&harness.policy).await.unwrap();
6785 assert_eq!(
6786 harness.store.attempt(id).unwrap().unwrap().state(),
6787 AttemptState::Cleaned
6788 );
6789 }
6790 assert_eq!(
6791 provider.resource_count(),
6792 0,
6793 "{stage} resource cleaned exactly once"
6794 );
6795 let destroyed = provider
6796 .actions
6797 .lock()
6798 .unwrap()
6799 .iter()
6800 .filter(|action| **action == "destroy")
6801 .count();
6802 assert_eq!(
6803 destroyed,
6804 usize::from(stage != AttemptState::Allocated),
6805 "{stage} destroy count"
6806 );
6807 assert_eq!(
6808 harness.github.registrations.load(Ordering::SeqCst),
6809 0,
6810 "restart from {stage} cannot duplicate JIT"
6811 );
6812 assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6813 }
6814 }
6815
6816 #[tokio::test]
6817 async fn duplicate_provider_ownership_is_quarantined_until_unambiguous() {
6818 let harness = isolated_harness();
6819 let provider = Arc::new(FakeIsolatedProvider::default());
6820 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6821 let id = AttemptId::from_u128(0x7777);
6822 let runtime = harness.host_root().join("duplicate-provider-ownership");
6823 fs::create_dir_all(&runtime).unwrap();
6824 let mut attempt =
6825 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
6826 let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6827 harness.policy.execution_policy()
6828 else {
6829 unreachable!()
6830 };
6831 attempt
6832 .allocate_execution(AttemptExecution::Isolated {
6833 provider_kind: Backend::Oci,
6834 environment_id: None,
6835 resolved_image: image.clone(),
6836 generation: "duplicate-generation".into(),
6837 })
6838 .unwrap();
6839 attempt.begin_prepare(harness.clock.now()).unwrap();
6840 harness.store.record_attempt(&attempt).unwrap();
6841 let first = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
6842 let mut second = first.clone();
6843 let EnvironmentIdentity::Isolated { environment_id, .. } = &mut second else {
6844 unreachable!()
6845 };
6846 *environment_id = "another-environment".into();
6847 provider
6848 .resources
6849 .lock()
6850 .unwrap()
6851 .insert(id, (first, EnvironmentState::Running));
6852 provider.extra_resources.lock().unwrap().push(second);
6853 launcher
6854 .recover_startup(std::slice::from_ref(&harness.policy))
6855 .await
6856 .expect("duplicate is local");
6857 let deferred = harness.store.attempt(id).unwrap().unwrap();
6858 assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
6859 assert!(deferred.counts_against_capacity());
6860 assert_eq!(provider.resource_count(), 1);
6861 assert_eq!(
6862 provider
6863 .actions
6864 .lock()
6865 .unwrap()
6866 .iter()
6867 .filter(|action| **action == "destroy")
6868 .count(),
6869 0
6870 );
6871 provider.extra_resources.lock().unwrap().clear();
6872 harness.clock.advance_secs(31);
6874 launcher
6875 .recover_startup(std::slice::from_ref(&harness.policy))
6876 .await
6877 .expect("single identity recovered");
6878 assert_eq!(provider.resource_count(), 0);
6879 assert_eq!(
6880 harness.store.attempt(id).unwrap().unwrap().state(),
6881 AttemptState::Cleaned
6882 );
6883 }
6884
6885 #[tokio::test]
6886 async fn provider_resource_without_live_journal_is_surfaced_but_never_deleted() {
6887 let harness = isolated_harness();
6888 let provider = Arc::new(FakeIsolatedProvider::default());
6889 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6890 let id = AttemptId::from_u128(0x8888);
6891 let runtime = harness.host_root().join("orphan-provider-resource");
6892 fs::create_dir_all(&runtime).unwrap();
6893 let mut attempt =
6894 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
6895 let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6896 harness.policy.execution_policy()
6897 else {
6898 unreachable!()
6899 };
6900 attempt
6901 .allocate_execution(AttemptExecution::Isolated {
6902 provider_kind: Backend::Oci,
6903 environment_id: None,
6904 resolved_image: image.clone(),
6905 generation: "orphan-generation".into(),
6906 })
6907 .unwrap();
6908 let identity = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
6909 provider
6910 .resources
6911 .lock()
6912 .unwrap()
6913 .insert(id, (identity, EnvironmentState::Running));
6914 launcher
6915 .recover_startup(std::slice::from_ref(&harness.policy))
6916 .await
6917 .expect("orphan cannot block host");
6918 assert_eq!(provider.resource_count(), 1);
6919 assert!(
6920 harness
6921 .events
6922 .events()
6923 .contains(&AttemptEvent::QuarantinedOwnedResource { attempt: id })
6924 );
6925 assert_eq!(
6926 provider
6927 .actions
6928 .lock()
6929 .unwrap()
6930 .iter()
6931 .filter(|action| **action == "destroy")
6932 .count(),
6933 0
6934 );
6935
6936 let EnvironmentIdentity::Isolated { environment_id, .. } = provider
6937 .resources
6938 .lock()
6939 .unwrap()
6940 .get(&id)
6941 .unwrap()
6942 .0
6943 .clone()
6944 else {
6945 unreachable!()
6946 };
6947 attempt.begin_prepare(harness.clock.now()).unwrap();
6948 attempt.prepared_environment(environment_id).unwrap();
6949 attempt.mark_prepared(harness.clock.now()).unwrap();
6950 attempt.jit_received(harness.clock.now()).unwrap();
6951 attempt.started_isolated(harness.clock.now()).unwrap();
6952 attempt
6953 .conclude(
6954 AttemptOutcome::failed(FailureReason::ProcessStartFailed),
6955 harness.clock.now(),
6956 )
6957 .unwrap();
6958 attempt.begin_destroy(harness.clock.now()).unwrap();
6959 attempt.clean_isolated(harness.clock.now()).unwrap();
6960 harness.store.record_attempt(&attempt).unwrap();
6961 launcher
6962 .recover_startup(std::slice::from_ref(&harness.policy))
6963 .await
6964 .expect("cleaned row cannot authorize deletion");
6965 assert_eq!(provider.resource_count(), 1);
6966 assert_eq!(
6967 provider
6968 .actions
6969 .lock()
6970 .unwrap()
6971 .iter()
6972 .filter(|action| **action == "destroy")
6973 .count(),
6974 0
6975 );
6976 }
6977
6978 #[tokio::test]
6987 async fn a_root_that_refuses_a_launch_is_recorded_and_cleared_when_one_succeeds() {
6988 use runner_manager_platform::service::{clear_runner_root_refusal, runner_root_refusals};
6989
6990 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6991 .with_host_runner_root();
6992 harness.ready().await;
6993
6994 let unusable = harness
6998 ._root
6999 .path()
7000 .join("absent")
7001 .join("deeper")
7002 .join("runners");
7003 let mut host = harness.host.clone();
7004 host.runner_root_override = Some(
7005 LocalAbsolutePath::new(unusable.to_str().expect("a UTF-8 temporary path"))
7006 .expect("a local absolute host root"),
7007 );
7008 harness.store.put_host(&host).unwrap();
7009
7010 let failure = harness
7011 .launch_result()
7012 .await
7013 .expect_err("a root whose parents are missing cannot hold a runner");
7014 assert!(
7015 matches!(failure.reason, FailureReason::Other(_)),
7016 "{failure:?}"
7017 );
7018
7019 let refusals = runner_root_refusals(&harness.app_paths).expect("readable");
7020 let refusal = refusals
7021 .first()
7022 .expect("the refusal reached the one surface that can hold it");
7023 assert_eq!(refusal.policy, harness.policy.id.to_string());
7024 assert_eq!(refusal.kind, "missing_parents");
7025 assert!(
7026 refusal.root.contains("runners") && refusal.detail.contains("runners"),
7027 "the directory must be named in full: {refusal:?}"
7028 );
7029
7030 harness.store.put_host(&harness.host).unwrap();
7033 harness.launch().await;
7034 assert!(
7035 runner_root_refusals(&harness.app_paths)
7036 .expect("readable")
7037 .is_empty(),
7038 "a successful placement clears that policy's record"
7039 );
7040
7041 clear_runner_root_refusal(&harness.app_paths, &harness.policy.id.to_string())
7042 .expect("cleanup");
7043 }
7044
7045 #[tokio::test]
7046 async fn a_launch_reports_how_long_each_step_took() {
7047 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7048 harness.ready().await;
7049 let started = harness.launch().await;
7050 let events = harness.events.events();
7051 let launched = events
7052 .iter()
7053 .position(|event| matches!(event, AttemptEvent::Launched { attempt, .. } if *attempt == started.id))
7054 .expect("a successful launch reports its step timings");
7055 let starting = events
7056 .iter()
7057 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == started.id))
7058 .unwrap();
7059 assert!(starting < launched, "timings follow the start: {events:?}");
7060 }
7061
7062 #[tokio::test]
7063 async fn a_failed_launch_still_reports_its_step_timings() {
7064 let harness = Harness::new(
7065 FakeGithubLifecycle::default().fail(true),
7066 Arc::new(PersistentDemand),
7067 );
7068 harness.ready().await;
7069 let failed = harness.launch_after_waiting(Duration::from_secs(90)).await;
7070 assert!(failed.is_err());
7071
7072 let timings = harness
7073 .events
7074 .events()
7075 .into_iter()
7076 .find_map(|event| match event {
7077 AttemptEvent::Launched {
7078 started: false,
7079 timings,
7080 ..
7081 } => Some(timings),
7082 _ => None,
7083 })
7084 .expect("a launch that failed at registration reports its timings");
7085 assert_eq!(timings.lock_wait, Duration::from_secs(90));
7086 assert_eq!(timings.spawn, Duration::ZERO, "spawn was never reached");
7087 assert!(timings.total() >= Duration::from_secs(90));
7088 }
7089
7090 #[cfg(target_os = "macos")]
7091 #[tokio::test]
7092 async fn a_launch_removes_the_seed_of_a_root_no_longer_in_use() {
7093 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7094 harness.ready().await;
7095
7096 let old_root = harness._root.path().join("old-root");
7098 let old_runtime = old_root.join("abandoned");
7099 fs::create_dir_all(old_root.join(PACKAGE_SEED_DIR).join("2.335.0")).unwrap();
7100 fs::create_dir_all(&old_runtime).unwrap();
7101 let old = RunnerAttempt::allocate(
7102 AttemptId::new_random(),
7103 harness.policy.id,
7104 &old_runtime,
7105 harness.clock.now(),
7106 );
7107 harness.store.record_attempt(&old).unwrap();
7108 let kept = harness.host_root().join(PACKAGE_SEED_DIR);
7109 fs::create_dir_all(kept.join("2.336.0")).unwrap();
7110
7111 harness.launch().await;
7112
7113 assert!(
7114 !old_root.join(PACKAGE_SEED_DIR).exists(),
7115 "the abandoned root's seed is removed"
7116 );
7117 assert!(old_runtime.exists(), "only the seed is touched");
7118 assert!(kept.exists(), "the host root's own seed is kept");
7119 }
7120
7121 #[tokio::test]
7122 async fn a_job_walks_every_state_and_cleans_every_artifact() {
7123 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7124 harness.ready().await;
7125 let started = harness.launch().await;
7126 assert_eq!(started.state(), AttemptState::Starting);
7127 assert_eq!(read_runner_id(started.runtime_path()), Some(73));
7128
7129 harness
7130 .github
7131 .observe(GithubRunnerObservation::Registered { busy: false });
7132 harness.launcher.supervise(&harness.policy).await.unwrap();
7133 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7134
7135 harness
7136 .github
7137 .observe(GithubRunnerObservation::Registered { busy: true });
7138 harness.launcher.supervise(&harness.policy).await.unwrap();
7139 assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
7140
7141 harness.processes.finish_successfully();
7142 harness
7143 .github
7144 .observe(GithubRunnerObservation::NotRegistered);
7145 harness.launcher.supervise(&harness.policy).await.unwrap();
7146 let cleaned = harness.only_attempt();
7147 assert_eq!(cleaned.state(), AttemptState::Cleaned);
7148 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
7149 assert!(!started.runtime_path().exists());
7150 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7151 assert_eq!(harness.github.remaining_runners.load(Ordering::SeqCst), 0);
7152
7153 let states: Vec<_> = harness
7154 .events
7155 .events()
7156 .into_iter()
7157 .filter_map(|event| match event {
7158 AttemptEvent::State { state, .. } => Some(state),
7159 _ => None,
7160 })
7161 .collect();
7162 assert_eq!(
7163 states,
7164 vec![
7165 AttemptState::Allocated,
7166 AttemptState::JitReceived,
7167 AttemptState::Starting,
7168 AttemptState::Idle,
7169 AttemptState::Busy,
7170 AttemptState::Finished,
7171 AttemptState::Cleaned,
7172 ]
7173 );
7174 }
7175
7176 #[tokio::test]
7177 async fn a_cleaned_ephemeral_attempt_reaps_a_directory_recreated_after_cleanup() {
7178 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7179 harness.ready().await;
7180 let started = harness.launch().await;
7181 let runtime = started.runtime_path().to_path_buf();
7182
7183 harness
7184 .github
7185 .observe(GithubRunnerObservation::Registered { busy: true });
7186 harness.launcher.supervise(&harness.policy).await.unwrap();
7187 harness.processes.finish_successfully();
7188 harness
7189 .github
7190 .observe(GithubRunnerObservation::NotRegistered);
7191 harness.launcher.supervise(&harness.policy).await.unwrap();
7192
7193 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7194 assert!(!runtime.exists());
7195 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7196
7197 let residue = runtime.join("_work").join("late-node-process");
7201 fs::create_dir_all(&residue).unwrap();
7202 fs::write(residue.join("node_modules.lock"), b"late residue").unwrap();
7203
7204 harness.launcher.supervise(&harness.policy).await.unwrap();
7205
7206 assert!(!runtime.exists(), "late ephemeral residue is reaped");
7207 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7208 assert_eq!(
7209 harness.packages.releases.load(Ordering::SeqCst),
7210 1,
7211 "reaping residue does not release the package lease twice"
7212 );
7213 }
7214
7215 #[tokio::test]
7216 async fn locked_late_residue_never_blocks_startup_recovery() {
7217 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7218 harness.ready().await;
7219 let started = harness.launch().await;
7220 let runtime = started.runtime_path().to_path_buf();
7221
7222 harness
7223 .github
7224 .observe(GithubRunnerObservation::Registered { busy: true });
7225 harness.launcher.supervise(&harness.policy).await.unwrap();
7226 harness.processes.finish_successfully();
7227 harness
7228 .github
7229 .observe(GithubRunnerObservation::NotRegistered);
7230 harness.launcher.supervise(&harness.policy).await.unwrap();
7231 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7232
7233 let held = runtime.join("_work").join("late-node-process");
7234 fs::create_dir_all(&held).unwrap();
7235 fs::write(held.join("node_modules.lock"), b"late residue").unwrap();
7236 let Some(block) = BlockedDeletion::inject(&held) else {
7237 eprintln!(
7238 "skipped: this account cannot be refused a deletion, so locked late residue cannot be injected"
7239 );
7240 return;
7241 };
7242
7243 let restarted = harness.restart();
7244 restarted
7245 .recover_startup(std::slice::from_ref(&harness.policy))
7246 .await
7247 .expect("late residue cannot take the daemon offline");
7248 assert!(runtime.exists(), "the locked residue remains for a retry");
7249 assert!(
7250 harness
7251 .reconcile_events
7252 .events()
7253 .iter()
7254 .any(|event| matches!(
7255 event,
7256 LifecycleEvent::AttemptCleanFailed {
7257 attempt,
7258 reason: "late_ephemeral_workspace_could_not_be_removed",
7259 ..
7260 } if *attempt == started.id
7261 )),
7262 "the non-blocking cleanup failure remains visible"
7263 );
7264
7265 block.release();
7266 harness.clock.advance_secs(31);
7268 restarted
7269 .supervise(&harness.policy)
7270 .await
7271 .expect("the ordinary retry succeeds after the lock is released");
7272 assert!(!runtime.exists(), "the retry removes the late residue");
7273 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7274 assert_eq!(
7275 harness.packages.releases.load(Ordering::SeqCst),
7276 1,
7277 "retrying residue never releases the package lease twice"
7278 );
7279 }
7280
7281 #[tokio::test]
7282 async fn mixed_locked_cleanup_residue_isolated_from_the_whole_startup_pass() {
7283 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7284 harness.ready().await;
7285
7286 let terminal = harness.launch().await;
7290 let terminal_runtime = terminal.runtime_path().to_path_buf();
7291 harness.conclude(terminal.id);
7292 let Some(terminal_block) = BlockedDeletion::inject(&terminal_runtime.join("late-child"))
7293 else {
7294 eprintln!("skipped: this account cannot inject the two independent deletion refusals");
7295 return;
7296 };
7297
7298 let cleaned = harness.launch().await;
7302 let cleaned_runtime = cleaned.runtime_path().to_path_buf();
7303 harness.conclude(cleaned.id);
7304 harness
7305 .launcher
7306 .clean(cleaned.id)
7307 .await
7308 .expect("the second attempt initially cleans");
7309 let Some(cleaned_block) =
7310 BlockedDeletion::inject(&cleaned_runtime.join("_work").join("late-child"))
7311 else {
7312 terminal_block.release();
7313 eprintln!("skipped: this account cannot inject the two independent deletion refusals");
7314 return;
7315 };
7316
7317 let restarted = harness.restart();
7318 for _ in 0..2 {
7319 restarted
7320 .recover_startup(std::slice::from_ref(&harness.policy))
7321 .await
7322 .expect("repeated recovery remains ready while both residues are locked");
7323 }
7324 assert_ne!(
7325 harness.attempt(terminal.id).state(),
7326 AttemptState::Cleaned,
7327 "the journal does not claim the terminal workspace was removed"
7328 );
7329 assert_eq!(
7330 harness.attempt(cleaned.id).state(),
7331 AttemptState::Cleaned,
7332 "late residue does not undo an already durable cleaned transition"
7333 );
7334 assert!(terminal_runtime.exists());
7335 assert!(cleaned_runtime.exists());
7336 assert_eq!(
7337 harness.packages.releases.load(Ordering::SeqCst),
7338 1,
7339 "repeated recovery neither loses nor duplicates package leases"
7340 );
7341
7342 terminal_block.release();
7343 cleaned_block.release();
7344 harness.clock.advance_secs(31);
7346 restarted
7347 .supervise(&harness.policy)
7348 .await
7349 .expect("one ordinary pass clears both residues after their locks disappear");
7350 assert!(!terminal_runtime.exists());
7351 assert!(!cleaned_runtime.exists());
7352 assert_eq!(harness.attempt(terminal.id).state(), AttemptState::Cleaned);
7353 assert_eq!(harness.attempt(cleaned.id).state(), AttemptState::Cleaned);
7354 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 2);
7355 }
7356
7357 #[tokio::test]
7358 async fn a_terminal_attempt_whose_runtime_is_already_gone_recovers_cleanly() {
7359 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7360 harness.ready().await;
7361 let attempt = harness.launch().await;
7362 let runtime = attempt.runtime_path().to_path_buf();
7363 harness.conclude(attempt.id);
7364 fs::remove_dir_all(&runtime).unwrap();
7365
7366 harness
7367 .restart()
7368 .recover_startup(std::slice::from_ref(&harness.policy))
7369 .await
7370 .expect("an already absent disposable runtime is successful cleanup");
7371
7372 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7373 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7374 }
7375
7376 #[tokio::test]
7377 async fn a_non_workspace_cleanup_failure_still_fails_closed_and_can_retry() {
7378 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7379 harness.ready().await;
7380 let attempt = harness.launch().await;
7381 let runtime = attempt.runtime_path().to_path_buf();
7382 harness.conclude(attempt.id);
7383 harness.packages.fail_releases(1);
7384
7385 let restarted = harness.restart();
7386 let failure = restarted
7387 .recover_startup(std::slice::from_ref(&harness.policy))
7388 .await
7389 .expect_err("a package-accounting failure is not safe to downgrade to residue");
7390 assert!(
7391 failure
7392 .reason()
7393 .to_string()
7394 .contains("package lease could not be released")
7395 );
7396 assert!(
7397 !runtime.exists(),
7398 "workspace removal completed before release failed"
7399 );
7400 assert_ne!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7401
7402 restarted
7403 .recover_startup(std::slice::from_ref(&harness.policy))
7404 .await
7405 .expect("the same journal entry retries safely after the transient failure");
7406 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7407 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7408 }
7409
7410 #[tokio::test]
7411 async fn startup_never_scrubs_a_cleaned_persistent_work_directory() {
7412 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
7413 .with_persistent_workspace(1);
7414 harness.ready().await;
7415 let attempt = harness.launch().await;
7416 let retained = attempt.runtime_path().join("_work").join("checkout-marker");
7417 fs::create_dir_all(retained.parent().unwrap()).unwrap();
7418 fs::write(&retained, b"persistent checkout").unwrap();
7419 harness.cleanup_retaining_work(attempt.id).await;
7420
7421 harness
7422 .restart()
7423 .recover_startup(std::slice::from_ref(&harness.policy))
7424 .await
7425 .expect("a cleaned persistent slot is already reconciled");
7426
7427 assert_eq!(fs::read(&retained).unwrap(), b"persistent checkout");
7428 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7429 }
7430
7431 #[tokio::test]
7432 async fn an_idle_exit_is_not_a_failure_in_the_journal_or_events() {
7433 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7434 harness.ready().await;
7435 let started = harness.launch().await;
7436 harness
7437 .github
7438 .observe(GithubRunnerObservation::Registered { busy: false });
7439 harness.launcher.supervise(&harness.policy).await.unwrap();
7440 harness.clock.advance_secs(11);
7441 harness.processes.set_alive(false);
7442 harness
7443 .github
7444 .observe(GithubRunnerObservation::NotRegistered);
7445 harness.launcher.supervise(&harness.policy).await.unwrap();
7446
7447 let cleaned = harness.only_attempt();
7448 assert!(cleaned.outcome().unwrap().is_idle_exit());
7449 assert!(!cleaned.outcome().unwrap().is_failure());
7450 assert!(!started.runtime_path().exists());
7451 assert!(
7452 harness
7453 .reconcile_events
7454 .events()
7455 .iter()
7456 .any(|event| matches!(
7457 event,
7458 LifecycleEvent::AttemptCleaned {
7459 outcome: OutcomeKind::IdleExit,
7460 ..
7461 }
7462 ))
7463 );
7464 assert!(!harness.events.events().iter().any(|event| matches!(
7465 event,
7466 AttemptEvent::Concluded {
7467 outcome: OutcomeKind::Failed,
7468 ..
7469 }
7470 )));
7471 }
7472
7473 #[tokio::test]
7474 async fn handoff_is_absent_after_success_and_every_failed_spawn_retry() {
7475 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7476 harness.processes.fail_spawns(2);
7477 harness.ready().await;
7478 let attempt = harness.launch().await;
7479 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 3);
7480 assert!(harness.processes.saw_secret.load(Ordering::SeqCst));
7481 let names: Vec<_> = fs::read_dir(attempt.runtime_path())
7482 .unwrap()
7483 .map(|entry| entry.unwrap().file_name())
7484 .collect();
7485 assert!(
7486 names.iter().all(|name| {
7487 !name
7488 .to_string_lossy()
7489 .starts_with(RestrictiveHandoff::NAME_PREFIX)
7490 }),
7491 "JIT artifact survived: {names:?}"
7492 );
7493 assert_eq!(
7494 *harness.delay.0.lock().unwrap(),
7495 vec![Duration::from_millis(10), Duration::from_millis(20)]
7496 );
7497 }
7498
7499 #[tokio::test]
7500 async fn jit_retry_stops_with_demand_and_a_terminal_403_never_retries() {
7501 let gone = Harness::new(
7502 FakeGithubLifecycle::default().fail(false),
7503 Arc::new(FakeDemand::answering([false])),
7504 );
7505 gone.ready().await;
7506 assert!(gone.launch_result().await.is_err());
7507 assert_eq!(gone.github.registrations.load(Ordering::SeqCst), 1);
7508 assert!(gone.delay.0.lock().unwrap().is_empty());
7509
7510 let forbidden = Harness::new(
7511 FakeGithubLifecycle::default().fail(true),
7512 Arc::new(PersistentDemand),
7513 );
7514 forbidden.ready().await;
7515 assert!(forbidden.launch_result().await.is_err());
7516 assert_eq!(forbidden.github.registrations.load(Ordering::SeqCst), 1);
7517 assert!(forbidden.delay.0.lock().unwrap().is_empty());
7518 assert!(matches!(
7519 forbidden.only_attempt().outcome(),
7520 Some(AttemptOutcome::Failed {
7521 reason: FailureReason::Other(action)
7522 }) if action.contains("403")
7523 ));
7524
7525 let transient = Harness::new(
7526 FakeGithubLifecycle::default().fail(false).fail(false),
7527 Arc::new(PersistentDemand),
7528 );
7529 transient.ready().await;
7530 transient.launch().await;
7531 assert_eq!(transient.github.registrations.load(Ordering::SeqCst), 3);
7532 assert_eq!(
7533 *transient.delay.0.lock().unwrap(),
7534 vec![Duration::from_millis(10), Duration::from_millis(20)]
7535 );
7536 }
7537
7538 #[test]
7546 fn a_workspace_leaves_room_for_the_deepest_path_a_checkout_writes() {
7547 const MAX_PATH: usize = 260;
7548 let root = r"C:\Users\IvanD\AppData\Local\IvanMurzak\runner-manager\data\runtime";
7550 let repo = "GitHub-Runner-Scaler-UI";
7554 let deepest = format!(
7555 r"_work\{repo}\{repo}\.git\objects\pack\pack-{}.keep",
7556 "0".repeat(40)
7557 );
7558
7559 let name = workspace_name(AttemptId::new_random());
7560 assert_eq!(name.len(), WORKSPACE_NAME_LEN, "{name}");
7561 assert!(
7562 name.chars().all(|c| c.is_ascii_hexdigit()),
7563 "a directory name must not carry the identifier's dashes: {name}"
7564 );
7565
7566 let full = format!(r"{root}\{name}\{deepest}");
7567 assert!(
7568 full.len() < MAX_PATH,
7569 "the deepest path a checkout writes must fit: {} characters, limit {MAX_PATH}",
7570 full.len()
7571 );
7572
7573 let old = format!(
7576 r"{root}\{}\{}\{deepest}",
7577 PolicyId::new_random(),
7578 AttemptId::new_random()
7579 );
7580 assert!(
7581 old.len() > MAX_PATH,
7582 "the old layout is supposed to be the thing that did not fit: {} characters",
7583 old.len()
7584 );
7585 }
7586
7587 #[tokio::test]
7588 async fn two_attempts_never_share_a_workspace_even_after_failure() {
7589 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7590 harness.ready().await;
7591 let first = harness.launch().await;
7592 fs::write(first.runtime_path().join("hostile-leftover"), b"first job").unwrap();
7593 harness
7594 .github
7595 .observe(GithubRunnerObservation::Registered { busy: false });
7596 harness.launcher.supervise(&harness.policy).await.unwrap();
7597 harness.clock.advance_secs(11);
7598 harness.processes.set_alive(false);
7599 harness
7600 .github
7601 .observe(GithubRunnerObservation::NotRegistered);
7602 harness.launcher.supervise(&harness.policy).await.unwrap();
7603 assert!(!first.runtime_path().exists());
7604
7605 let second = harness.launch().await;
7606 assert_ne!(first.runtime_path(), second.runtime_path());
7607 assert!(!second.runtime_path().join("hostile-leftover").exists());
7608
7609 fs::write(
7610 second.runtime_path().join("hostile-on-failure"),
7611 b"second job",
7612 )
7613 .unwrap();
7614 harness.processes.set_alive(false);
7615 harness
7616 .github
7617 .observe(GithubRunnerObservation::NotRegistered);
7618 harness.launcher.supervise(&harness.policy).await.unwrap();
7619 assert!(
7620 !second.runtime_path().exists(),
7621 "failed workspace was retained"
7622 );
7623 }
7624
7625 #[tokio::test]
7626 async fn a_runner_that_never_gets_a_job_is_stopped_deregistered_and_not_replaced() {
7627 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7628 harness.ready().await;
7629 let attempt = harness.launch().await;
7630
7631 harness
7635 .github
7636 .observe(GithubRunnerObservation::Registered { busy: false });
7637 harness.launcher.supervise(&harness.policy).await.unwrap();
7638 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7639
7640 harness.clock.advance_secs(9);
7643 harness
7644 .github
7645 .observe(GithubRunnerObservation::Registered { busy: false });
7646 let none_yet = harness.launcher.supervise(&harness.policy).await.unwrap();
7647 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7648 assert!(none_yet.is_empty());
7649 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
7650
7651 harness.clock.advance_secs(1);
7653 harness
7654 .github
7655 .observe(GithubRunnerObservation::Registered { busy: false });
7656 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7657
7658 let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
7659 assert_eq!(
7660 concluded.outcome(),
7661 Some(&AttemptOutcome::ExitedIdleWithoutWork),
7662 "a surplus runner did not fail; recording one as a failure sends an operator \
7663 hunting a fault that does not exist"
7664 );
7665 assert_eq!(concluded.state(), AttemptState::Cleaned);
7666 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
7667 assert!(!attempt.runtime_path().exists());
7668
7669 assert_eq!(
7672 *harness.github.deregistrations.lock().unwrap(),
7673 vec![73],
7674 "the attempt's own runner id, deleted exactly once"
7675 );
7676
7677 assert!(
7680 replacements.is_empty(),
7681 "a surplus exit must not request a replacement"
7682 );
7683 }
7684
7685 #[tokio::test]
7686 async fn a_registration_github_will_not_delete_still_concludes_the_attempt() {
7687 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7691 harness.ready().await;
7692 let attempt = harness.launch().await;
7693 harness
7694 .github
7695 .observe(GithubRunnerObservation::Registered { busy: false });
7696 harness.launcher.supervise(&harness.policy).await.unwrap();
7697
7698 harness
7699 .github
7700 .deregistration_fails
7701 .store(true, Ordering::SeqCst);
7702 harness.clock.advance_secs(11);
7703 harness
7704 .github
7705 .observe(GithubRunnerObservation::Registered { busy: false });
7706 harness.launcher.supervise(&harness.policy).await.unwrap();
7707
7708 assert_eq!(
7709 *harness.github.deregistrations.lock().unwrap(),
7710 vec![73],
7711 "the delete was attempted"
7712 );
7713 let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
7714 assert_eq!(
7715 concluded.outcome(),
7716 Some(&AttemptOutcome::ExitedIdleWithoutWork),
7717 "the attempt concluded anyway"
7718 );
7719 assert_eq!(concluded.state(), AttemptState::Cleaned);
7720 assert!(!attempt.runtime_path().exists());
7721 }
7722
7723 #[tokio::test]
7724 async fn exit_before_acceptance_returns_replacement_intent_without_launching() {
7725 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7726 harness.ready().await;
7727 let first = harness.launch().await;
7728 harness.processes.set_alive(false);
7729 harness
7730 .github
7731 .observe(GithubRunnerObservation::NotRegistered);
7732 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7733 let failed = harness.store.attempt(first.id).unwrap().unwrap();
7734 assert!(matches!(
7735 failed.outcome(),
7736 Some(AttemptOutcome::Failed {
7737 reason: FailureReason::ProcessExitedUnexpectedly
7738 })
7739 ));
7740 assert!(!first.runtime_path().exists());
7741
7742 assert_eq!(
7743 replacements,
7744 vec![ReplacementIntent {
7745 policy: harness.policy.id,
7746 previous_attempt: first.id,
7747 operation: "exit_before_acceptance_replacement",
7748 }]
7749 );
7750 assert_eq!(harness.store.attempts().unwrap().len(), 1);
7751 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
7752 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
7753 assert!(harness.delay.0.lock().unwrap().is_empty());
7754 }
7755
7756 fn write_runner_diagnostics(runtime: &Path, ran_a_job: bool) {
7760 let diag = runtime.join("_diag");
7761 fs::create_dir_all(&diag).unwrap();
7762 fs::write(diag.join("Runner_20261007-211941-utc.log"), b"listener").unwrap();
7763 if ran_a_job {
7764 fs::write(diag.join("Worker_20261007-211950-utc.log"), b"worker").unwrap();
7765 }
7766 }
7767
7768 #[tokio::test]
7773 async fn a_job_too_short_to_be_seen_busy_is_recorded_as_completed() {
7774 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7775 harness.ready().await;
7776 let started = harness.launch().await;
7777 assert_eq!(started.state(), AttemptState::Starting);
7778 write_runner_diagnostics(started.runtime_path(), true);
7779 harness.processes.finish_successfully();
7780 harness
7781 .github
7782 .observe(GithubRunnerObservation::NotRegistered);
7783
7784 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7785
7786 let cleaned = harness.only_attempt();
7787 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
7788 assert_eq!(cleaned.state(), AttemptState::Cleaned);
7789 assert_eq!(cleaned.github_runner_id(), Some(73));
7790 assert!(
7791 replacements.is_empty(),
7792 "a finished job needs no replacement"
7793 );
7794 let busy_seen = harness.events.events().into_iter().any(|event| {
7795 matches!(
7796 event,
7797 AttemptEvent::State {
7798 state: AttemptState::Busy,
7799 ..
7800 }
7801 )
7802 });
7803 assert!(busy_seen, "the journal walks the diagram to `busy` first");
7804 }
7805
7806 #[tokio::test]
7808 async fn a_job_taken_between_two_passes_after_idle_is_recorded_as_completed() {
7809 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7810 harness.ready().await;
7811 let started = harness.launch().await;
7812 harness
7813 .github
7814 .observe(GithubRunnerObservation::Registered { busy: false });
7815 harness.launcher.supervise(&harness.policy).await.unwrap();
7816 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7817
7818 write_runner_diagnostics(started.runtime_path(), true);
7819 harness.processes.finish_successfully();
7820 harness
7821 .github
7822 .observe(GithubRunnerObservation::NotRegistered);
7823 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
7824
7825 assert_eq!(
7826 harness.only_attempt().outcome(),
7827 Some(&AttemptOutcome::CompletedJob)
7828 );
7829 assert!(replacements.is_empty());
7830 }
7831
7832 #[tokio::test]
7836 async fn an_assignment_the_runner_never_took_up_is_not_a_completed_job() {
7837 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7838 harness.ready().await;
7839 let started = harness.launch().await;
7840 harness
7841 .github
7842 .observe(GithubRunnerObservation::Registered { busy: true });
7843 harness.launcher.supervise(&harness.policy).await.unwrap();
7844 assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
7845
7846 write_runner_diagnostics(started.runtime_path(), false);
7847 harness.processes.finish_successfully();
7848 harness
7849 .github
7850 .observe(GithubRunnerObservation::NotRegistered);
7851 harness.launcher.supervise(&harness.policy).await.unwrap();
7852
7853 let cleaned = harness.only_attempt();
7854 assert_eq!(
7855 cleaned.outcome(),
7856 Some(&AttemptOutcome::ExitedIdleWithoutWork)
7857 );
7858 assert_eq!(cleaned.state(), AttemptState::Cleaned);
7859 }
7860
7861 #[tokio::test]
7864 async fn a_finished_job_still_listed_at_github_is_completed_and_deregistered() {
7865 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7866 harness.ready().await;
7867 let started = harness.launch().await;
7868 harness
7869 .github
7870 .observe(GithubRunnerObservation::Registered { busy: true });
7871 harness.launcher.supervise(&harness.policy).await.unwrap();
7872
7873 write_runner_diagnostics(started.runtime_path(), true);
7874 harness.processes.finish_successfully();
7875 harness
7876 .github
7877 .observe(GithubRunnerObservation::Registered { busy: true });
7878 harness.launcher.supervise(&harness.policy).await.unwrap();
7879
7880 assert_eq!(
7881 harness.only_attempt().outcome(),
7882 Some(&AttemptOutcome::CompletedJob)
7883 );
7884 assert_eq!(*harness.github.deregistrations.lock().unwrap(), vec![73]);
7885 }
7886
7887 #[tokio::test]
7890 async fn a_live_runner_whose_assignment_was_withdrawn_goes_back_to_idle() {
7891 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7892 harness.ready().await;
7893 let started = harness.launch().await;
7894 harness
7895 .github
7896 .observe(GithubRunnerObservation::Registered { busy: true });
7897 harness.launcher.supervise(&harness.policy).await.unwrap();
7898 assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
7899
7900 write_runner_diagnostics(started.runtime_path(), false);
7901 harness
7902 .github
7903 .observe(GithubRunnerObservation::Registered { busy: false });
7904 harness.launcher.supervise(&harness.policy).await.unwrap();
7905 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7906
7907 harness
7909 .github
7910 .observe(GithubRunnerObservation::Registered { busy: true });
7911 harness.launcher.supervise(&harness.policy).await.unwrap();
7912 write_runner_diagnostics(started.runtime_path(), true);
7913 harness
7914 .github
7915 .observe(GithubRunnerObservation::Registered { busy: false });
7916 harness.launcher.supervise(&harness.policy).await.unwrap();
7917 assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
7918 }
7919
7920 #[tokio::test]
7923 async fn a_clean_exit_waits_for_github_to_answer() {
7924 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7925 harness.ready().await;
7926 let started = harness.launch().await;
7927 write_runner_diagnostics(started.runtime_path(), true);
7928 harness.processes.finish_successfully();
7929 harness.github.observe(GithubRunnerObservation::Unreachable);
7930 harness.launcher.supervise(&harness.policy).await.unwrap();
7931 assert_eq!(harness.only_attempt().state(), AttemptState::Starting);
7932
7933 harness
7934 .github
7935 .observe(GithubRunnerObservation::NotRegistered);
7936 harness.launcher.supervise(&harness.policy).await.unwrap();
7937 assert_eq!(
7938 harness.only_attempt().outcome(),
7939 Some(&AttemptOutcome::CompletedJob)
7940 );
7941 }
7942
7943 #[tokio::test]
7946 async fn a_short_job_with_no_runner_id_still_concludes() {
7947 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7948 harness.ready().await;
7949 let started = harness.launch().await;
7950 fs::remove_file(started.runtime_path().join(RUNNER_ID_FILE)).unwrap();
7951 write_runner_diagnostics(started.runtime_path(), true);
7952 harness.processes.finish_successfully();
7953 harness
7954 .github
7955 .observe(GithubRunnerObservation::NotRegistered);
7956 harness
7957 .launcher
7958 .supervise(&harness.policy)
7959 .await
7960 .expect("supervision goes on");
7961 assert!(harness.only_attempt().is_terminal());
7962 }
7963
7964 #[tokio::test]
7967 async fn without_runner_diagnostics_an_early_exit_is_still_a_failure() {
7968 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7969 harness.ready().await;
7970 harness.launch().await;
7971 harness.processes.finish_successfully();
7972 harness
7973 .github
7974 .observe(GithubRunnerObservation::NotRegistered);
7975 harness.launcher.supervise(&harness.policy).await.unwrap();
7976 assert!(matches!(
7977 harness.only_attempt().outcome(),
7978 Some(AttemptOutcome::Failed {
7979 reason: FailureReason::ProcessExitedUnexpectedly
7980 })
7981 ));
7982 }
7983
7984 #[tokio::test]
7985 async fn expired_jit_is_removed_and_does_not_reregister_after_demand_disappears() {
7986 let harness = Harness::new(
7987 FakeGithubLifecycle::default(),
7988 Arc::new(FakeDemand::answering([false])),
7989 );
7990 let id = AttemptId::new_random();
7991 let runtime = harness
7992 .launcher
7993 .app_paths
7994 .runtime_dir()
7995 .join(harness.policy.id.to_string())
7996 .join(id.to_string());
7997 fs::create_dir_all(&runtime).unwrap();
7998 let mut attempt =
7999 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8000 attempt.jit_received(harness.clock.now()).unwrap();
8001 harness.store.record_attempt(&attempt).unwrap();
8002 harness.clock.advance_secs(11);
8003 let replacements = harness
8004 .launcher
8005 .recover_startup(std::slice::from_ref(&harness.policy))
8006 .await
8007 .unwrap();
8008 assert_eq!(
8009 replacements,
8010 vec![ReplacementIntent {
8011 policy: harness.policy.id,
8012 previous_attempt: id,
8013 operation: "jit_expired_replacement",
8014 }]
8015 );
8016
8017 let cleaned = harness.store.attempt(id).unwrap().unwrap();
8018 assert_eq!(cleaned.state(), AttemptState::Cleaned);
8019 assert!(matches!(
8020 cleaned.outcome(),
8021 Some(AttemptOutcome::Failed {
8022 reason: FailureReason::JitExpired
8023 })
8024 ));
8025 assert!(!runtime.exists());
8026 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
8027 assert!(harness.delay.0.lock().unwrap().is_empty());
8028 }
8029
8030 #[tokio::test]
8031 async fn expired_jit_returns_intent_but_never_launches_inside_lifecycle() {
8032 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8033 let id = AttemptId::new_random();
8034 let runtime = harness
8035 .launcher
8036 .app_paths
8037 .runtime_dir()
8038 .join("expired-with-demand");
8039 fs::create_dir_all(&runtime).unwrap();
8040 let mut attempt =
8041 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8042 attempt.jit_received(harness.clock.now()).unwrap();
8043 harness.store.record_attempt(&attempt).unwrap();
8044 harness.clock.advance_secs(11);
8045 let replacements = harness
8046 .launcher
8047 .recover_startup(std::slice::from_ref(&harness.policy))
8048 .await
8049 .unwrap();
8050
8051 let attempts = harness.store.attempts().unwrap();
8052 assert_eq!(attempts.len(), 1);
8053 assert_eq!(
8054 attempts
8055 .iter()
8056 .find(|attempt| attempt.id == id)
8057 .unwrap()
8058 .state(),
8059 AttemptState::Cleaned
8060 );
8061 assert_eq!(
8062 replacements,
8063 vec![ReplacementIntent {
8064 policy: harness.policy.id,
8065 previous_attempt: id,
8066 operation: "jit_expired_replacement",
8067 }]
8068 );
8069 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
8070 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8071 assert!(harness.delay.0.lock().unwrap().is_empty());
8072 }
8073
8074 #[tokio::test]
8075 async fn package_materialization_retries_are_bounded_and_demand_adjacent() {
8076 let persistent = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8077 persistent.packages.fail_materializations(2);
8078 persistent.ready().await;
8079 persistent.launch().await;
8080 assert_eq!(
8081 persistent.packages.materializations.load(Ordering::SeqCst),
8082 3
8083 );
8084 assert_eq!(
8085 *persistent.delay.0.lock().unwrap(),
8086 vec![Duration::from_millis(10), Duration::from_millis(20)]
8087 );
8088
8089 let gone_before_wait = Harness::new(
8090 FakeGithubLifecycle::default(),
8091 Arc::new(FakeDemand::answering([false])),
8092 );
8093 gone_before_wait.packages.fail_materializations(3);
8094 gone_before_wait.ready().await;
8095 assert!(gone_before_wait.launch_result().await.is_err());
8096 assert_eq!(
8097 gone_before_wait
8098 .packages
8099 .materializations
8100 .load(Ordering::SeqCst),
8101 1
8102 );
8103 assert!(gone_before_wait.delay.0.lock().unwrap().is_empty());
8104
8105 let gone_during_wait = Harness::new(
8106 FakeGithubLifecycle::default(),
8107 Arc::new(FakeDemand::answering([true, false])),
8108 );
8109 gone_during_wait.packages.fail_materializations(3);
8110 gone_during_wait.ready().await;
8111 assert!(gone_during_wait.launch_result().await.is_err());
8112 assert_eq!(
8113 gone_during_wait
8114 .packages
8115 .materializations
8116 .load(Ordering::SeqCst),
8117 1
8118 );
8119 assert_eq!(
8120 *gone_during_wait.delay.0.lock().unwrap(),
8121 vec![Duration::from_millis(10)]
8122 );
8123 }
8124
8125 #[tokio::test]
8126 async fn replacement_is_intent_only_and_never_launches_inside_lifecycle() {
8127 let harness = Harness::new(
8128 FakeGithubLifecycle::default(),
8129 Arc::new(FakeDemand::answering([true, false])),
8130 );
8131 harness.ready().await;
8132 let first = harness.launch().await;
8133 harness.processes.set_alive(false);
8134 harness
8135 .github
8136 .observe(GithubRunnerObservation::NotRegistered);
8137 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8138
8139 assert_eq!(harness.store.attempts().unwrap().len(), 1);
8140 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
8141 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
8142 assert!(harness.delay.0.lock().unwrap().is_empty());
8143 assert_eq!(
8144 replacements,
8145 vec![ReplacementIntent {
8146 policy: harness.policy.id,
8147 previous_attempt: first.id,
8148 operation: "exit_before_acceptance_replacement",
8149 }]
8150 );
8151 assert_eq!(
8152 harness.store.attempt(first.id).unwrap().unwrap().state(),
8153 AttemptState::Cleaned
8154 );
8155 }
8156
8157 #[tokio::test]
8158 async fn startup_adopts_a_live_process_and_refuses_launch_before_recovery() {
8159 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8160 let before = harness.launch_result().await;
8161 assert!(before.is_err());
8162 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8163
8164 let id = AttemptId::new_random();
8165 let runtime = harness.launcher.app_paths.runtime_dir().join("adopt");
8166 fs::create_dir_all(&runtime).unwrap();
8167 let mut attempt =
8168 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
8169 attempt.jit_received(harness.clock.now()).unwrap();
8170 attempt.started(4242, harness.clock.now()).unwrap();
8171 harness.store.record_attempt(&attempt).unwrap();
8172 harness.processes.set_alive(true);
8173 harness
8174 .github
8175 .observe(GithubRunnerObservation::NotRegistered);
8176 let replacements = harness
8177 .launcher
8178 .recover_startup(std::slice::from_ref(&harness.policy))
8179 .await
8180 .unwrap();
8181 assert!(replacements.is_empty());
8182 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8183 assert!(
8184 harness
8185 .events
8186 .events()
8187 .contains(&AttemptEvent::Adopted { attempt: id })
8188 );
8189 }
8190
8191 #[tokio::test]
8192 async fn spawn_before_starting_crash_recovers_pid_then_completes_and_cleans() {
8193 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8194 let id = AttemptId::new_random();
8195 let runtime = harness
8196 .launcher
8197 .app_paths
8198 .runtime_dir()
8199 .join("spawn-before-starting");
8200 fs::create_dir_all(&runtime).unwrap();
8201 let mut attempt =
8202 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8203 attempt.jit_received(harness.clock.now()).unwrap();
8204 harness.store.record_attempt(&attempt).unwrap();
8205 harness.processes.set_alive(true);
8206 harness
8207 .github
8208 .observe(GithubRunnerObservation::Registered { busy: true });
8209
8210 let replacements = harness
8211 .launcher
8212 .recover_startup(std::slice::from_ref(&harness.policy))
8213 .await
8214 .unwrap();
8215 assert!(replacements.is_empty());
8216 let recovered = harness.store.attempt(id).unwrap().unwrap();
8217 assert_eq!(recovered.state(), AttemptState::Busy);
8218 assert_eq!(recovered.process_id(), Some(4242));
8219 assert_eq!(recovered.github_runner_id(), Some(73));
8220 let events = harness.events.events();
8221 let starting = events
8222 .iter()
8223 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == id))
8224 .unwrap();
8225 let busy = events
8226 .iter()
8227 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Busy } if *attempt == id))
8228 .unwrap();
8229 assert!(starting < busy, "recovery skipped a legal edge: {events:?}");
8230
8231 harness.processes.finish_successfully();
8232 harness
8233 .github
8234 .observe(GithubRunnerObservation::NotRegistered);
8235 assert!(
8236 harness
8237 .launcher
8238 .supervise(&harness.policy)
8239 .await
8240 .unwrap()
8241 .is_empty()
8242 );
8243 let cleaned = harness.store.attempt(id).unwrap().unwrap();
8244 assert_eq!(cleaned.state(), AttemptState::Cleaned);
8245 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
8246 assert!(!runtime.exists());
8247 }
8248
8249 #[tokio::test]
8250 async fn failed_post_spawn_stop_keeps_capacity_until_supervision_proves_death() {
8251 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8252 harness.processes.fail_spawn_with_live_child();
8253 harness.ready().await;
8254 assert!(harness.launch_result().await.is_err());
8255
8256 let attempt = harness.only_attempt();
8257 assert_eq!(attempt.state(), AttemptState::Starting);
8258 assert_eq!(attempt.process_id(), Some(4242));
8259 assert!(attempt.outcome().is_none());
8260 assert!(attempt.state().counts_against_capacity());
8261 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
8262 assert!(harness.delay.0.lock().unwrap().is_empty());
8263
8264 harness.processes.set_alive(false);
8265 harness
8266 .github
8267 .observe(GithubRunnerObservation::NotRegistered);
8268 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8269 assert_eq!(replacements.len(), 1);
8270 assert_eq!(
8271 harness.store.attempt(attempt.id).unwrap().unwrap().state(),
8272 AttemptState::Cleaned
8273 );
8274 }
8275
8276 #[tokio::test]
8277 async fn remote_runner_identity_closes_both_sides_of_the_registration_crash_boundary() {
8278 for sidecar_already_present in [false, true] {
8279 let harness = Harness::new(
8280 FakeGithubLifecycle::default(),
8281 Arc::new(FakeDemand::answering([false])),
8282 );
8283 let id = AttemptId::new_random();
8284 let runtime =
8285 harness
8286 .launcher
8287 .app_paths
8288 .runtime_dir()
8289 .join(if sidecar_already_present {
8290 "after-id-sidecar"
8291 } else {
8292 "before-id-sidecar"
8293 });
8294 fs::create_dir_all(&runtime).unwrap();
8295 let mut attempt =
8296 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8297 if sidecar_already_present {
8298 write_runner_id(&runtime, 73).unwrap();
8299 attempt.jit_received(harness.clock.now()).unwrap();
8300 }
8301 harness.store.record_attempt(&attempt).unwrap();
8302 harness.processes.set_alive(true);
8303 harness
8304 .github
8305 .observe(GithubRunnerObservation::Registered { busy: false });
8306 harness
8307 .launcher
8308 .recover_startup(std::slice::from_ref(&harness.policy))
8309 .await
8310 .unwrap();
8311
8312 assert_eq!(read_runner_id(&runtime), Some(73));
8313 assert!(
8314 harness
8315 .store
8316 .attempt(id)
8317 .unwrap()
8318 .unwrap()
8319 .outcome()
8320 .is_none()
8321 );
8322 let events = harness.events.events();
8323 let recovered = events.iter().position(|event| {
8324 matches!(
8325 event,
8326 AttemptEvent::RemoteIdentityRecovered {
8327 attempt,
8328 runner_id: 73
8329 } if *attempt == id
8330 )
8331 });
8332 assert_eq!(recovered.is_some(), !sidecar_already_present);
8333 if let Some(recovered) = recovered {
8334 let adopted = events
8335 .iter()
8336 .position(|event| matches!(event, AttemptEvent::Adopted { attempt } if *attempt == id))
8337 .unwrap();
8338 assert!(
8339 recovered < adopted,
8340 "identity was not durable before adoption: {events:?}"
8341 );
8342 }
8343 assert!(runtime.exists());
8344 }
8345 }
8346
8347 #[tokio::test]
8348 async fn recovery_stays_closed_for_unknown_policy_and_unreachable_attempts() {
8349 let unknown = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8350 let unknown_attempt = RunnerAttempt::allocate(
8351 AttemptId::new_random(),
8352 PolicyId::from_u128(0xfeed),
8353 unknown
8354 .launcher
8355 .app_paths
8356 .runtime_dir()
8357 .join("unknown-policy"),
8358 unknown.clock.now(),
8359 );
8360 unknown.store.record_attempt(&unknown_attempt).unwrap();
8361 let expired_id = AttemptId::new_random();
8362 let expired_runtime = unknown
8363 .launcher
8364 .app_paths
8365 .runtime_dir()
8366 .join("expired-beside-unknown");
8367 fs::create_dir_all(&expired_runtime).unwrap();
8368 let mut expired = RunnerAttempt::allocate(
8369 expired_id,
8370 unknown.policy.id,
8371 expired_runtime,
8372 unknown.clock.now(),
8373 );
8374 expired.jit_received(unknown.clock.now()).unwrap();
8375 unknown.store.record_attempt(&expired).unwrap();
8376 unknown.clock.advance_secs(11);
8377 assert!(matches!(
8378 unknown
8379 .launcher
8380 .recover_startup(std::slice::from_ref(&unknown.policy))
8381 .await,
8382 Err(LifecycleError::RecoveryIncomplete)
8383 ));
8384 assert!(unknown.launch_result().await.is_err());
8385 assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
8386 let recovered_policy = fixtures::policy()
8387 .id(PolicyId::from_u128(0xfeed))
8388 .repository("octo/repo")
8389 .autoscale("home", 2)
8390 .active()
8391 .build();
8392 let pending = unknown
8393 .launcher
8394 .recover_startup(&[unknown.policy.clone(), recovered_policy])
8395 .await
8396 .unwrap();
8397 assert_eq!(
8398 pending,
8399 vec![ReplacementIntent {
8400 policy: unknown.policy.id,
8401 previous_attempt: expired_id,
8402 operation: "jit_expired_replacement",
8403 }]
8404 );
8405 assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
8406
8407 let unreachable = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8408 let id = AttemptId::new_random();
8409 let runtime = unreachable
8410 .launcher
8411 .app_paths
8412 .runtime_dir()
8413 .join("unreachable");
8414 fs::create_dir_all(&runtime).unwrap();
8415 unreachable
8416 .store
8417 .record_attempt(&RunnerAttempt::allocate(
8418 id,
8419 unreachable.policy.id,
8420 runtime,
8421 unreachable.clock.now(),
8422 ))
8423 .unwrap();
8424 unreachable
8425 .github
8426 .observe(GithubRunnerObservation::Unreachable);
8427 assert!(matches!(
8428 unreachable
8429 .launcher
8430 .recover_startup(std::slice::from_ref(&unreachable.policy))
8431 .await,
8432 Err(LifecycleError::RecoveryIncomplete)
8433 ));
8434 assert!(unreachable.launch_result().await.is_err());
8435 assert_eq!(unreachable.processes.spawns.load(Ordering::SeqCst), 0);
8436 }
8437
8438 #[tokio::test]
8439 async fn a_dead_busy_process_unknown_to_github_is_orphaned_and_cleaned() {
8440 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8441 let id = AttemptId::new_random();
8442 let runtime = harness.launcher.app_paths.runtime_dir().join("orphan");
8443 fs::create_dir_all(&runtime).unwrap();
8444 let mut attempt =
8445 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8446 attempt.jit_received(harness.clock.now()).unwrap();
8447 attempt.started(4242, harness.clock.now()).unwrap();
8448 attempt.assigned_job(73, harness.clock.now()).unwrap();
8449 harness.store.record_attempt(&attempt).unwrap();
8450 harness.processes.set_alive(false);
8451 harness
8452 .github
8453 .observe(GithubRunnerObservation::NotRegistered);
8454 harness
8455 .launcher
8456 .recover_startup(std::slice::from_ref(&harness.policy))
8457 .await
8458 .unwrap();
8459 let cleaned = harness.store.attempt(id).unwrap().unwrap();
8460 assert_eq!(cleaned.state(), AttemptState::Cleaned);
8461 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::Orphaned));
8462 assert!(!runtime.exists());
8463 }
8464
8465 #[tokio::test]
8466 async fn registration_timeout_journals_intent_stops_then_concludes_with_dead_reason() {
8467 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8468 harness.ready().await;
8469 let id = AttemptId::new_random();
8470 let runtime = harness.launcher.app_paths.runtime_dir().join("timeout");
8471 fs::create_dir_all(&runtime).unwrap();
8472 let mut attempt =
8473 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
8474 attempt.jit_received(harness.clock.now()).unwrap();
8475 attempt.started(4242, harness.clock.now()).unwrap();
8476 harness.store.record_attempt(&attempt).unwrap();
8477 harness.clock.advance_secs(11);
8478 harness.processes.set_alive(true);
8479 harness
8480 .github
8481 .observe(GithubRunnerObservation::NotRegistered);
8482 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8483 assert_eq!(
8484 replacements,
8485 vec![ReplacementIntent {
8486 policy: harness.policy.id,
8487 previous_attempt: id,
8488 operation: "registration_timeout_replacement",
8489 }]
8490 );
8491
8492 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
8493 assert!(!harness.processes.alive.load(Ordering::SeqCst));
8494 let actions = harness.processes.actions.lock().unwrap().clone();
8495 let intent = actions
8496 .iter()
8497 .position(|action| *action == "terminate_intent")
8498 .unwrap();
8499 let signal = actions
8500 .iter()
8501 .position(|action| *action == "terminate")
8502 .unwrap();
8503 assert!(
8504 intent < signal,
8505 "intent was not durable before signal: {actions:?}"
8506 );
8507
8508 let cleaned = harness.store.attempt(id).unwrap().unwrap();
8509 assert!(matches!(
8510 cleaned.outcome(),
8511 Some(AttemptOutcome::Failed {
8512 reason: FailureReason::TerminatedAfterRegistrationTimeout
8513 })
8514 ));
8515 let events = harness.events.events();
8516 let intent = events
8517 .iter()
8518 .position(|event| matches!(event, AttemptEvent::TerminateIntent { .. }))
8519 .unwrap();
8520 let stopped = events
8521 .iter()
8522 .position(|event| matches!(event, AttemptEvent::Terminated { .. }))
8523 .unwrap();
8524 let concluded = events
8525 .iter()
8526 .position(|event| matches!(event, AttemptEvent::Concluded { .. }))
8527 .unwrap();
8528 assert!(intent < stopped && stopped < concluded, "{events:?}");
8529 }
8530
8531 #[tokio::test]
8532 async fn timeout_crash_recovery_returns_the_same_replacement_intent() {
8533 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8534 let id = AttemptId::new_random();
8535 let runtime = harness
8536 .launcher
8537 .app_paths
8538 .runtime_dir()
8539 .join("timeout-after-crash");
8540 fs::create_dir_all(&runtime).unwrap();
8541 let mut attempt =
8542 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
8543 attempt.jit_received(harness.clock.now()).unwrap();
8544 attempt.started(4242, harness.clock.now()).unwrap();
8545 harness.store.record_attempt(&attempt).unwrap();
8546 harness.processes.intent.store(true, Ordering::SeqCst);
8547 harness.processes.set_alive(false);
8548 harness
8549 .github
8550 .observe(GithubRunnerObservation::NotRegistered);
8551
8552 let replacements = harness
8553 .launcher
8554 .recover_startup(std::slice::from_ref(&harness.policy))
8555 .await
8556 .unwrap();
8557 assert_eq!(
8558 replacements,
8559 vec![ReplacementIntent {
8560 policy: harness.policy.id,
8561 previous_attempt: id,
8562 operation: "registration_timeout_replacement",
8563 }]
8564 );
8565 let consumed = RunnerLauncher::supervise(&harness.launcher, &harness.policy)
8566 .await
8567 .unwrap();
8568 assert_eq!(consumed, replacements);
8569 assert!(
8570 RunnerLauncher::supervise(&harness.launcher, &harness.policy)
8571 .await
8572 .unwrap()
8573 .is_empty(),
8574 "startup replacement evidence must be consumed exactly once by e1"
8575 );
8576 assert!(matches!(
8577 harness.store.attempt(id).unwrap().unwrap().outcome(),
8578 Some(AttemptOutcome::Failed {
8579 reason: FailureReason::TerminatedAfterRegistrationTimeout
8580 })
8581 ));
8582 }
8583
8584 #[tokio::test]
8585 async fn terminate_intent_sync_failure_prevents_signal_and_conclusion() {
8586 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8587 let id = AttemptId::new_random();
8588 let runtime = harness
8589 .launcher
8590 .app_paths
8591 .runtime_dir()
8592 .join("timeout-sync-failure");
8593 fs::create_dir_all(&runtime).unwrap();
8594 let mut attempt =
8595 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8596 attempt.jit_received(harness.clock.now()).unwrap();
8597 attempt.started(4242, harness.clock.now()).unwrap();
8598 harness.store.record_attempt(&attempt).unwrap();
8599 harness.clock.advance_secs(11);
8600 harness.processes.set_alive(true);
8601 harness.processes.fail_intent();
8602 harness
8603 .github
8604 .observe(GithubRunnerObservation::NotRegistered);
8605
8606 assert!(
8607 harness
8608 .launcher
8609 .recover_startup(std::slice::from_ref(&harness.policy))
8610 .await
8611 .is_err()
8612 );
8613 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
8614 assert!(harness.processes.alive.load(Ordering::SeqCst));
8615 assert_eq!(
8616 harness.store.attempt(id).unwrap().unwrap().state(),
8617 AttemptState::Starting
8618 );
8619 assert!(!harness.events.events().iter().any(|event| matches!(
8620 event,
8621 AttemptEvent::Terminated { attempt } | AttemptEvent::Concluded { attempt, .. }
8622 if *attempt == id
8623 )));
8624 }
8625
8626 #[tokio::test]
8627 async fn diagnostics_survive_cleanup_without_the_jit_or_a_token() {
8628 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8629 harness.ready().await;
8630 let attempt = harness.launch().await;
8631 harness
8632 .github
8633 .observe(GithubRunnerObservation::Registered { busy: false });
8634 harness.launcher.supervise(&harness.policy).await.unwrap();
8635 harness.clock.advance_secs(11);
8636 harness.processes.set_alive(false);
8637 harness
8638 .github
8639 .observe(GithubRunnerObservation::NotRegistered);
8640 harness.launcher.supervise(&harness.policy).await.unwrap();
8641 let diagnostic = fs::read_to_string(
8642 harness
8643 .launcher
8644 .diagnostics_root
8645 .join(format!("{}.log", attempt.id)),
8646 )
8647 .unwrap();
8648 assert!(diagnostic.contains("exited_idle_without_work"));
8649 assert!(!diagnostic.contains(JIT));
8650 assert!(!diagnostic.contains("ghp_"));
8651 assert!(!attempt.runtime_path().exists());
8652 }
8653
8654 #[test]
8655 fn native_process_listing_never_contains_jit_and_handoffs_never_survive() {
8656 let root = tempfile::tempdir().unwrap();
8657 let policy = fixtures::policy()
8658 .repository("octo/repo")
8659 .autoscale("home", 1)
8660 .active()
8661 .build();
8662 let runtime = root.path().join("successful");
8663 fs::create_dir_all(&runtime).unwrap();
8664 let processes = NativeProcesses::new();
8665 let config = EncodedJitConfig::new(JIT);
8666 let handoff =
8667 RestrictiveHandoff::create(&runtime, SecretString::from(config.expose().to_owned()))
8668 .unwrap();
8669 let mut child = native_inspection_spec()
8670 .spawn_runner_with_handoff(&handoff)
8671 .expect("native child starts");
8672 let pid = child.pid();
8673 handoff.delete().unwrap();
8674 let command_line = native_command_line(pid);
8675 assert!(
8676 !command_line.contains(JIT),
8677 "the encoded JIT configuration appeared in the native process listing"
8678 );
8679 assert_no_jit_file(&runtime);
8680 child
8681 .stop(Duration::from_secs(1))
8682 .expect("native child stops");
8683
8684 let failed_runtime = root.path().join("failed");
8685 fs::create_dir_all(&failed_runtime).unwrap();
8686 let failed = RunnerAttempt::allocate(
8687 AttemptId::new_random(),
8688 policy.id,
8689 &failed_runtime,
8690 FakeClock::default().now(),
8691 );
8692 assert!(
8693 processes
8694 .spawn(&failed, &EncodedJitConfig::new(JIT))
8695 .is_err(),
8696 "a runtime with no runner executable must fail"
8697 );
8698 assert_no_jit_file(&failed_runtime);
8699 processes
8700 .record_terminate_intent(&failed)
8701 .expect("the intent file and its directory entry are durably synced");
8702 assert_eq!(
8703 fs::read(NativeProcesses::intent_path(&failed)).unwrap(),
8704 b"registration-timeout\n"
8705 );
8706 }
8707
8708 #[test]
8709 fn post_spawn_boundaries_are_bounded_durable_and_never_retry_jit() {
8710 let root = tempfile::tempdir().unwrap();
8711 let policy = fixtures::policy()
8712 .repository("octo/repo")
8713 .autoscale("home", 1)
8714 .active()
8715 .build();
8716 let processes = NativeProcesses::new();
8717 processes.use_long_lived_test_listener();
8718 for (index, boundary) in [
8719 PostSpawnBoundary::HandoffDelete,
8720 PostSpawnBoundary::IdentitySerialize,
8721 PostSpawnBoundary::IdentityWrite,
8722 PostSpawnBoundary::ChildMapInsert,
8723 ]
8724 .into_iter()
8725 .enumerate()
8726 {
8727 let runtime = root.path().join(format!("post-spawn-{index}"));
8728 let bin = runtime.join("bin");
8729 fs::create_dir_all(&bin).unwrap();
8730 #[cfg(windows)]
8731 let listener = bin.join("Runner.Listener.exe");
8732 #[cfg(not(windows))]
8733 let listener = bin.join("Runner.Listener");
8734 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
8735 let attempt = RunnerAttempt::allocate(
8736 AttemptId::new_random(),
8737 policy.id,
8738 &runtime,
8739 FakeClock::default().now(),
8740 );
8741 processes.fail_post_spawn_at(boundary);
8742 let failure = processes
8743 .spawn(&attempt, &EncodedJitConfig::new(JIT))
8744 .expect_err("fault must cross the post-spawn cleanup path");
8745 assert!(!failure.retryable, "{boundary:?} allowed duplicate retry");
8746 assert!(
8747 !processes.is_alive(&attempt).unwrap(),
8748 "{boundary:?} left a child"
8749 );
8750 assert!(!NativeProcesses::identity_path(&attempt).exists());
8751 assert_no_jit_file(&runtime);
8752 }
8753 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
8754
8755 let runtime = root.path().join("identity-and-stop-fail");
8756 let bin = runtime.join("bin");
8757 fs::create_dir_all(&bin).unwrap();
8758 #[cfg(windows)]
8759 let listener = bin.join("Runner.Listener.exe");
8760 #[cfg(not(windows))]
8761 let listener = bin.join("Runner.Listener");
8762 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
8763 let attempt = RunnerAttempt::allocate(
8764 AttemptId::new_random(),
8765 policy.id,
8766 &runtime,
8767 FakeClock::default().now(),
8768 );
8769 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
8773 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
8774 processes.fail_next_post_spawn_stop();
8775 let failure = processes
8776 .spawn(&attempt, &EncodedJitConfig::new(JIT))
8777 .expect_err("the identity boundary must fail closed");
8778 assert!(failure.live_pid.is_some());
8779 assert_long_lived_listener_ready(&processes, &attempt);
8780 assert!(processes.is_alive(&attempt).unwrap());
8781 assert!(!NativeProcesses::identity_path(&attempt).exists());
8782 assert!(NativeProcesses::fallback_identity_path(&attempt).is_file());
8783 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
8784 processes.terminate(&attempt).unwrap();
8785
8786 let runtime = root.path().join("persistent-stop-and-identity-failures");
8787 let bin = runtime.join("bin");
8788 fs::create_dir_all(&bin).unwrap();
8789 #[cfg(windows)]
8790 let listener = bin.join("Runner.Listener.exe");
8791 #[cfg(not(windows))]
8792 let listener = bin.join("Runner.Listener");
8793 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
8794 let mut unresolved = RunnerAttempt::allocate(
8795 AttemptId::new_random(),
8796 policy.id,
8797 &runtime,
8798 FakeClock::default().now(),
8799 );
8800 for _ in 0..3 {
8801 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
8802 }
8803 processes.fail_post_spawn_stops(MAX_POST_SPAWN_STOP_ATTEMPTS);
8804 let failure = processes
8805 .spawn(&unresolved, &EncodedJitConfig::new(JIT))
8806 .expect_err("bounded cleanup must return even when every stop errors");
8807 let pid = failure
8808 .live_pid
8809 .expect("the owned child remains supervised in this invocation");
8810 assert!(matches!(failure.reason, FailureReason::Other(_)));
8811 assert_long_lived_listener_ready(&processes, &unresolved);
8812 unresolved.jit_received(FakeClock::default().now()).unwrap();
8813 unresolved.started(pid, FakeClock::default().now()).unwrap();
8814 let journal = SqliteStore::open_in_memory().unwrap();
8815 journal.record_attempt(&unresolved).unwrap();
8816 let recovered = journal.attempt(unresolved.id).unwrap().unwrap();
8817 assert_eq!(recovered.process_id(), Some(pid));
8818 assert_eq!(recovered.state(), AttemptState::Starting);
8819 assert!(processes.is_alive(&unresolved).unwrap());
8820 assert!(!NativeProcesses::identity_path(&unresolved).exists());
8821 assert!(!NativeProcesses::fallback_identity_path(&unresolved).exists());
8822 assert_eq!(
8823 fs::read_to_string(NativeProcesses::unresolved_process_path(&unresolved)).unwrap(),
8824 pid.to_string(),
8825 "bounded cleanup must leave durable unresolved-process evidence before returning"
8826 );
8827 assert!(
8828 NativeProcesses::new().is_alive(&recovered).is_err(),
8829 "restart must fail closed on the durable starting/PID journal rather than trust a bare PID"
8830 );
8831 processes.terminate(&unresolved).unwrap();
8832
8833 let runtime = root.path().join("post-spawn-stop-failed");
8834 let bin = runtime.join("bin");
8835 fs::create_dir_all(&bin).unwrap();
8836 #[cfg(windows)]
8837 let listener = bin.join("Runner.Listener.exe");
8838 #[cfg(not(windows))]
8839 let listener = bin.join("Runner.Listener");
8840 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
8841 let attempt = RunnerAttempt::allocate(
8842 AttemptId::new_random(),
8843 policy.id,
8844 &runtime,
8845 FakeClock::default().now(),
8846 );
8847 processes.fail_post_spawn_at(PostSpawnBoundary::ChildMapInsert);
8848 processes.fail_next_post_spawn_stop();
8849 let failure = processes
8850 .spawn(&attempt, &EncodedJitConfig::new(JIT))
8851 .expect_err("the injected stop failure must preserve supervision");
8852 let live_pid = failure
8853 .live_pid
8854 .expect("live PID is returned to the journal");
8855 assert!(!failure.retryable);
8856 assert_long_lived_listener_ready(&processes, &attempt);
8857 assert!(NativeProcesses::identity_path(&attempt).is_file());
8858 assert_eq!(
8859 NativeProcesses::read_identity(&attempt)
8860 .unwrap()
8861 .unwrap()
8862 .pid(),
8863 live_pid
8864 );
8865 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
8866 processes.terminate(&attempt).unwrap();
8867 }
8868
8869 #[test]
8870 #[ignore = "spawned only as the platform-stable native listener fixture"]
8871 fn long_lived_native_listener_helper() {
8872 let ready = std::env::var_os("RUNNER_MANAGER_TEST_LISTENER_READY")
8873 .map(PathBuf::from)
8874 .expect("the parent supplies the readiness path");
8875 runner_manager_platform::host_fitness::write_atomically(&ready, b"ready\n")
8878 .expect("the listener publishes readiness");
8879 std::thread::sleep(Duration::from_secs(30));
8880 }
8881
8882 fn assert_long_lived_listener_ready(processes: &NativeProcesses, attempt: &RunnerAttempt) {
8883 let ready = attempt.runtime_path().join(TEST_LISTENER_READY);
8884 let deadline = std::time::Instant::now() + Duration::from_secs(5);
8885 loop {
8886 if ready.is_file() {
8887 assert_eq!(fs::read(&ready).unwrap(), b"ready\n");
8888 return;
8889 }
8890 assert!(
8891 processes.is_alive(attempt).unwrap(),
8892 "the native listener exited before publishing readiness"
8893 );
8894 assert!(
8895 std::time::Instant::now() < deadline,
8896 "the native listener stayed alive but never published readiness"
8897 );
8898 std::thread::sleep(Duration::from_millis(10));
8899 }
8900 }
8901
8902 #[tokio::test]
8903 async fn every_production_launch_prunes_under_the_same_allocation_guard() {
8904 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8905 harness.ready().await;
8906 assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 0);
8907 harness.launch().await;
8908 assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 1);
8909 assert_eq!(
8910 *harness.packages.prune_currents.lock().unwrap(),
8911 vec![harness.packages.version.clone()],
8912 "the leased current version is an exclusion, never the prune target"
8913 );
8914 }
8915
8916 fn assert_no_jit_file(runtime: &Path) {
8917 for entry in fs::read_dir(runtime).unwrap() {
8918 let path = entry.unwrap().path();
8919 if path.is_file() {
8920 let bytes = fs::read(&path).unwrap();
8921 assert!(
8922 !bytes
8923 .windows(JIT.len())
8924 .any(|window| window == JIT.as_bytes()),
8925 "a JIT payload survived in a runtime file"
8926 );
8927 }
8928 }
8929 }
8930
8931 #[test]
8932 fn production_listener_command_uses_the_supported_jit_contract() {
8933 let runtime = Path::new("runtime");
8934 let spec = runner_listener_spec(
8935 PathBuf::from("Runner.Listener"),
8936 runtime,
8937 Vec::new(),
8938 &RunnerEnv::default(),
8939 );
8940 let arguments: Vec<_> = spec
8941 .arguments()
8942 .iter()
8943 .map(|argument| argument.to_string_lossy().into_owned())
8944 .collect();
8945
8946 assert_eq!(arguments, ["run"]);
8947 assert!(
8948 !arguments
8949 .iter()
8950 .any(|argument| argument == "--jit-config-file"),
8951 "the obsolete file option would be rejected by Runner.Listener 2.336.0"
8952 );
8953 }
8954
8955 #[test]
8959 fn production_listener_layers_runner_env_over_the_platform_defaults() {
8960 let root = tempfile::tempdir().unwrap();
8961 let runtime = root.path().join("attempt");
8962 let host_env =
8963 RunnerEnv::parse("ELECTRON_CACHE=/cache/electron\nHOME=/operator/chose/this\n")
8964 .unwrap();
8965 let caches = vec![
8966 (
8967 "ELECTRON_CACHE",
8968 std::ffi::OsString::from("/rman/_cache/o/r/electron"),
8969 ),
8970 (
8971 "npm_config_cache",
8972 std::ffi::OsString::from("/rman/_cache/o/r/npm"),
8973 ),
8974 ];
8975 let spec = runner_listener_spec(
8976 PathBuf::from("Runner.Listener"),
8977 &runtime,
8978 caches,
8979 &host_env,
8980 );
8981 let env = |name: &str| spec.configured_env(name).map(PathBuf::from);
8982
8983 assert!(spec.expects_normal_scheduling());
8984 assert_eq!(
8985 env("ELECTRON_CACHE"),
8986 Some(PathBuf::from("/cache/electron")),
8987 "runner.env must win over a dependency cache of the same name"
8988 );
8989 assert_eq!(
8990 env("npm_config_cache"),
8991 Some(PathBuf::from("/rman/_cache/o/r/npm"))
8992 );
8993 assert_eq!(
8994 env("HOME"),
8995 Some(PathBuf::from("/operator/chose/this")),
8996 "runner.env must win over a platform default of the same name"
8997 );
8998 for tmp in ["TMPDIR", "TEMP", "TMP"] {
8999 assert_eq!(env(tmp), Some(runtime.join("tmp")), "{tmp}");
9000 }
9001
9002 let home = runtime.join(runner_env::RUNNER_HOME_DIR);
9003 if cfg!(windows) {
9004 assert_eq!(env("USERPROFILE"), Some(home.clone()));
9007 assert_eq!(env("APPDATA"), Some(home.join(r"AppData\Roaming")));
9008 assert_eq!(env("LOCALAPPDATA"), Some(home.join(r"AppData\Local")));
9009 assert!(home.join(r"AppData\Roaming").is_dir());
9010 assert!(home.join(r"AppData\Local").is_dir());
9011 } else {
9012 assert_eq!(env("USERPROFILE"), None);
9014 assert!(!home.exists());
9015 }
9016 }
9017
9018 #[test]
9022 fn production_listener_opts_powershell_out_of_telemetry() {
9023 let root = tempfile::tempdir().unwrap();
9024 let runtime = root.path().join("attempt");
9025 let listener = |host_env: &RunnerEnv| {
9026 runner_listener_spec(
9027 PathBuf::from("Runner.Listener"),
9028 &runtime,
9029 Vec::new(),
9030 host_env,
9031 )
9032 };
9033 let optout = |spec: &SpawnSpec| {
9034 spec.configured_env(runner_env::POWERSHELL_TELEMETRY_OPTOUT)
9035 .map(OsStr::to_os_string)
9036 };
9037
9038 let spec = listener(&RunnerEnv::default());
9039 if std::env::var_os(runner_env::POWERSHELL_TELEMETRY_OPTOUT)
9040 .is_some_and(|value| !value.is_empty())
9041 {
9042 assert_eq!(optout(&spec), None);
9044 } else {
9045 assert_eq!(
9046 optout(&spec),
9047 Some(runner_env::POWERSHELL_TELEMETRY_OPTOUT_VALUE.into())
9048 );
9049 }
9050
9051 let operator = RunnerEnv::parse("POWERSHELL_TELEMETRY_OPTOUT=0\n").unwrap();
9052 assert_eq!(
9053 optout(&listener(&operator)),
9054 Some("0".into()),
9055 "runner.env must win over the telemetry opt-out"
9056 );
9057 }
9058
9059 #[test]
9060 fn an_invalid_runner_env_fails_the_launch_without_echoing_a_value() {
9061 let root = tempfile::tempdir().unwrap();
9062 let path = runner_env::path_in(root.path());
9063 fs::write(&path, "GOOD=1\nghp_notarealtokenbutshapedlikeone\n").unwrap();
9064 let processes = NativeProcesses::new().with_runner_env_file(path.clone());
9065 let failure = processes.host_env().unwrap_err().to_string();
9066 assert!(
9067 failure.contains("runner.env") && failure.contains("line 2"),
9068 "{failure}"
9069 );
9070 assert!(
9071 !failure.contains("ghp_"),
9072 "the failure echoed a value: {failure}"
9073 );
9074
9075 let policy = fixtures::policy()
9078 .repository("octo/repo")
9079 .autoscale("home", 1)
9080 .active()
9081 .build();
9082 let attempt = RunnerAttempt::allocate(
9083 AttemptId::new_random(),
9084 policy.id,
9085 root.path(),
9086 FakeClock::default().now(),
9087 );
9088 assert!(processes.prepare(&attempt, &policy).is_err());
9089
9090 fs::write(&path, "GOOD=1\n").unwrap();
9091 assert!(processes.prepare(&attempt, &policy).is_ok());
9092 assert_eq!(processes.host_env().unwrap().get("GOOD"), Some("1"));
9093 assert!(NativeProcesses::new().host_env().unwrap().is_empty());
9094 }
9095
9096 #[test]
9100 fn prepare_hands_the_policys_cache_namespace_to_the_launch() {
9101 let root = tempfile::tempdir().unwrap();
9102 let runner_root = root.path().join("rman");
9103 let runtime = runner_root.join("a1");
9104 fs::create_dir_all(runtime.join("bin")).unwrap();
9105 let config = runner_manager_platform::dependency_cache::config_path_in(root.path());
9106 let source_root = runner_root.clone();
9107 let processes = NativeProcesses::new().with_dependency_caches(DependencyCaches::new(
9108 config.clone(),
9109 Arc::new(move || Some(source_root.clone())),
9110 None,
9111 ));
9112 let policy = fixtures::policy()
9113 .repository("Octo/Repo")
9114 .autoscale("home", 1)
9115 .active()
9116 .build();
9117 let attempt = RunnerAttempt::allocate(
9118 AttemptId::new_random(),
9119 policy.id,
9120 &runtime,
9121 FakeClock::default().now(),
9122 );
9123 assert!(
9124 processes
9125 .cache_env(&attempt, None, &RunnerEnv::default())
9126 .is_empty(),
9127 "a launch with no prepared target, such as a bare `spawn`, has no cache"
9128 );
9129 let prepared = processes.prepare(&attempt, &policy).unwrap();
9130 let variables = processes.cache_env(
9131 &attempt,
9132 prepared.cache_target.as_ref(),
9133 &RunnerEnv::default(),
9134 );
9135 let npm = variables
9136 .iter()
9137 .find(|(name, _)| *name == "npm_config_cache")
9138 .map(|(_, value)| PathBuf::from(value));
9139 assert_eq!(
9140 npm,
9141 Some(
9142 runner_root
9143 .join("_cache")
9144 .join("octo")
9145 .join("repo")
9146 .join("npm")
9147 )
9148 );
9149
9150 fs::write(&config, "[tools]\nnot-a-tool = true\n").unwrap();
9151 let refused = processes
9152 .prepare(&attempt, &policy)
9153 .unwrap_err()
9154 .to_string();
9155 assert!(refused.contains("caches.toml"), "{refused}");
9156 }
9157
9158 #[cfg(windows)]
9159 fn native_inspection_spec() -> SpawnSpec {
9160 SpawnSpec::new("powershell.exe").args([
9161 "-NoProfile",
9162 "-NonInteractive",
9163 "-Command",
9164 "Start-Sleep -Seconds 30",
9165 ])
9166 }
9167
9168 #[cfg(unix)]
9169 fn native_inspection_spec() -> SpawnSpec {
9170 SpawnSpec::new("/bin/sh").args(["-c", "sleep 30"])
9171 }
9172
9173 #[cfg(windows)]
9174 fn native_command_line(pid: u32) -> String {
9175 let output = std::process::Command::new("powershell.exe")
9176 .args([
9177 "-NoProfile",
9178 "-NonInteractive",
9179 "-Command",
9180 &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId = {pid}').CommandLine"),
9181 ])
9182 .output()
9183 .expect("PowerShell can inspect the native child");
9184 assert!(output.status.success(), "native process inspection failed");
9185 String::from_utf8(output.stdout).expect("Windows command lines are Unicode")
9186 }
9187
9188 #[cfg(target_os = "linux")]
9189 fn native_command_line(pid: u32) -> String {
9190 fs::read(format!("/proc/{pid}/cmdline"))
9191 .map(|bytes| String::from_utf8_lossy(&bytes).replace('\0', " "))
9192 .expect("/proc exposes the native child command line")
9193 }
9194
9195 #[cfg(target_os = "macos")]
9196 fn native_command_line(pid: u32) -> String {
9197 let output = std::process::Command::new("ps")
9198 .args(["-o", "command=", "-p", &pid.to_string()])
9199 .output()
9200 .expect("ps can inspect the native child");
9201 assert!(output.status.success(), "native process inspection failed");
9202 String::from_utf8(output.stdout).expect("the command line is UTF-8")
9203 }
9204
9205 #[tokio::test]
9208 async fn a_persistent_repository_leases_s1_and_journals_it_before_any_github_effect() {
9209 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9210 .with_persistent_workspace(2);
9211 harness.github.watch_journal(Arc::clone(&harness.store));
9212 harness.ready().await;
9213
9214 let attempt = harness.launch().await;
9215
9216 assert_eq!(
9217 attempt.workspace(),
9218 AttemptWorkspace::persistent_slot(nz(1)),
9219 "the lowest free slot is leased"
9220 );
9221 assert_eq!(attempt.runtime_path(), harness.slot_path(1));
9222 assert!(attempt.holds_slot_lease());
9223 assert_eq!(
9225 harness.attempt(attempt.id).runtime_path(),
9226 harness.slot_path(1)
9227 );
9228
9229 let facts = harness.github.registration_facts();
9233 assert_eq!(facts.len(), 1);
9234 assert_eq!(
9235 facts[0].leased_slots,
9236 vec![1],
9237 "the lease was journalled first"
9238 );
9239 assert_eq!(facts[0].work_folder, DEFAULT_WORK_FOLDER);
9240 }
9241
9242 #[tokio::test]
9243 async fn a_terminal_but_uncleaned_attempt_keeps_its_slot_without_holding_capacity() {
9244 let harness = Harness::new(
9245 FakeGithubLifecycle::default().fail(true),
9246 Arc::new(PersistentDemand),
9247 )
9248 .with_persistent_workspace(2);
9249 harness.ready().await;
9250
9251 harness.launch_result().await.unwrap_err();
9253 let first = harness.store.attempts().unwrap().remove(0);
9254 assert_eq!(first.state(), AttemptState::Failed);
9255 assert!(
9256 !first.state().counts_against_capacity(),
9257 "a concluded attempt is invisible to host capacity"
9258 );
9259 assert!(
9260 first.holds_slot_lease(),
9261 "and still owns its directory, so its slot is not free"
9262 );
9263
9264 let second = harness.launch().await;
9265 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
9266 assert_eq!(second.runtime_path(), harness.slot_path(2));
9267 assert_eq!(
9268 harness
9269 .store
9270 .slot_leases_for_policy(harness.policy.id)
9271 .unwrap()
9272 .len(),
9273 2
9274 );
9275 }
9276
9277 #[tokio::test]
9278 async fn two_sequential_allocations_at_capacity_one_reuse_s1_and_its_retained_work() {
9279 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9280 .with_persistent_workspace(1);
9281 harness.ready().await;
9282
9283 let first = harness.launch().await;
9284 assert_eq!(first.runtime_path(), harness.slot_path(1));
9285
9286 let checkout = harness.slot_path(1).join(DEFAULT_WORK_FOLDER).join("repo");
9288 fs::create_dir_all(&checkout).unwrap();
9289 fs::write(checkout.join("checkout.txt"), b"from the first job").unwrap();
9290
9291 harness.cleanup_retaining_work(first.id).await;
9292
9293 let second = harness.launch().await;
9294 assert_ne!(second.id, first.id);
9295 assert_eq!(
9296 second.workspace(),
9297 AttemptWorkspace::persistent_slot(nz(1)),
9298 "a released slot is leased again rather than skipped"
9299 );
9300 assert_eq!(
9301 second.runtime_path(),
9302 first.runtime_path(),
9303 "the same slot is the same exact path"
9304 );
9305 assert_eq!(
9306 fs::read_to_string(checkout.join("checkout.txt")).unwrap(),
9307 "from the first job",
9308 "the retained job workspace survived the second allocation"
9309 );
9310 assert!(harness.slot_path(1).join("runner-package").exists());
9312 }
9313
9314 #[tokio::test]
9315 async fn lowering_capacity_leaves_higher_slots_alone_and_raising_it_permits_them_again() {
9316 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9317 .with_persistent_workspace(2);
9318 harness.ready().await;
9319
9320 let first = harness.launch().await;
9321 let second = harness.launch().await;
9322 assert_eq!(second.runtime_path(), harness.slot_path(2));
9323 let kept = harness
9324 .slot_path(2)
9325 .join(DEFAULT_WORK_FOLDER)
9326 .join("kept.txt");
9327 fs::create_dir_all(kept.parent().unwrap()).unwrap();
9328 fs::write(&kept, b"s2 was here").unwrap();
9329 harness.cleanup_retaining_work(second.id).await;
9330
9331 harness.policy.set_max_capacity(nz(1)).unwrap();
9333 let refusal = harness.launch_result().await.unwrap_err().to_string();
9334 assert!(
9335 refusal.contains("s1 to s1"),
9336 "the refusal names the ceiling it reached: {refusal}"
9337 );
9338 assert!(
9339 harness.slot_path(2).exists() && kept.exists(),
9340 "lowering capacity deletes nothing; the higher slot is merely unusable"
9341 );
9342
9343 harness.policy.set_max_capacity(nz(2)).unwrap();
9345 let third = harness.launch().await;
9346 assert_eq!(third.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
9347 assert_eq!(third.runtime_path(), harness.slot_path(2));
9348 assert_eq!(fs::read_to_string(&kept).unwrap(), "s2 was here");
9349 assert!(first.holds_slot_lease(), "s1 was never disturbed");
9350 }
9351
9352 #[tokio::test]
9353 async fn organization_and_ephemeral_policies_never_enter_slot_allocation() {
9354 for policy in [
9355 fixtures::policy()
9356 .organization("octo")
9357 .autoscale("home", 2)
9358 .active()
9359 .build(),
9360 fixtures::policy()
9361 .repository("octo/repo")
9362 .autoscale("home", 2)
9363 .active()
9364 .build(),
9365 ] {
9366 let mut harness =
9367 Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9368 .with_host_runner_root();
9369 assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
9370 harness.policy = policy;
9371 harness.ready().await;
9372
9373 let attempt = harness.launch().await;
9374 assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
9375 assert_eq!(attempt.workspace().slot_number(), None);
9376 assert!(!attempt.holds_slot_lease());
9377 assert_eq!(
9378 attempt.runtime_path().parent().unwrap(),
9379 harness.host_root(),
9380 "a disposable attempt is a child of the host root, never of a slot"
9381 );
9382 assert!(
9383 harness
9384 .store
9385 .slot_leases_for_policy(harness.policy.id)
9386 .unwrap()
9387 .is_empty()
9388 );
9389 }
9390 }
9391
9392 #[tokio::test]
9393 async fn two_concurrent_allocations_never_share_a_slot() {
9394 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9395 .with_persistent_workspace(2);
9396 harness.github.watch_journal(Arc::clone(&harness.store));
9397 harness.ready().await;
9398
9399 let (first, second) = tokio::join!(harness.launch_result(), harness.launch_result());
9403 let first = first.unwrap();
9404 let second = second.unwrap();
9405
9406 let slots: BTreeSet<u16> = [&first, &second]
9407 .iter()
9408 .map(|attempt| {
9409 attempt
9410 .workspace()
9411 .slot_number()
9412 .expect("a persistent attempt leases a slot")
9413 })
9414 .collect();
9415 assert_eq!(slots, BTreeSet::from([1, 2]), "one slot each, never shared");
9416 assert_ne!(first.runtime_path(), second.runtime_path());
9417 assert_eq!(
9418 harness
9419 .store
9420 .slot_leases_for_policy(harness.policy.id)
9421 .unwrap()
9422 .len(),
9423 2
9424 );
9425
9426 let facts = harness.github.registration_facts();
9431 assert_eq!(facts.len(), 2);
9432 for fact in facts {
9433 let attempt = [&first, &second]
9434 .into_iter()
9435 .find(|attempt| runner_name(attempt.id) == fact.runner_name)
9436 .expect("every registration belongs to one of the two attempts");
9437 let slot = attempt
9438 .workspace()
9439 .slot_number()
9440 .expect("a persistent attempt leases a slot");
9441 assert!(
9442 fact.leased_slots.contains(&slot),
9443 "a JIT request never precedes its own lease: s{slot} not in {:?}",
9444 fact.leased_slots
9445 );
9446 assert_eq!(fact.work_folder, DEFAULT_WORK_FOLDER);
9447 }
9448 }
9449
9450 #[tokio::test]
9451 async fn the_database_is_the_final_fence_against_two_attempts_in_one_slot() {
9452 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9453 .with_persistent_workspace(2);
9454 harness.ready().await;
9455 let first = harness.launch().await;
9456
9457 let clash = RunnerAttempt::allocate_in(
9461 AttemptId::new_random(),
9462 harness.policy.id,
9463 first.runtime_path(),
9464 AttemptWorkspace::persistent_slot(nz(1)),
9465 harness.clock.now(),
9466 );
9467 assert!(matches!(
9468 harness.store.record_attempt(&clash).unwrap_err(),
9469 StoreError::SlotAlreadyLeased { slot: 1, .. }
9470 ));
9471
9472 let error = harness.launcher.record_allocation(&clash).unwrap_err();
9475 let rendered = error.to_string();
9476 assert!(rendered.contains("slot s1"), "{rendered}");
9477 assert!(rendered.contains("nothing was written"), "{rendered}");
9478 assert_eq!(
9479 harness.store.attempts().unwrap().len(),
9480 1,
9481 "the losing allocator journalled nothing"
9482 );
9483 }
9484
9485 #[test]
9486 fn slot_selection_fills_the_lowest_gap_and_stops_at_the_ceiling() {
9487 let leased = |slots: &[u16]| -> Vec<RunnerAttempt> {
9488 slots
9489 .iter()
9490 .map(|slot| {
9491 RunnerAttempt::allocate_in(
9492 AttemptId::new_random(),
9493 fixtures::POLICY_ID,
9494 format!("/srv/rman/acme/s{slot}"),
9495 AttemptWorkspace::persistent_slot(nz(*slot)),
9496 fixtures::created_at(),
9497 )
9498 })
9499 .collect()
9500 };
9501
9502 assert_eq!(lowest_free_slot(&[], nz(1)), Some(nz(1)));
9503 assert_eq!(lowest_free_slot(&leased(&[1]), nz(4)), Some(nz(2)));
9504 assert_eq!(lowest_free_slot(&leased(&[1, 3]), nz(4)), Some(nz(2)));
9506 assert_eq!(lowest_free_slot(&leased(&[1]), nz(1)), None);
9508 assert_eq!(lowest_free_slot(&leased(&[1, 2]), nz(2)), None);
9509 let ephemeral = vec![RunnerAttempt::allocate(
9511 AttemptId::new_random(),
9512 fixtures::POLICY_ID,
9513 "/srv/rman/host/abc",
9514 fixtures::created_at(),
9515 )];
9516 assert_eq!(lowest_free_slot(&ephemeral, nz(1)), Some(nz(1)));
9517 }
9518
9519 #[test]
9520 fn a_slot_is_reusable_only_when_it_is_empty_or_holds_one_real_work_directory() {
9521 let root = tempfile::tempdir().unwrap();
9522 let slot = root.path().join("s1");
9523 fs::create_dir(&slot).unwrap();
9524 accept_reusable_slot(&slot).expect("an empty slot is reusable");
9525
9526 fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
9527 accept_reusable_slot(&slot).expect("a retained job workspace is reusable");
9528
9529 fs::create_dir(slot.join("bin")).unwrap();
9532 fs::write(slot.join(".github-runner-id"), b"73").unwrap();
9533 let refusal = accept_reusable_slot(&slot).unwrap_err().to_string();
9534 assert!(refusal.contains("bin"), "{refusal}");
9535 assert!(refusal.contains(".github-runner-id"), "{refusal}");
9536
9537 let file_work = root.path().join("s2");
9539 fs::create_dir(&file_work).unwrap();
9540 fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
9541 assert!(accept_reusable_slot(&file_work).is_err());
9542 }
9543
9544 #[cfg(unix)]
9545 #[test]
9546 fn a_link_shaped_work_directory_is_refused_rather_than_followed() {
9547 let root = tempfile::tempdir().unwrap();
9551 let elsewhere = root.path().join("elsewhere");
9552 fs::create_dir(&elsewhere).unwrap();
9553
9554 let slot = root.path().join("s1");
9555 fs::create_dir(&slot).unwrap();
9556 std::os::unix::fs::symlink(&elsewhere, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
9557 assert!(accept_reusable_slot(&slot).is_err());
9558
9559 let linked_slot = root.path().join("s2");
9560 std::os::unix::fs::symlink(&elsewhere, &linked_slot).unwrap();
9561 assert!(create_or_validate_slot(&linked_slot).is_err());
9562 }
9563
9564 #[test]
9565 fn a_slot_standing_where_a_file_is_refuses_rather_than_replacing_it() {
9566 let root = tempfile::tempdir().unwrap();
9567 let occupied = root.path().join("s1");
9568 fs::write(&occupied, b"an operator's file").unwrap();
9569 let refusal = create_or_validate_slot(&occupied).unwrap_err().to_string();
9570 assert!(refusal.contains("is not a directory"), "{refusal}");
9571 assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
9572
9573 let fresh = root.path().join("s2");
9574 create_or_validate_slot(&fresh).expect("a missing slot is created");
9575 assert!(fresh.is_dir());
9576 create_or_validate_slot(&fresh).expect("an existing directory is accepted");
9577 }
9578
9579 #[test]
9580 fn the_retained_work_directory_is_matched_the_way_the_filesystem_matches_it() {
9581 assert!(is_work_folder(OsStr::new(DEFAULT_WORK_FOLDER)));
9582 assert!(!is_work_folder(OsStr::new("_work2")));
9583 assert_eq!(is_work_folder(OsStr::new("_Work")), cfg!(windows));
9587 }
9588
9589 #[test]
9590 fn package_materialization_never_overwrites_or_follows_a_retained_work_directory() {
9591 let root = tempfile::tempdir().unwrap();
9592 let package = root.path().join("package");
9593 fs::create_dir_all(package.join("bin")).unwrap();
9594 fs::write(package.join("bin").join("Runner.Listener"), b"binary").unwrap();
9595 fs::create_dir_all(package.join("externals").join(DEFAULT_WORK_FOLDER)).unwrap();
9597
9598 let slot = root.path().join("s1");
9599 let retained = slot.join(DEFAULT_WORK_FOLDER).join("repo");
9600 fs::create_dir_all(&retained).unwrap();
9601 fs::write(retained.join("checkout.txt"), b"from the first job").unwrap();
9602
9603 copy_package_tree(&package, &slot, &TrustedTrees::default())
9604 .expect("the package lays out around `_work`");
9605 assert!(slot.join("bin").join("Runner.Listener").exists());
9606 assert!(
9607 slot.join("externals").join(DEFAULT_WORK_FOLDER).is_dir(),
9608 "the guard is top-level only"
9609 );
9610 assert_eq!(
9611 fs::read_to_string(retained.join("checkout.txt")).unwrap(),
9612 "from the first job"
9613 );
9614
9615 fs::create_dir(package.join(DEFAULT_WORK_FOLDER)).unwrap();
9617 let error = copy_package_tree(&package, &slot, &TrustedTrees::default()).unwrap_err();
9618 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
9619 assert_eq!(
9620 fs::read_to_string(retained.join("checkout.txt")).unwrap(),
9621 "from the first job"
9622 );
9623 }
9624
9625 #[test]
9626 fn rolling_back_a_materialization_keeps_a_slot_but_removes_a_disposable_directory() {
9627 let root = tempfile::tempdir().unwrap();
9628
9629 let slot = root.path().join("s1");
9630 let retained = slot.join(DEFAULT_WORK_FOLDER);
9631 fs::create_dir_all(retained.join("repo")).unwrap();
9632 fs::write(retained.join("repo").join("checkout.txt"), b"kept").unwrap();
9633 fs::create_dir_all(slot.join("bin")).unwrap();
9634 fs::write(slot.join(".github-runner-id"), b"73").unwrap();
9635 let persistent = RunnerAttempt::allocate_in(
9636 AttemptId::new_random(),
9637 fixtures::POLICY_ID,
9638 &slot,
9639 AttemptWorkspace::persistent_slot(nz(1)),
9640 fixtures::created_at(),
9641 );
9642
9643 remove_materialized_package(&persistent).unwrap();
9644 assert!(slot.is_dir(), "the slot itself is not removed");
9645 assert!(!slot.join("bin").exists());
9646 assert!(!slot.join(".github-runner-id").exists());
9647 assert_eq!(
9648 fs::read_to_string(retained.join("repo").join("checkout.txt")).unwrap(),
9649 "kept"
9650 );
9651
9652 let disposable_path = root.path().join("abcdef012345");
9653 fs::create_dir_all(disposable_path.join(DEFAULT_WORK_FOLDER)).unwrap();
9654 let disposable = RunnerAttempt::allocate(
9655 AttemptId::new_random(),
9656 fixtures::POLICY_ID,
9657 &disposable_path,
9658 fixtures::created_at(),
9659 );
9660 remove_materialized_package(&disposable).unwrap();
9661 assert!(
9662 !disposable_path.exists(),
9663 "a disposable directory is still removed whole"
9664 );
9665 }
9666
9667 fn litter_the_slot(slot: &Path) {
9677 for directory in ["bin", "externals", "_diag"] {
9678 fs::create_dir_all(slot.join(directory)).unwrap();
9679 }
9680 fs::write(slot.join("bin").join("Runner.Listener"), b"binary").unwrap();
9681 for file in SENSITIVE_SLOT_ENTRIES
9682 .iter()
9683 .filter(|entry| !slot.join(entry).is_dir())
9684 {
9685 fs::write(slot.join(file), b"runner state").unwrap();
9686 }
9687 fs::write(
9689 slot.join(format!(
9690 "{}0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.tmp",
9691 RestrictiveHandoff::NAME_PREFIX
9692 )),
9693 JIT.as_bytes(),
9694 )
9695 .unwrap();
9696 }
9697
9698 fn retain_under_work(slot: &Path) -> PathBuf {
9700 let checkout = slot.join(DEFAULT_WORK_FOLDER).join("repo").join("target");
9701 fs::create_dir_all(&checkout).unwrap();
9702 let marker = checkout.join("build-output.bin");
9703 fs::write(&marker, RETAINED).unwrap();
9704 marker
9705 }
9706
9707 const RETAINED: &str = "a Git-ignored build output the next job reuses";
9709
9710 fn entries_of(directory: &Path) -> Vec<String> {
9712 let mut names: Vec<String> = fs::read_dir(directory)
9713 .unwrap()
9714 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
9715 .collect();
9716 names.sort();
9717 names
9718 }
9719
9720 fn only_the_job_workspace() -> Vec<String> {
9722 vec![DEFAULT_WORK_FOLDER.to_owned()]
9723 }
9724
9725 #[cfg(unix)]
9726 #[test]
9727 fn disposable_tree_removal_does_not_open_a_dotnet_diagnostic_fifo() {
9728 use std::sync::mpsc;
9729
9730 let temporary = tempfile::tempdir().unwrap();
9731 let tree = temporary.path().join("attempt");
9732 let diagnostic = tree.join("tmp/clr-debug-pipe-runner-in");
9733 fs::create_dir_all(diagnostic.parent().unwrap()).unwrap();
9734 assert!(
9735 std::process::Command::new("mkfifo")
9736 .arg(&diagnostic)
9737 .status()
9738 .unwrap()
9739 .success()
9740 );
9741
9742 let (finished, result) = mpsc::channel();
9743 std::thread::spawn(move || {
9744 let removed = remove_runtime_tree(&tree);
9745 let _ = finished.send(removed);
9746 });
9747
9748 result
9749 .recv_timeout(Duration::from_secs(2))
9750 .expect("runtime deletion must not wait for a FIFO peer")
9751 .unwrap();
9752 assert!(!diagnostic.exists());
9753 }
9754
9755 #[cfg(windows)]
9758 #[test]
9759 fn disposable_tree_removal_unlinks_a_junction_nobody_may_list() {
9760 let temporary = tempfile::tempdir().unwrap();
9761 let tree = temporary.path().join("attempt");
9762 let cache = tree.join(r"tmp\home\AppData\Local\Microsoft\Windows\INetCache");
9763 let outside = temporary.path().join("outside");
9764 fs::create_dir_all(&cache).unwrap();
9765 fs::create_dir_all(&outside).unwrap();
9766 fs::write(outside.join("keep.txt"), b"not the attempt's").unwrap();
9767 fs::write(tree.join("tmp").join("job.log"), b"job output").unwrap();
9768 let junction = cache.join("Content.IE5");
9769 let created = std::process::Command::new("cmd")
9770 .arg("/C")
9771 .arg("mklink")
9772 .arg("/J")
9773 .arg(&junction)
9774 .arg(&outside)
9775 .output()
9776 .unwrap();
9777 assert!(created.status.success(), "{created:?}");
9778 let denied = std::process::Command::new("icacls")
9782 .arg(&junction)
9783 .args(["/L", "/deny", "*S-1-1-0:(RD)"])
9784 .output()
9785 .unwrap();
9786 assert!(denied.status.success(), "{denied:?}");
9787
9788 let unfixed = remove_dir_all::remove_dir_all(&tree)
9790 .expect_err("the fixture no longer reproduces the WinINet junction");
9791 assert_eq!(unfixed.kind(), std::io::ErrorKind::PermissionDenied);
9792 assert!(junction.symlink_metadata().is_ok());
9793
9794 remove_runtime_tree(&tree).expect("a tree holding a deny-list junction is removed");
9795 assert!(!tree.exists(), "the attempt tree survived");
9796 assert_eq!(
9797 fs::read(outside.join("keep.txt")).unwrap(),
9798 b"not the attempt's",
9799 "removing the junction followed it into the directory it points at"
9800 );
9801 }
9802
9803 struct BlockedDeletion {
9819 directory: PathBuf,
9820 #[cfg(windows)]
9821 _handle: fs::File,
9822 }
9823
9824 impl BlockedDeletion {
9825 const HELD: &'static str = "held-open";
9826
9827 fn inject(directory: &Path) -> Option<Self> {
9830 #[cfg(unix)]
9831 if !Self::refusal_is_possible() {
9832 return None;
9833 }
9834 fs::create_dir_all(directory).unwrap();
9835 fs::write(
9836 directory.join(Self::HELD),
9837 b"a file the scrub cannot remove",
9838 )
9839 .unwrap();
9840 #[cfg(windows)]
9841 let handle = {
9842 use std::os::windows::fs::OpenOptionsExt;
9843
9844 fs::OpenOptions::new()
9845 .read(true)
9846 .share_mode(0)
9847 .open(directory.join(Self::HELD))
9848 .expect("the blocking handle opens")
9849 };
9850 #[cfg(unix)]
9851 Self::set_mode(directory, 0o555);
9852 Some(Self {
9853 directory: directory.to_path_buf(),
9854 #[cfg(windows)]
9855 _handle: handle,
9856 })
9857 }
9858
9859 fn release(self) {
9860 drop(self);
9861 }
9862
9863 #[cfg(unix)]
9864 fn refusal_is_possible() -> bool {
9865 let probe = tempfile::tempdir().unwrap();
9866 let directory = probe.path().join("probe");
9867 fs::create_dir(&directory).unwrap();
9868 fs::write(directory.join("file"), b"probe").unwrap();
9869 Self::set_mode(&directory, 0o555);
9870 let refused = fs::remove_dir_all(&directory).is_err();
9871 Self::set_mode(&directory, 0o755);
9872 refused
9873 }
9874
9875 #[cfg(unix)]
9876 fn set_mode(directory: &Path, mode: u32) {
9877 use std::os::unix::fs::PermissionsExt;
9878
9879 let mut permissions = fs::metadata(directory).unwrap().permissions();
9880 permissions.set_mode(mode);
9881 fs::set_permissions(directory, permissions).unwrap();
9882 }
9883 }
9884
9885 impl Drop for BlockedDeletion {
9886 fn drop(&mut self) {
9887 #[cfg(unix)]
9888 Self::set_mode(&self.directory, 0o755);
9889 #[cfg(not(unix))]
9890 let _ = &self.directory;
9891 }
9892 }
9893
9894 #[tokio::test]
9895 async fn two_sequential_jobs_keep_the_checkout_and_start_without_the_earlier_runner_state() {
9896 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9897 .with_persistent_workspace(1);
9898 harness.ready().await;
9899
9900 let first = harness.launch().await;
9901 let slot = harness.slot_path(1);
9902 assert_eq!(first.runtime_path(), slot);
9903 assert_eq!(
9904 read_runner_id(&slot),
9905 Some(73),
9906 "the attempt registered, so its identity is on disk"
9907 );
9908 let marker = retain_under_work(&slot);
9909 litter_the_slot(&slot);
9910
9911 harness.cleanup_retaining_work(first.id).await;
9912
9913 assert_eq!(entries_of(&slot), only_the_job_workspace());
9916 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
9917 assert_eq!(
9918 read_runner_id(&slot),
9919 None,
9920 "the first attempt's registration identity is gone before the second starts"
9921 );
9922 assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
9923 assert!(!harness.attempt(first.id).holds_slot_lease());
9924
9925 let second = harness.launch().await;
9926 assert_ne!(second.id, first.id);
9927 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(1)));
9928 assert_eq!(
9929 second.runtime_path(),
9930 slot,
9931 "the same slot, so the same retained `_work`"
9932 );
9933 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
9934 }
9935
9936 #[tokio::test]
9937 async fn cleaning_a_persistent_slot_needs_no_policy_and_scans_no_directory_for_ownership() {
9938 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9939 .with_persistent_workspace(1);
9940 harness.ready().await;
9941 let attempt = harness.launch().await;
9942 let slot = harness.slot_path(1);
9943 let marker = retain_under_work(&slot);
9944 litter_the_slot(&slot);
9945 harness.conclude(attempt.id);
9946
9947 harness
9953 .store
9954 .remove_policy(harness.policy.id, harness.policy.revision())
9955 .unwrap();
9956 assert!(harness.store.policy(harness.policy.id).unwrap().is_none());
9957
9958 harness
9959 .launcher
9960 .clean(attempt.id)
9961 .await
9962 .expect("journal facts alone are enough to clean the slot");
9963
9964 assert_eq!(entries_of(&slot), only_the_job_workspace());
9965 assert!(marker.exists());
9966 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
9967 }
9968
9969 #[tokio::test]
9970 async fn an_injected_partial_deletion_quarantines_the_slot_across_a_restart() {
9971 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9972 .with_persistent_workspace(2);
9973 harness.ready().await;
9974 let first = harness.launch().await;
9975 let slot = harness.slot_path(1);
9976 let marker = retain_under_work(&slot);
9977 litter_the_slot(&slot);
9978 harness.conclude(first.id);
9979
9980 let Some(block) = BlockedDeletion::inject(&slot.join("bin")) else {
9981 eprintln!(
9982 "skipped: this account cannot be refused a deletion, so no partial deletion can \
9983 be injected"
9984 );
9985 return;
9986 };
9987
9988 let refusal = harness
9989 .launcher
9990 .clean(first.id)
9991 .await
9992 .expect_err("a deletion that failed may not report a cleaned slot");
9993 let rendered = refusal.reason.to_string();
9994 assert!(rendered.contains("could not be removed"), "{rendered}");
9995
9996 let held = harness.attempt(first.id);
9997 assert_eq!(held.state(), AttemptState::Failed, "still not cleaned");
9998 assert!(held.holds_slot_lease(), "so the slot is still leased");
9999 assert!(
10000 !held.state().counts_against_capacity(),
10001 "and a concluded attempt still costs the host no capacity"
10002 );
10003
10004 let restarted = harness.restart();
10009 restarted
10010 .recover_startup(std::slice::from_ref(&harness.policy))
10011 .await
10012 .expect("one quarantined slot does not stop the host recovering");
10013 assert_eq!(
10014 harness.attempt(first.id).state(),
10015 AttemptState::Failed,
10016 "the quarantine survived the restart"
10017 );
10018 assert!(
10019 harness
10020 .reconcile_events
10021 .events()
10022 .iter()
10023 .any(|event| matches!(
10024 event,
10025 LifecycleEvent::AttemptCleanFailed {
10026 reason: "slot_entry_could_not_be_removed",
10027 ..
10028 }
10029 )),
10030 "the refusal is reported rather than retried in silence"
10031 );
10032
10033 let guard = harness.allocation_lock.acquire().await.unwrap();
10036 let second = restarted
10037 .launch(LaunchRequest {
10038 host: &harness.host,
10039 policy: &harness.policy,
10040 allocation_guard: &guard,
10041 lock_wait: Duration::ZERO,
10042 })
10043 .await
10044 .expect("the host can still launch");
10045 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
10046 drop(guard);
10047
10048 block.release();
10051 restarted
10052 .clean(first.id)
10053 .await
10054 .expect("the retried cleanup completes");
10055 assert_eq!(entries_of(&slot), only_the_job_workspace());
10056 assert!(marker.exists());
10057 assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
10058 }
10059
10060 #[tokio::test]
10071 async fn an_injected_deletion_failure_leaves_a_disposable_attempt_uncleaned() {
10072 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
10073 harness.ready().await;
10074 let attempt = harness.launch().await;
10075 let runtime = attempt.runtime_path().to_path_buf();
10076 assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
10077 harness.conclude(attempt.id);
10078
10079 let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
10080 eprintln!(
10081 "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
10082 );
10083 return;
10084 };
10085
10086 let refusal = harness
10087 .launcher
10088 .clean(attempt.id)
10089 .await
10090 .expect_err("a deletion that failed may not report a removed workspace");
10091 let rendered = refusal.reason.to_string();
10092 assert!(
10093 rendered.contains("could not be removed"),
10094 "the refusal names what happened: {rendered}"
10095 );
10096 assert_ne!(
10097 harness.attempt(attempt.id).state(),
10098 AttemptState::Cleaned,
10099 "an attempt whose directory is still on disk is not cleaned"
10100 );
10101 assert!(
10102 runtime.is_dir(),
10103 "the directory the removal could not finish is still there, which is the fact the journal must keep agreeing with"
10104 );
10105
10106 let restarted = harness.restart();
10107 restarted
10108 .recover_startup(std::slice::from_ref(&harness.policy))
10109 .await
10110 .expect("one locked ephemeral workspace does not stop startup recovery");
10111 assert!(
10112 harness
10113 .reconcile_events
10114 .events()
10115 .iter()
10116 .any(|event| matches!(
10117 event,
10118 LifecycleEvent::AttemptCleanFailed {
10119 attempt: failed_attempt,
10120 reason: "ephemeral_workspace_could_not_be_removed",
10121 ..
10122 } if *failed_attempt == attempt.id
10123 )),
10124 "the deferred cleanup remains visible while the daemon keeps running"
10125 );
10126
10127 block.release();
10130 harness
10131 .launcher
10132 .clean(attempt.id)
10133 .await
10134 .expect("the retried cleanup completes");
10135 assert!(!runtime.exists(), "the whole attempt directory goes");
10136 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
10137 }
10138
10139 #[tokio::test]
10143 async fn supervision_backs_off_a_workspace_it_cannot_remove() {
10144 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
10145 harness.ready().await;
10146 let attempt = harness.launch().await;
10147 let runtime = attempt.runtime_path().to_path_buf();
10148 harness.conclude(attempt.id);
10149 let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
10150 eprintln!(
10151 "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
10152 );
10153 return;
10154 };
10155 let failures = || {
10156 harness
10157 .reconcile_events
10158 .events()
10159 .iter()
10160 .filter(|event| {
10161 matches!(
10162 event,
10163 LifecycleEvent::AttemptCleanFailed { attempt: failed, .. }
10164 if *failed == attempt.id
10165 )
10166 })
10167 .count()
10168 };
10169 let supervise = async || {
10170 harness
10171 .launcher
10172 .supervise(&harness.policy)
10173 .await
10174 .expect("a stuck workspace does not fail supervision");
10175 };
10176
10177 supervise().await;
10178 assert_eq!(failures(), 1);
10179 supervise().await;
10180 assert_eq!(
10181 failures(),
10182 1,
10183 "the failed cleanup was retried on the very next pass"
10184 );
10185 harness.clock.advance_secs(31);
10186 supervise().await;
10187 assert_eq!(
10188 failures(),
10189 2,
10190 "the cleanup was not retried once its delay passed"
10191 );
10192
10193 block.release();
10194 harness.clock.advance_secs(61);
10195 supervise().await;
10196 assert!(
10197 !runtime.exists(),
10198 "the retry removes the workspace once it can"
10199 );
10200 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
10201 }
10202
10203 #[tokio::test]
10204 async fn changing_a_repository_back_to_ephemeral_leaves_every_old_slot_untouched() {
10205 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10206 .with_persistent_workspace(1);
10207 harness.ready().await;
10208 let first = harness.launch().await;
10209 let slot = harness.slot_path(1);
10210 let marker = retain_under_work(&slot);
10211 harness.cleanup_retaining_work(first.id).await;
10212
10213 harness
10217 .policy
10218 .set_workspace_policy(WorkspacePolicy::Ephemeral)
10219 .unwrap();
10220
10221 let second = harness.launch().await;
10222 assert_eq!(second.workspace(), AttemptWorkspace::Ephemeral);
10223 assert_eq!(
10224 second.runtime_path().parent().unwrap(),
10225 harness.host_root(),
10226 "a disposable attempt is a child of the host root"
10227 );
10228 assert!(slot.is_dir(), "the old slot is left where it stands");
10229 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
10230
10231 harness.conclude(second.id);
10234 harness.launcher.clean(second.id).await.unwrap();
10235 assert!(!second.runtime_path().exists());
10236 assert!(marker.exists());
10237 }
10238
10239 #[tokio::test]
10240 async fn a_persistent_slot_is_scrubbed_only_after_the_process_is_signalled_and_gone() {
10241 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10242 .with_persistent_workspace(1);
10243 harness.ready().await;
10244 let slot = harness.slot_path(1);
10245 fs::create_dir_all(&slot).unwrap();
10246 let marker = retain_under_work(&slot);
10247 litter_the_slot(&slot);
10248
10249 let id = AttemptId::new_random();
10250 let mut attempt = RunnerAttempt::allocate_in(
10251 id,
10252 harness.policy.id,
10253 &slot,
10254 AttemptWorkspace::persistent_slot(nz(1)),
10255 harness.clock.now(),
10256 );
10257 attempt.jit_received(harness.clock.now()).unwrap();
10258 attempt.started(4242, harness.clock.now()).unwrap();
10259 harness.store.record_attempt(&attempt).unwrap();
10260 harness.clock.advance_secs(11);
10261 harness.processes.set_alive(true);
10262 harness
10263 .github
10264 .observe(GithubRunnerObservation::NotRegistered);
10265
10266 harness.launcher.supervise(&harness.policy).await.unwrap();
10267
10268 let actions = harness.processes.actions.lock().unwrap().clone();
10272 let intent = actions
10273 .iter()
10274 .position(|action| *action == "terminate_intent")
10275 .unwrap();
10276 let signal = actions
10277 .iter()
10278 .position(|action| *action == "terminate")
10279 .unwrap();
10280 assert!(intent < signal, "{actions:?}");
10281 assert!(!harness.processes.alive.load(Ordering::SeqCst));
10282
10283 let cleaned = harness.attempt(id);
10284 assert_eq!(cleaned.state(), AttemptState::Cleaned);
10285 assert!(matches!(
10286 cleaned.outcome(),
10287 Some(AttemptOutcome::Failed {
10288 reason: FailureReason::TerminatedAfterRegistrationTimeout
10289 })
10290 ));
10291 assert_eq!(entries_of(&slot), only_the_job_workspace());
10292 assert!(marker.exists());
10293 }
10294
10295 #[test]
10296 fn a_scrub_retains_one_real_work_directory_and_removes_every_other_entry() {
10297 let root = tempfile::tempdir().unwrap();
10298 let slot = root.path().join("s1");
10299 fs::create_dir(&slot).unwrap();
10300 let marker = retain_under_work(&slot);
10301 litter_the_slot(&slot);
10302 fs::write(slot.join("runner-package"), b"verified").unwrap();
10303
10304 scrub_slot_entries(&slot).expect("a slot of ordinary runner state scrubs");
10305 verify_slot_scrubbed(&slot).expect("and proves it afterwards");
10306
10307 assert_eq!(entries_of(&slot), only_the_job_workspace());
10308 assert!(marker.exists());
10309 }
10310
10311 #[test]
10312 fn a_residue_refusal_never_reports_the_under_count_as_the_fact() {
10313 let slot = Path::new("/runners/s1");
10314
10315 let counted = residue_detail(slot, 2, &["`bin`".to_owned()]);
10318 assert!(counted.contains("2 entries other than"), "{counted}");
10319 assert!(counted.contains("including `bin`"), "{counted}");
10320 assert_eq!(
10321 residue_detail(slot, 1, &[]),
10322 format!(
10323 "1 entry other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}",
10324 slot.display()
10325 )
10326 );
10327
10328 let raced = residue_detail(slot, 0, &["`.credentials`".to_owned()]);
10333 assert!(!raced.contains('0'), "{raced}");
10334 assert!(raced.contains("reported nothing but"), "{raced}");
10335 assert!(raced.contains("`.credentials` survived cleanup"), "{raced}");
10336 }
10337
10338 #[test]
10339 fn verification_asks_the_filesystem_rather_than_the_listing_that_missed_an_entry() {
10340 let root = tempfile::tempdir().unwrap();
10341 let slot = root.path().join("s1");
10342 fs::create_dir(&slot).unwrap();
10343 fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10344 verify_slot_scrubbed(&slot).expect("only `_work` is a clean slot");
10345
10346 for survivor in ["bin", ".credentials", IDENTITY_FILE, RUNNER_ID_FILE] {
10350 fs::write(slot.join(survivor), b"left behind").unwrap();
10351 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
10352 assert_eq!(quarantine.refusal, SlotRefusal::Residue);
10353 assert!(
10354 quarantine.detail.contains(&format!("`{survivor}`")),
10355 "{quarantine}"
10356 );
10357 fs::remove_file(slot.join(survivor)).unwrap();
10358 }
10359
10360 let handoff = slot.join(format!("{}whatever.tmp", RestrictiveHandoff::NAME_PREFIX));
10363 fs::write(&handoff, JIT.as_bytes()).unwrap();
10364 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
10365 assert!(
10366 quarantine.detail.contains("an encoded JIT handoff"),
10367 "{quarantine}"
10368 );
10369 assert!(!quarantine.detail.contains(JIT), "{quarantine}");
10370 fs::remove_file(&handoff).unwrap();
10371
10372 fs::write(slot.join("ghp_DO_NOT_LEAK"), b"named by the job").unwrap();
10376 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
10377 assert!(
10378 quarantine.detail.contains("1 entry other than"),
10379 "{quarantine}"
10380 );
10381 assert!(
10382 !quarantine.detail.contains("ghp_DO_NOT_LEAK"),
10383 "{quarantine}"
10384 );
10385 }
10386
10387 #[test]
10388 fn a_slot_is_derived_from_the_journal_and_refused_when_it_disagrees() {
10389 let root = tempfile::tempdir().unwrap();
10390 let configured =
10391 LocalAbsolutePath::new(root.path().to_str().unwrap()).expect("a local absolute root");
10392 let slot = configured.as_path().join("s1");
10393 fs::create_dir(&slot).unwrap();
10394
10395 verify_journalled_slot(&slot, nz(1), Some(&configured))
10396 .expect("the journalled slot agrees");
10397 verify_journalled_slot(&slot, nz(1), None)
10398 .expect("and a policy that is gone removes a check, not the ability to clean");
10399
10400 assert_eq!(
10403 verify_journalled_slot(&slot, nz(2), None)
10404 .unwrap_err()
10405 .refusal,
10406 SlotRefusal::NotTheJournalledSlot
10407 );
10408 for stray in ["s1/nested", "not-a-slot", "s01"] {
10409 let path = configured.as_path().join(stray);
10410 assert_eq!(
10411 verify_journalled_slot(&path, nz(1), None)
10412 .unwrap_err()
10413 .refusal,
10414 SlotRefusal::NotTheJournalledSlot,
10415 "{}",
10416 path.display()
10417 );
10418 }
10419
10420 let elsewhere = tempfile::tempdir().unwrap();
10423 let other =
10424 LocalAbsolutePath::new(elsewhere.path().to_str().unwrap()).expect("a second root");
10425 assert_eq!(
10426 verify_journalled_slot(&slot, nz(1), Some(&other))
10427 .unwrap_err()
10428 .refusal,
10429 SlotRefusal::PolicyRootDisagrees
10430 );
10431 }
10432
10433 #[cfg(unix)]
10434 #[test]
10435 fn a_substituted_work_directory_quarantines_the_slot_and_deletes_nothing_outside_it() {
10436 let root = tempfile::tempdir().unwrap();
10440 let outside = root.path().join("operator-data");
10441 fs::create_dir(&outside).unwrap();
10442 let sentinel = outside.join("do-not-delete.txt");
10443 fs::write(
10444 &sentinel,
10445 b"an operator's data, outside every approved root",
10446 )
10447 .unwrap();
10448
10449 let slot = root.path().join("s1");
10450 fs::create_dir(&slot).unwrap();
10451 fs::create_dir(slot.join("bin")).unwrap();
10452 std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10453
10454 let quarantine = scrub_slot_entries(&slot).unwrap_err();
10455 assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
10456 assert!(
10457 sentinel.exists(),
10458 "the deletion followed the link out of the slot"
10459 );
10460 assert!(outside.is_dir());
10461 assert!(
10462 slot.join(DEFAULT_WORK_FOLDER).symlink_metadata().is_ok(),
10463 "the substituted link is left for the operator, never unlinked as if it were ours"
10464 );
10465
10466 let file_work = root.path().join("s2");
10470 fs::create_dir(&file_work).unwrap();
10471 fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
10472 assert_eq!(
10473 scrub_slot_entries(&file_work).unwrap_err().refusal,
10474 SlotRefusal::WorkNotADirectory
10475 );
10476 }
10477
10478 #[cfg(unix)]
10479 #[test]
10480 fn a_slot_replaced_by_a_link_out_of_its_root_is_refused_before_anything_is_read() {
10481 let root = tempfile::tempdir().unwrap();
10482 let outside = root.path().join("operator-data");
10483 fs::create_dir(&outside).unwrap();
10484 let sentinel = outside.join("do-not-delete.txt");
10485 fs::write(
10486 &sentinel,
10487 b"an operator's data, outside every approved root",
10488 )
10489 .unwrap();
10490
10491 let inside = root.path().join("inside");
10494 fs::create_dir(&inside).unwrap();
10495 let slot = inside.join("s1");
10496 std::os::unix::fs::symlink(&outside, &slot).unwrap();
10497
10498 assert_eq!(
10499 verify_journalled_slot(&slot, nz(1), None)
10500 .unwrap_err()
10501 .refusal,
10502 SlotRefusal::Containment
10503 );
10504 assert!(sentinel.exists());
10505 assert!(
10506 slot.symlink_metadata().is_ok(),
10507 "the link is left for the operator rather than removed as if it were ours"
10508 );
10509 }
10510
10511 #[cfg(windows)]
10521 #[test]
10522 fn a_slot_root_replaced_by_a_junction_is_refused_before_anything_is_read() {
10523 let root = tempfile::tempdir().unwrap();
10524 let outside = root.path().join("operator-data");
10525 fs::create_dir(&outside).unwrap();
10526 let sentinel = outside.join("do-not-delete.txt");
10527 fs::write(
10528 &sentinel,
10529 b"an operator's data, outside every approved root",
10530 )
10531 .unwrap();
10532
10533 let inside = root.path().join("inside");
10536 fs::create_dir(&inside).unwrap();
10537 let slot = inside.join("s1");
10538 let Some(()) = plant_junction(&slot, &outside) else {
10539 eprintln!("skipped: this machine would not create a directory junction");
10540 return;
10541 };
10542
10543 assert_eq!(
10544 verify_journalled_slot(&slot, nz(1), None)
10545 .unwrap_err()
10546 .refusal,
10547 SlotRefusal::Containment
10548 );
10549 assert!(
10550 sentinel.exists(),
10551 "the refusal resolved the junction and reached the operator's data"
10552 );
10553 assert!(
10554 slot.symlink_metadata().is_ok(),
10555 "the junction is left for the operator rather than removed as if it were ours"
10556 );
10557 }
10558
10559 #[cfg(windows)]
10568 fn plant_junction(link: &Path, target: &Path) -> Option<()> {
10569 let made = std::process::Command::new("cmd")
10570 .arg("/C")
10571 .arg("mklink")
10572 .arg("/J")
10573 .arg(link)
10574 .arg(target)
10575 .output()
10576 .ok()?;
10577 (made.status.success() && link.symlink_metadata().is_ok()).then_some(())
10578 }
10579
10580 #[test]
10585 fn removing_an_attempt_never_reaches_into_the_dependency_caches_it_linked() {
10586 let root = tempfile::tempdir().unwrap();
10587 let cache = root.path().join("_cache").join("o").join("r").join("npm");
10588 fs::create_dir_all(&cache).unwrap();
10589 let cached = cache.join("package.tgz");
10590 fs::write(&cached, b"kept between jobs").unwrap();
10591 let attempt = root.path().join("0123456789abcdef");
10592 let work = attempt.join("_work").join("repo");
10593 fs::create_dir_all(&work).unwrap();
10594 fs::hard_link(&cached, work.join("hard-linked.tgz")).unwrap();
10595 #[cfg(unix)]
10596 std::os::unix::fs::symlink(&cache, work.join("linked-cache")).unwrap();
10597 #[cfg(windows)]
10598 if plant_junction(&work.join("linked-cache"), &cache).is_none() {
10599 eprintln!("this machine would not create a junction; the hard link is still checked");
10600 }
10601
10602 remove_runtime_tree(&attempt).unwrap();
10603 assert!(!attempt.exists());
10604 assert_eq!(fs::read(&cached).unwrap(), b"kept between jobs");
10605 }
10606
10607 #[cfg(windows)]
10608 #[test]
10609 fn a_work_directory_replaced_by_a_junction_fails_closed_and_deletes_nothing_beyond_it() {
10610 let root = tempfile::tempdir().unwrap();
10611 let outside = root.path().join("operator-data");
10612 fs::create_dir(&outside).unwrap();
10613 let sentinel = outside.join("do-not-delete.txt");
10614 fs::write(
10615 &sentinel,
10616 b"an operator's data, outside every approved root",
10617 )
10618 .unwrap();
10619
10620 let slot = root.path().join("s1");
10621 fs::create_dir(&slot).unwrap();
10622 fs::create_dir(slot.join("bin")).unwrap();
10623 let Some(()) = plant_junction(&slot.join(DEFAULT_WORK_FOLDER), &outside) else {
10624 eprintln!("skipped: this machine would not create a directory junction");
10625 return;
10626 };
10627
10628 let work = fs::symlink_metadata(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10632 assert!(is_link_like(&work), "a junction is a reparse point");
10633 let quarantine = scrub_slot_entries(&slot).unwrap_err();
10634 assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
10635 assert!(
10636 sentinel.exists(),
10637 "the deletion followed the junction out of the slot"
10638 );
10639 assert!(outside.is_dir());
10640
10641 let elsewhere = root.path().join("s2");
10644 fs::create_dir(&elsewhere).unwrap();
10645 fs::create_dir(elsewhere.join(DEFAULT_WORK_FOLDER)).unwrap();
10646 if plant_junction(&elsewhere.join("externals"), &outside).is_some() {
10647 scrub_slot_entries(&elsewhere).expect("an ordinary entry is removed, junction or not");
10648 verify_slot_scrubbed(&elsewhere).expect("and the slot verifies");
10649 assert!(sentinel.exists(), "the junction was followed, not unlinked");
10650 assert_eq!(entries_of(&elsewhere), only_the_job_workspace());
10651 }
10652 }
10653
10654 #[cfg(unix)]
10655 #[tokio::test]
10656 async fn a_substituted_work_directory_leaves_the_attempt_uncleaned_and_still_leased() {
10657 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10658 .with_persistent_workspace(2);
10659 harness.ready().await;
10660 let first = harness.launch().await;
10661 let slot = harness.slot_path(1);
10662 harness.conclude(first.id);
10663
10664 let outside = harness._root.path().join("operator-data");
10665 fs::create_dir_all(&outside).unwrap();
10666 let sentinel = outside.join("do-not-delete.txt");
10667 fs::write(&sentinel, b"outside every approved root").unwrap();
10668 std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10669
10670 harness
10671 .launcher
10672 .clean(first.id)
10673 .await
10674 .expect_err("a slot whose `_work` was substituted is quarantined");
10675 assert!(sentinel.exists());
10676
10677 let held = harness.attempt(first.id);
10678 assert_eq!(held.state(), AttemptState::Failed);
10679 assert!(held.holds_slot_lease());
10680
10681 let second = harness.launch().await;
10683 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
10684 }
10685
10686 #[test]
10687 fn a_slot_that_is_a_file_is_refused_and_a_slot_that_is_gone_is_not() {
10688 let root = tempfile::tempdir().unwrap();
10689 let occupied = root.path().join("s1");
10690 fs::write(&occupied, b"an operator's file").unwrap();
10691 verify_journalled_slot(&occupied, nz(1), None).expect("the path is the journalled slot");
10694 assert_eq!(
10695 slot_is_present(&occupied).unwrap_err().refusal,
10696 SlotRefusal::SlotNotADirectory
10697 );
10698 assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
10699
10700 assert!(!slot_is_present(&root.path().join("s2")).unwrap());
10703 let present = root.path().join("s3");
10704 fs::create_dir(&present).unwrap();
10705 assert!(slot_is_present(&present).unwrap());
10706 }
10707
10708 #[test]
10709 fn cleanup_dispatches_on_the_journalled_kind_and_not_on_what_the_directory_holds() {
10710 let root = tempfile::tempdir().unwrap();
10711
10712 let disposable = root.path().join("abcdef012345");
10716 fs::create_dir_all(disposable.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
10717 let ephemeral = RunnerAttempt::allocate(
10718 AttemptId::new_random(),
10719 fixtures::POLICY_ID,
10720 &disposable,
10721 fixtures::created_at(),
10722 );
10723 remove_materialized_package(&ephemeral).unwrap();
10724 assert!(!disposable.exists());
10725
10726 let slot = root.path().join("s1");
10728 fs::create_dir_all(slot.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
10729 fs::create_dir_all(slot.join("bin")).unwrap();
10730 let persistent = RunnerAttempt::allocate_in(
10731 AttemptId::new_random(),
10732 fixtures::POLICY_ID,
10733 &slot,
10734 AttemptWorkspace::persistent_slot(nz(1)),
10735 fixtures::created_at(),
10736 );
10737 remove_materialized_package(&persistent).unwrap();
10738 assert_eq!(entries_of(&slot), only_the_job_workspace());
10739 assert!(slot.join(DEFAULT_WORK_FOLDER).join("repo").is_dir());
10740 }
10741
10742 #[test]
10743 fn every_slot_refusal_names_a_distinct_event_class_and_keeps_the_lease() {
10744 let refusals = [
10745 SlotRefusal::NotTheJournalledSlot,
10746 SlotRefusal::PolicyRootDisagrees,
10747 SlotRefusal::Containment,
10748 SlotRefusal::SlotNotADirectory,
10749 SlotRefusal::Enumeration,
10750 SlotRefusal::WorkNotADirectory,
10751 SlotRefusal::Deletion,
10752 SlotRefusal::Residue,
10753 ];
10754 let classes: BTreeSet<&str> = refusals.iter().map(|refusal| refusal.class()).collect();
10755 assert_eq!(
10756 classes.len(),
10757 refusals.len(),
10758 "an event class shared by two refusals tells an operator less than it appears to"
10759 );
10760 for refusal in refusals {
10761 assert!(
10764 refusal
10765 .class()
10766 .chars()
10767 .all(|c| c.is_ascii_lowercase() || c == '_'),
10768 "{}",
10769 refusal.class()
10770 );
10771 assert!(
10772 refusal.remediation().contains("slot lease"),
10773 "every refusal has to say the lease is still held: {}",
10774 refusal.class()
10775 );
10776 }
10777 }
10778
10779 #[test]
10780 fn copy_package_tree_copies_files_and_preserves_paths_with_spaces() {
10781 let root = tempfile::tempdir().unwrap();
10782 let source = root.path().join("source with spaces");
10783 let dest = root.path().join("dest with spaces");
10784
10785 fs::create_dir_all(&source).unwrap();
10786 fs::write(source.join("file1.txt"), b"hello").unwrap();
10787
10788 let nested = source.join("nested dir");
10789 fs::create_dir_all(&nested).unwrap();
10790 fs::write(nested.join("file2.txt"), b"world").unwrap();
10791
10792 let nested_work = nested.join(DEFAULT_WORK_FOLDER);
10794 fs::create_dir_all(&nested_work).unwrap();
10795 fs::write(nested_work.join("allowed.txt"), b"allowed").unwrap();
10796
10797 copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap();
10798
10799 assert_eq!(fs::read_to_string(dest.join("file1.txt")).unwrap(), "hello");
10800 assert_eq!(
10801 fs::read_to_string(dest.join("nested dir").join("file2.txt")).unwrap(),
10802 "world"
10803 );
10804 assert_eq!(
10805 fs::read_to_string(
10806 dest.join("nested dir")
10807 .join(DEFAULT_WORK_FOLDER)
10808 .join("allowed.txt")
10809 )
10810 .unwrap(),
10811 "allowed"
10812 );
10813 }
10814
10815 #[test]
10816 fn every_field_name_the_attempt_sink_emits_is_allowed() {
10817 use runner_manager_platform::logging::is_field_allowed;
10818
10819 for field in [
10821 "event",
10822 "attempt_id",
10823 "reason",
10824 "retry_in_ms",
10825 "duration_ms",
10826 "lock_wait_ms",
10827 "materialize_ms",
10828 "register_ms",
10829 "spawn_ms",
10830 ] {
10831 assert!(
10832 is_field_allowed(field),
10833 "`{field}` would be logged as `[redacted]`"
10834 );
10835 }
10836 }
10837
10838 #[cfg(unix)]
10839 fn package_entry(root: &Path, version: &str, manifest: &str) -> PathBuf {
10840 let entry = root.join("packages").join(version);
10841 fs::create_dir_all(entry.join("bin")).unwrap();
10842 fs::write(entry.join(crate::package::MANIFEST_FILE), manifest).unwrap();
10843 fs::write(entry.join("bin").join("Runner.Listener"), version).unwrap();
10844 entry
10845 }
10846
10847 #[cfg(unix)]
10849 fn listener_inode(seed: &Path) -> u64 {
10850 use std::os::unix::fs::MetadataExt as _;
10851 fs::metadata(seed.join("bin").join("Runner.Listener"))
10852 .unwrap()
10853 .ino()
10854 }
10855
10856 #[cfg(unix)]
10857 #[test]
10858 fn a_package_seed_is_built_once_and_reused_while_untouched() {
10859 let root = tempfile::tempdir().unwrap();
10860 let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
10861 let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
10862 let trusted = TrustedTrees::default();
10863
10864 let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
10865 assert_eq!(seed, seeds.join("2.336.0"));
10866 assert_eq!(
10867 fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
10868 "2.336.0"
10869 );
10870
10871 let built = listener_inode(&seed);
10872 assert_eq!(
10873 refresh_package_seed(&source, &seeds, &trusted).unwrap(),
10874 seed
10875 );
10876 assert_eq!(listener_inode(&seed), built, "an untouched seed is reused");
10877 }
10878
10879 #[cfg(unix)]
10880 #[test]
10881 fn a_tampered_or_unrecorded_seed_is_rebuilt() {
10882 let root = tempfile::tempdir().unwrap();
10883 let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
10884 let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
10885 let trusted = TrustedTrees::default();
10886 let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
10887
10888 fs::write(seed.join("bin").join("Runner.Listener"), b"planted").unwrap();
10890 refresh_package_seed(&source, &seeds, &trusted).unwrap();
10891 assert_eq!(
10892 fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
10893 "2.336.0",
10894 "a seed whose print moved is rebuilt from the cache entry"
10895 );
10896
10897 let old_listener = fs::File::open(seed.join("bin").join("Runner.Listener")).unwrap();
10901 let built = listener_inode(&seed);
10902 refresh_package_seed(&source, &seeds, &TrustedTrees::default()).unwrap();
10903 assert_ne!(
10904 listener_inode(&seed),
10905 built,
10906 "an unrecorded seed is rebuilt"
10907 );
10908 drop(old_listener);
10909 }
10910
10911 #[cfg(unix)]
10912 #[test]
10913 fn a_package_seed_is_rebuilt_for_a_new_entry_and_drops_every_other() {
10914 let root = tempfile::tempdir().unwrap();
10915 let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
10916 let trusted = TrustedTrees::default();
10917 let old = package_entry(root.path(), "2.335.0", "{\"digest\":\"old\"}");
10918 refresh_package_seed(&old, &seeds, &trusted).unwrap();
10919 fs::create_dir_all(seeds.join(".staging-interrupted")).unwrap();
10920 fs::write(seeds.join("stray"), b"").unwrap();
10921
10922 let new = package_entry(root.path(), "2.336.0", "{\"digest\":\"new\"}");
10923 let seed = refresh_package_seed(&new, &seeds, &trusted).unwrap();
10924
10925 let mut left: Vec<_> = fs::read_dir(&seeds)
10926 .unwrap()
10927 .map(|entry| entry.unwrap().file_name().into_string().unwrap())
10928 .collect();
10929 left.sort();
10930 assert_eq!(left, ["2.336.0"]);
10931
10932 fs::write(
10934 new.join(crate::package::MANIFEST_FILE),
10935 "{\"digest\":\"again\"}",
10936 )
10937 .unwrap();
10938 refresh_package_seed(&new, &seeds, &trusted).unwrap();
10939 assert_eq!(
10940 fs::read_to_string(seed.join(crate::package::MANIFEST_FILE)).unwrap(),
10941 "{\"digest\":\"again\"}",
10942 "a stale seed is rebuilt from the changed entry"
10943 );
10944 }
10945
10946 #[cfg(unix)]
10947 #[test]
10948 fn a_package_on_the_runtime_volume_is_its_own_seed() {
10949 let root = tempfile::tempdir().unwrap();
10950 let source = package_entry(root.path(), "2.336.0", "{}");
10951 let runtime = root.path().join("runners").join("attempt");
10952 fs::create_dir_all(&runtime).unwrap();
10953
10954 let trusted = TrustedTrees::default();
10955 assert_eq!(package_seed(&source, &runtime, &trusted).unwrap(), source);
10956 copy_package_tree(&source, &runtime, &trusted).unwrap();
10957 assert!(runtime.join("bin").join("Runner.Listener").exists());
10958 assert!(!root.path().join("runners").join(PACKAGE_SEED_DIR).exists());
10959 }
10960
10961 #[test]
10962 fn copy_package_tree_refuses_top_level_work_folder() {
10963 let root = tempfile::tempdir().unwrap();
10964 let source = root.path().join("source");
10965 let dest = root.path().join("dest");
10966
10967 fs::create_dir_all(&source).unwrap();
10968 fs::write(source.join("file1.txt"), b"hello").unwrap();
10969
10970 let top_work = source.join(DEFAULT_WORK_FOLDER);
10972 fs::create_dir_all(&top_work).unwrap();
10973
10974 let err = copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap_err();
10975 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
10976 }
10977}