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