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