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