Skip to main content

inferlab_runtime/
server.rs

1mod cleanup;
2mod launch;
3mod observation;
4mod readiness;
5
6use crate::operation_bound::{OperationBound, OperationTimingEvidence};
7use crate::plan::{CommandPlan, LaunchFilePlan, LaunchPlan, ProcessEndpointPlan, ReadinessPlan};
8use crate::process_group::{SignalEvidence, process_start_time};
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11use std::time::Duration;
12
13#[cfg(test)]
14use crate::operation_bound::OperationTerminalCause;
15#[cfg(test)]
16use crate::plan::TargetRegistryExpectedTarget;
17#[cfg(test)]
18use crate::shell::shell_quote_path;
19use cleanup::removal_summary;
20#[cfg(test)]
21use cleanup::terminate_local;
22#[cfg(test)]
23use launch::{materialize_local_launch_files, remote_launch_file_script, spawn_local};
24#[cfg(test)]
25use observation::{run_status_command, verified_local_status};
26#[cfg(test)]
27use readiness::{
28    HttpTargetRegistryProbe, match_target_registry, probe_http, probe_http_json,
29    wait_http_target_registry_ready, wait_process_alive_ready,
30};
31#[cfg(test)]
32use sha2::{Digest, Sha256};
33#[cfg(test)]
34use std::collections::BTreeMap;
35#[cfg(test)]
36use std::fs;
37#[cfg(test)]
38use std::io::{Read, Write};
39#[cfg(test)]
40use std::os::unix::process::CommandExt;
41#[cfg(test)]
42use std::process::{Command, Output, Stdio};
43#[cfg(test)]
44use std::thread;
45#[cfg(test)]
46use std::time::Instant;
47
48pub const REMOTE_LOG_SYNC_DEADLINE: Duration = Duration::from_secs(30);
49
50#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
51#[serde(deny_unknown_fields)]
52pub struct HostProcessHandle {
53    pub leader_pid: u32,
54    pub process_group: u32,
55    pub leader_start_time_ticks: u64,
56    /// The container this process launched, when the command is a
57    /// containerized substitution: the daemon-owned cleanup handle the
58    /// process-group kill cannot reach ([[RFC-0003:C-RUNTIME-WORKFLOWS]]).
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub container: Option<String>,
61}
62
63impl HostProcessHandle {
64    fn new(leader_pid: u32, container: Option<String>) -> Result<Self, String> {
65        if leader_pid == 0 {
66            return Err("host process-group handle requires a non-zero leader pid".to_owned());
67        }
68        let leader_start_time_ticks = process_start_time(leader_pid)
69            .map_err(|error| error.to_string())?
70            .ok_or_else(|| {
71                format!("host process {leader_pid} exited before its identity could be recorded")
72            })?;
73        Ok(Self {
74            leader_pid,
75            process_group: leader_pid,
76            leader_start_time_ticks,
77            container,
78        })
79    }
80
81    fn validate(&self) -> Result<(), String> {
82        validate_process_identity(
83            self.leader_pid,
84            self.process_group,
85            self.leader_start_time_ticks,
86            "host",
87        )
88    }
89}
90
91#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
92#[serde(deny_unknown_fields)]
93pub struct SshProcessHandle {
94    pub target: String,
95    pub leader_pid: u32,
96    pub process_group: u32,
97    pub leader_start_time_ticks: u64,
98    pub stdout: PathBuf,
99    pub stderr: PathBuf,
100    /// The container this process launched, when the command is a
101    /// containerized substitution: the daemon-owned cleanup handle the
102    /// process-group kill cannot reach ([[RFC-0003:C-RUNTIME-WORKFLOWS]]).
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub container: Option<String>,
105}
106
107impl SshProcessHandle {
108    fn validate(&self) -> Result<(), String> {
109        if self.target.is_empty() {
110            return Err("SSH process handle requires a target".to_owned());
111        }
112        validate_process_identity(
113            self.leader_pid,
114            self.process_group,
115            self.leader_start_time_ticks,
116            "SSH",
117        )
118    }
119}
120
121fn validate_process_identity(
122    leader_pid: u32,
123    process_group: u32,
124    leader_start_time_ticks: u64,
125    kind: &str,
126) -> Result<(), String> {
127    if leader_pid == 0 || process_group == 0 || leader_start_time_ticks == 0 {
128        return Err(format!("{kind} process-group handle requires non-zero ids"));
129    }
130    if leader_pid != process_group {
131        return Err(format!(
132            "{kind} process-group handle requires leader_pid to equal process_group"
133        ));
134    }
135    Ok(())
136}
137
138#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
139#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
140pub enum ProcessHandle {
141    Local(HostProcessHandle),
142    Ssh(SshProcessHandle),
143}
144
145#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
146#[serde(rename_all = "kebab-case")]
147pub enum CleanupTrigger {
148    StartupRollback,
149    Stop,
150    Recovery,
151}
152
153#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
154#[serde(deny_unknown_fields)]
155pub struct CleanupEvidence {
156    pub trigger: CleanupTrigger,
157    pub elapsed_ms: u64,
158    pub status_deadline_ms: u64,
159    pub term_grace_ms: u64,
160    pub kill_grace_ms: u64,
161    pub reap_grace_ms: Option<u64>,
162    pub remote_deadline_ms: Option<u64>,
163    pub verified: bool,
164    pub already_exited: bool,
165    pub forced: bool,
166    pub signals: Vec<SignalEvidence>,
167    pub error: Option<String>,
168    /// Confirmed removal of the process's container on its launch machine
169    /// ([[RFC-0003:C-RUNTIME-WORKFLOWS]]); present when the cleanup path
170    /// attempted to remove a known container — from a running server's
171    /// handle, or from a launch failure whose command already named one
172    /// before any handle existed.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub container_removal: Option<ContainerRemovalEvidence>,
175}
176
177#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
178#[serde(deny_unknown_fields)]
179pub struct ContainerRemovalEvidence {
180    pub container: String,
181    pub elapsed_ms: u64,
182    pub operation_elapsed_ms: u64,
183    pub deadline_ms: u64,
184    pub client_cleanup: Option<crate::container::CommandCleanupEvidence>,
185    pub confirmed: bool,
186    pub already_absent: bool,
187    pub error: Option<String>,
188}
189
190#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
191#[serde(deny_unknown_fields)]
192pub struct ProcessStatus {
193    pub queried: bool,
194    pub alive: bool,
195    pub error: Option<String>,
196}
197
198#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
199#[serde(deny_unknown_fields)]
200pub struct TargetRegistryMatchEvidence {
201    pub url: String,
202    pub role: String,
203    pub healthy: bool,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub bootstrap_port: Option<u16>,
206}
207
208#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
209#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
210pub enum ReadinessEvidence {
211    Http {
212        url: String,
213        attempts: u32,
214        ready_unix_ms: u64,
215        timing: OperationTimingEvidence,
216        diagnostic_attempts: Vec<ReadinessAttemptEvidence>,
217    },
218    HttpTargetRegistry {
219        readiness_url: String,
220        registry_url: String,
221        attempts: u32,
222        ready_unix_ms: u64,
223        matched_targets: Vec<TargetRegistryMatchEvidence>,
224        timing: OperationTimingEvidence,
225        diagnostic_attempts: Vec<ReadinessAttemptEvidence>,
226    },
227    ProcessAlive {
228        ready_unix_ms: u64,
229        timing: OperationTimingEvidence,
230        diagnostic_attempts: Vec<ReadinessAttemptEvidence>,
231    },
232}
233
234#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
235#[serde(deny_unknown_fields)]
236pub struct ReadinessAttemptEvidence {
237    pub operation: String,
238    pub effective_bound_ms: u64,
239    pub succeeded: bool,
240    pub error: Option<String>,
241}
242
243#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
244#[serde(rename_all = "snake_case")]
245pub enum ReadinessFailureKind {
246    Exited,
247    Interrupted,
248    Timeout,
249}
250
251#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
252#[serde(deny_unknown_fields)]
253pub struct ReadinessFailure {
254    pub kind: ReadinessFailureKind,
255    pub message: String,
256    pub timing: Option<OperationTimingEvidence>,
257    pub diagnostic_attempts: Vec<ReadinessAttemptEvidence>,
258}
259
260pub struct ProcessSpec<'a> {
261    pub launch: &'a LaunchPlan,
262    pub command: &'a CommandPlan,
263    pub launch_files: &'a [LaunchFilePlan],
264    pub cache_root: &'a Path,
265    pub stdout: &'a Path,
266    pub stderr: &'a Path,
267    pub remote_dir: &'a Path,
268    /// The resolver-assigned container name when the command is a
269    /// containerized substitution.
270    pub container: Option<&'a str>,
271}
272
273#[derive(Debug, thiserror::Error)]
274pub enum ServerLaunchError {
275    #[error("{message}")]
276    Preparation { message: String },
277    #[error("failed to {operation} {path}: {source}")]
278    FileIo {
279        operation: &'static str,
280        path: PathBuf,
281        #[source]
282        source: std::io::Error,
283    },
284    #[error("failed to launch {program:?}: {source}")]
285    Process {
286        program: String,
287        #[source]
288        source: std::io::Error,
289    },
290    #[error(transparent)]
291    Ssh(#[from] crate::ssh::SshError),
292    #[error("{operation} exited with {status}: {diagnostics}")]
293    Exit {
294        operation: String,
295        status: std::process::ExitStatus,
296        diagnostics: String,
297    },
298    #[error("SSH launch on {target:?} returned non-UTF-8 identity: {source}")]
299    NonUtf8Identity {
300        target: String,
301        #[source]
302        source: std::string::FromUtf8Error,
303    },
304    #[error("SSH launch on {target:?} returned no process id")]
305    MissingProcessId { target: String },
306    #[error("SSH launch on {target:?} returned no process start time")]
307    MissingStartTime { target: String },
308    #[error("SSH launch on {target:?} returned invalid process id {value:?}: {source}")]
309    InvalidProcessId {
310        target: String,
311        value: String,
312        #[source]
313        source: std::num::ParseIntError,
314    },
315    #[error("SSH launch on {target:?} returned invalid process start time {value:?}: {source}")]
316    InvalidStartTime {
317        target: String,
318        value: String,
319        #[source]
320        source: std::num::ParseIntError,
321    },
322    #[error("SSH launch on {target:?} returned an invalid process identity: {details}")]
323    InvalidIdentity { target: String, details: String },
324    #[error("existing launch file target {path} is not a regular file", path = path.display())]
325    NotRegularFile { path: PathBuf },
326    #[error(
327        "existing launch file {path} does not match declared digest {expected}; found {actual}",
328        path = path.display()
329    )]
330    FileDigestMismatch {
331        path: PathBuf,
332        expected: String,
333        actual: String,
334    },
335}
336
337#[derive(Debug)]
338pub struct LaunchFailure {
339    pub error: ServerLaunchError,
340    pub ownership_unknown: bool,
341    /// The structured outcome of removing the container this launch may
342    /// have created, when the failure attempted one; the record's cleanup
343    /// evidence carries the actual container and reason rather than a
344    /// generic note ([[RFC-0003:C-RUNTIME-WORKFLOWS]]).
345    pub container_removal: Option<Box<ContainerRemovalEvidence>>,
346    pub cleanup: Option<Box<CleanupEvidence>>,
347    cleanup_note: Option<String>,
348}
349
350impl LaunchFailure {
351    pub fn before_launch(message: String) -> Self {
352        Self {
353            error: ServerLaunchError::Preparation { message },
354            ownership_unknown: false,
355            container_removal: None,
356            cleanup: None,
357            cleanup_note: None,
358        }
359    }
360
361    fn from_error(error: ServerLaunchError) -> Self {
362        Self {
363            error,
364            ownership_unknown: false,
365            container_removal: None,
366            cleanup: None,
367            cleanup_note: None,
368        }
369    }
370
371    pub fn message(&self) -> String {
372        let mut message = self.error.to_string();
373        if let Some(note) = &self.cleanup_note {
374            message = format!("{message}; {note}");
375        } else if let Some(cleanup) = &self.cleanup
376            && let Some(error) = &cleanup.error
377        {
378            message = format!("{message}; local launch cleanup was not verified: {error}");
379        }
380        if let Some(removal) = &self.container_removal {
381            message = format!("{message}; {}", removal_summary(removal));
382        }
383        message
384    }
385
386    pub fn unresolved_ownership(message: String) -> Self {
387        Self {
388            error: ServerLaunchError::Preparation { message },
389            ownership_unknown: true,
390            container_removal: None,
391            cleanup: None,
392            cleanup_note: None,
393        }
394    }
395}
396
397pub trait ProcessLauncher {
398    fn spawn(&self, spec: ProcessSpec<'_>) -> Result<ProcessHandle, LaunchFailure>;
399}
400
401pub trait ProcessObserver {
402    fn status(&self, handle: &ProcessHandle) -> ProcessStatus;
403    fn status_with_bound(&self, handle: &ProcessHandle, bound: &OperationBound) -> ProcessStatus;
404    fn sync_logs(
405        &self,
406        handle: &ProcessHandle,
407        stdout: &Path,
408        stderr: &Path,
409        cleanup: bool,
410    ) -> Result<(), LogSyncError>;
411}
412
413pub trait ReadinessObserver {
414    fn wait_ready(
415        &self,
416        handle: &ProcessHandle,
417        endpoint: &ProcessEndpointPlan,
418        readiness: &ReadinessPlan,
419        bound: &OperationBound,
420        on_probe_failure: &mut dyn FnMut(&str),
421    ) -> Result<ReadinessEvidence, ReadinessFailure>;
422}
423
424pub trait ProcessCleanup {
425    fn terminate(
426        &self,
427        handle: &ProcessHandle,
428        trigger: CleanupTrigger,
429        on_container_removal: &mut dyn FnMut(&str),
430    ) -> CleanupEvidence;
431}
432
433pub trait ServerRuntime:
434    ProcessLauncher + ProcessObserver + ReadinessObserver + ProcessCleanup
435{
436}
437
438impl<T> ServerRuntime for T where
439    T: ProcessLauncher + ProcessObserver + ReadinessObserver + ProcessCleanup
440{
441}
442
443#[derive(Debug, thiserror::Error)]
444pub enum ProcessCommandError {
445    #[error("{operation} deadline expired")]
446    Deadline { operation: String },
447    #[error("{operation} was interrupted")]
448    Interrupted { operation: String },
449    #[error("failed to launch {operation}: {source}")]
450    Launch {
451        operation: String,
452        #[source]
453        source: std::io::Error,
454    },
455    #[error("failed to launch {operation}: {source}")]
456    Ssh {
457        operation: String,
458        #[source]
459        source: crate::ssh::SshError,
460    },
461    #[error("{operation} failed: {source}")]
462    Io {
463        operation: String,
464        #[source]
465        source: std::io::Error,
466    },
467    #[error("{operation} wait failed: {source}; child cleanup: {cleanup}")]
468    WaitCleanup {
469        operation: String,
470        #[source]
471        source: std::io::Error,
472        cleanup: String,
473    },
474    #[error("{operation} exited with {status}: {stderr}")]
475    Exit {
476        operation: String,
477        status: std::process::ExitStatus,
478        stderr: String,
479    },
480}
481
482#[derive(Debug, thiserror::Error)]
483pub enum LogSyncError {
484    #[error("failed to read remote log {path}: {source}")]
485    ReadRemote {
486        path: PathBuf,
487        #[source]
488        source: ProcessCommandError,
489    },
490    #[error("failed to read remote log {path}: command exited with {status}: {stderr}")]
491    RemoteExit {
492        path: PathBuf,
493        status: std::process::ExitStatus,
494        stderr: String,
495    },
496    #[error("failed to write local log {path}: {source}")]
497    WriteLocal {
498        path: PathBuf,
499        #[source]
500        source: std::io::Error,
501    },
502}
503
504#[derive(Clone, Copy, Debug, Default)]
505pub struct SystemProcessRuntime;
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use crate::plan::LaunchFilePlan;
511    use std::cell::Cell;
512    use std::io::{BufRead, BufReader};
513    use std::net::TcpListener;
514    use std::os::unix::fs::{MetadataExt, PermissionsExt};
515
516    #[test]
517    fn expired_readiness_owner_prevents_a_fresh_network_attempt() {
518        let bound = OperationBound::finite(Duration::ZERO);
519        let error = probe_http("127.0.0.1", 9, "/ready", &bound, 30)
520            .err()
521            .map(|error| error.to_string())
522            .unwrap_or_default();
523
524        assert_eq!(error, "readiness operation deadline expired");
525    }
526
527    #[test]
528    fn readiness_attempt_deadline_bounds_a_trickled_status_line() -> Result<(), String> {
529        let listener = TcpListener::bind(("127.0.0.1", 0)).map_err(|error| error.to_string())?;
530        let port = listener
531            .local_addr()
532            .map_err(|error| error.to_string())?
533            .port();
534        let server = thread::spawn(move || -> Result<(), String> {
535            let (mut stream, _) = listener.accept().map_err(|error| error.to_string())?;
536            let mut request = [0_u8; 1024];
537            let _ = stream.read(&mut request);
538            let response = format!("HTTP/1.1 200 {}\r\n", " ".repeat(96));
539            for byte in response.bytes() {
540                if stream.write_all(&[byte]).is_err() {
541                    break;
542                }
543                thread::sleep(Duration::from_millis(20));
544            }
545            Ok(())
546        });
547
548        let started = Instant::now();
549        let error = probe_http("127.0.0.1", port, "/ready", &OperationBound::unbounded(), 1)
550            .err()
551            .map(|error| error.to_string())
552            .unwrap_or_default();
553        let elapsed = started.elapsed();
554        server
555            .join()
556            .map_err(|_| "trickle fixture panicked".to_owned())??;
557
558        assert!(error.contains("deadline expired"), "{error}");
559        assert!(
560            elapsed < Duration::from_secs(2),
561            "a one-second attempt lasted {elapsed:?}"
562        );
563        Ok(())
564    }
565
566    #[test]
567    fn finite_readiness_accepts_a_response_after_250_milliseconds() -> Result<(), String> {
568        let listener = TcpListener::bind(("127.0.0.1", 0)).map_err(|error| error.to_string())?;
569        let port = listener
570            .local_addr()
571            .map_err(|error| error.to_string())?
572            .port();
573        let server = thread::spawn(move || -> Result<(), String> {
574            let (mut stream, _) = listener.accept().map_err(|error| error.to_string())?;
575            let mut request = [0_u8; 1024];
576            let _ = stream.read(&mut request);
577            thread::sleep(Duration::from_millis(350));
578            stream
579                .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
580                .map_err(|error| error.to_string())
581        });
582
583        let started = Instant::now();
584        probe_http(
585            "127.0.0.1",
586            port,
587            "/ready",
588            &OperationBound::finite(Duration::from_secs(2)),
589            1,
590        )
591        .map_err(|error| error.to_string())?;
592        let elapsed = started.elapsed();
593        server
594            .join()
595            .map_err(|_| "delayed readiness fixture panicked".to_owned())??;
596
597        assert!(elapsed >= Duration::from_millis(250), "elapsed {elapsed:?}");
598        assert!(elapsed < Duration::from_secs(2), "elapsed {elapsed:?}");
599        Ok(())
600    }
601
602    #[test]
603    fn readiness_attempt_deadline_includes_the_complete_response_body() -> Result<(), String> {
604        let listener = TcpListener::bind(("127.0.0.1", 0)).map_err(|error| error.to_string())?;
605        let port = listener
606            .local_addr()
607            .map_err(|error| error.to_string())?
608            .port();
609        let server = thread::spawn(move || -> Result<(), String> {
610            let (mut stream, _) = listener.accept().map_err(|error| error.to_string())?;
611            let mut request = [0_u8; 1024];
612            let _ = stream.read(&mut request);
613            stream
614                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nx")
615                .map_err(|error| error.to_string())?;
616            thread::sleep(Duration::from_millis(1_500));
617            Ok(())
618        });
619
620        let started = Instant::now();
621        let error = probe_http("127.0.0.1", port, "/ready", &OperationBound::unbounded(), 1)
622            .err()
623            .map(|error| error.to_string());
624        let elapsed = started.elapsed();
625        server
626            .join()
627            .map_err(|_| "readiness body fixture panicked".to_owned())??;
628
629        assert!(
630            error.is_some_and(|error| error == "readiness operation deadline expired"),
631            "readiness accepted an incomplete response body"
632        );
633        assert!(
634            elapsed < Duration::from_millis(1_500),
635            "elapsed {elapsed:?}"
636        );
637        Ok(())
638    }
639
640    #[test]
641    fn registry_attempt_deadline_bounds_a_trickled_body() -> Result<(), String> {
642        let listener = TcpListener::bind(("127.0.0.1", 0)).map_err(|error| error.to_string())?;
643        let port = listener
644            .local_addr()
645            .map_err(|error| error.to_string())?
646            .port();
647        let server = thread::spawn(move || -> Result<(), String> {
648            let (mut stream, _) = listener.accept().map_err(|error| error.to_string())?;
649            let mut request = [0_u8; 1024];
650            let _ = stream.read(&mut request);
651            stream
652                .write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n")
653                .map_err(|error| error.to_string())?;
654            let body = format!("{}{{}}", " ".repeat(96));
655            for byte in body.bytes() {
656                if stream.write_all(&[byte]).is_err() {
657                    break;
658                }
659                thread::sleep(Duration::from_millis(20));
660            }
661            Ok(())
662        });
663
664        let started = Instant::now();
665        let error = probe_http_json(
666            "127.0.0.1",
667            port,
668            "/workers",
669            "target registry",
670            &OperationBound::unbounded(),
671            1,
672        )
673        .err()
674        .map(|error| error.to_string())
675        .unwrap_or_default();
676        let elapsed = started.elapsed();
677        server
678            .join()
679            .map_err(|_| "registry trickle fixture panicked".to_owned())??;
680
681        assert!(error.contains("deadline expired"), "{error}");
682        assert!(
683            elapsed < Duration::from_secs(2),
684            "a one-second registry attempt lasted {elapsed:?}"
685        );
686        Ok(())
687    }
688
689    #[test]
690    fn expired_readiness_owner_rejects_a_registry_match() {
691        let expected = vec![TargetRegistryExpectedTarget {
692            url: "http://decode:30001".to_owned(),
693            role: "decode".to_owned(),
694            bootstrap_port: None,
695        }];
696        let response = serde_json::json!({
697            "workers": [{
698                "url": "http://decode:30001",
699                "worker_type": "decode",
700                "is_healthy": true
701            }]
702        });
703
704        let error = match_target_registry(
705            &response,
706            &target_registry_probe(&expected),
707            &OperationBound::finite(Duration::ZERO),
708        )
709        .err()
710        .map(|error| error.to_string())
711        .unwrap_or_default();
712
713        assert_eq!(error, "readiness operation deadline expired");
714    }
715
716    #[test]
717    fn process_status_command_cannot_outlive_the_readiness_owner() {
718        let started = Instant::now();
719        let error = run_status_command(
720            &["sh", "-c", "sleep 5"],
721            &[],
722            &OperationBound::finite(Duration::from_millis(50)),
723        )
724        .err()
725        .map(|error| error.to_string())
726        .unwrap_or_default();
727
728        assert_eq!(error, "process status attempt deadline expired");
729        assert!(
730            started.elapsed() < Duration::from_secs(1),
731            "bounded process status did not stop promptly"
732        );
733    }
734
735    #[test]
736    fn finite_readiness_retries_an_expired_process_status_attempt() -> Result<(), String> {
737        let calls = Cell::new(0_u32);
738        let mut failures = Vec::new();
739        let bound = OperationBound::finite(Duration::from_secs(3));
740        let evidence = wait_process_alive_ready(
741            |_| {
742                calls.set(calls.get() + 1);
743                if calls.get() == 1 {
744                    thread::sleep(Duration::from_millis(1_050));
745                    ProcessStatus {
746                        queried: false,
747                        alive: false,
748                        error: Some("process status attempt deadline expired".to_owned()),
749                    }
750                } else {
751                    alive_status()
752                }
753            },
754            1,
755            &bound,
756            &mut |failure| failures.push(failure.to_owned()),
757        )
758        .map_err(|failure| failure.message)?;
759
760        assert_eq!(calls.get(), 2);
761        assert_eq!(failures, ["process status attempt deadline expired"]);
762        let ReadinessEvidence::ProcessAlive {
763            timing,
764            diagnostic_attempts,
765            ..
766        } = evidence
767        else {
768            return Err("process-alive readiness returned the wrong evidence kind".to_owned());
769        };
770        assert_eq!(
771            timing.start_boundary,
772            "after_process_spawn_before_readiness_attempt"
773        );
774        assert_eq!(diagnostic_attempts.len(), 1);
775        assert!(diagnostic_attempts[0].succeeded);
776        assert!((1..=1_000).contains(&diagnostic_attempts[0].effective_bound_ms));
777        Ok(())
778    }
779
780    #[test]
781    fn unbounded_process_status_command_does_not_acquire_a_timeout() -> Result<(), String> {
782        let output = run_status_command(
783            &["sh", "-c", "sleep 0.1; printf alive"],
784            &[],
785            &OperationBound::unbounded(),
786        )
787        .map_err(|error| error.to_string())?;
788
789        assert!(output.status.success());
790        assert_eq!(output.stdout, b"alive");
791        Ok(())
792    }
793
794    fn launch_file(root: &Path, text: &str, name: &str) -> LaunchFilePlan {
795        let sha256 = format!("{:x}", Sha256::digest(text.as_bytes()));
796        let relative_path = format!("launch-files/{sha256}/{name}");
797        LaunchFilePlan {
798            resolved_path: root.join(&relative_path),
799            relative_path,
800            text: text.to_owned(),
801            sha256,
802        }
803    }
804
805    fn run_script_with_input(script: &str, input: &[u8]) -> Result<Output, String> {
806        match crate::container::run_with_bound(
807            &["bash", "-c", script],
808            &[],
809            None,
810            Some(input),
811            &OperationBound::unbounded(),
812            None,
813        ) {
814            Ok(crate::container::BoundedWait::Exited {
815                status,
816                stdout,
817                stderr,
818            }) => Ok(Output {
819                status,
820                stdout,
821                stderr,
822            }),
823            Ok(crate::container::BoundedWait::Expired { .. }) => {
824                Err("unbounded launch-file fixture expired".to_owned())
825            }
826            Ok(crate::container::BoundedWait::Interrupted { .. }) => {
827                Err("launch-file fixture was interrupted".to_owned())
828            }
829            Err(_) => Err("launch-file fixture failed".to_owned()),
830        }
831    }
832
833    fn target_registry_endpoint(
834        registry_body: String,
835    ) -> Result<(ProcessEndpointPlan, thread::JoinHandle<Result<(), String>>), String> {
836        let listener = TcpListener::bind(("127.0.0.1", 0)).map_err(|error| error.to_string())?;
837        let port = listener
838            .local_addr()
839            .map_err(|error| error.to_string())?
840            .port();
841        let server = thread::spawn(move || {
842            for _ in 0..2 {
843                let (mut stream, _) = listener.accept().map_err(|error| error.to_string())?;
844                let mut request_line = String::new();
845                let mut reader =
846                    BufReader::new(stream.try_clone().map_err(|error| error.to_string())?);
847                reader
848                    .read_line(&mut request_line)
849                    .map_err(|error| error.to_string())?;
850                loop {
851                    let mut header = String::new();
852                    reader
853                        .read_line(&mut header)
854                        .map_err(|error| error.to_string())?;
855                    if header == "\r\n" || header.is_empty() {
856                        break;
857                    }
858                }
859                let body = if request_line.starts_with("GET /workers ") {
860                    registry_body.as_bytes()
861                } else {
862                    b""
863                };
864                let mut response = format!(
865                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
866                    body.len()
867                )
868                .into_bytes();
869                response.extend_from_slice(body);
870                stream
871                    .write_all(&response)
872                    .map_err(|error| error.to_string())?;
873            }
874            Ok(())
875        });
876        Ok((
877            ProcessEndpointPlan {
878                host: "127.0.0.1".to_owned(),
879                port,
880            },
881            server,
882        ))
883    }
884
885    fn target_registry_probe<'a>(
886        expected_targets: &'a [TargetRegistryExpectedTarget],
887    ) -> HttpTargetRegistryProbe<'a> {
888        HttpTargetRegistryProbe {
889            readiness_path: "/readiness",
890            registry_path: "/workers",
891            targets_field: "workers",
892            target_url_field: "url",
893            target_role_field: "worker_type",
894            target_healthy_field: "is_healthy",
895            target_bootstrap_port_field: "bootstrap_port",
896            expected_targets,
897        }
898    }
899
900    fn alive_status() -> ProcessStatus {
901        ProcessStatus {
902            queried: true,
903            alive: true,
904            error: None,
905        }
906    }
907
908    #[test]
909    fn local_launch_file_publication_reuses_the_immutable_target() -> Result<(), String> {
910        let root = tempfile::tempdir().map_err(|error| error.to_string())?;
911        let launch_file = launch_file(
912            root.path(),
913            "worker: \u{2603}\nmode: context\n",
914            "worker.yaml",
915        );
916
917        materialize_local_launch_files(std::slice::from_ref(&launch_file))
918            .map_err(|error| error.to_string())?;
919        let first_metadata =
920            fs::metadata(&launch_file.resolved_path).map_err(|error| error.to_string())?;
921        materialize_local_launch_files(std::slice::from_ref(&launch_file))
922            .map_err(|error| error.to_string())?;
923        let second_metadata =
924            fs::metadata(&launch_file.resolved_path).map_err(|error| error.to_string())?;
925
926        assert_eq!(
927            fs::read_to_string(&launch_file.resolved_path).map_err(|error| error.to_string())?,
928            launch_file.text
929        );
930        assert_eq!(first_metadata.ino(), second_metadata.ino());
931        assert_eq!(second_metadata.permissions().mode() & 0o222, 0);
932        Ok(())
933    }
934
935    #[test]
936    fn local_launch_file_mismatch_fails_before_spawn_without_replacing_it() -> Result<(), String> {
937        let root = tempfile::tempdir().map_err(|error| error.to_string())?;
938        let cache = root.path().join("cache");
939        let launch_file = launch_file(&cache, "expected\n", "worker.yaml");
940        let parent = launch_file
941            .resolved_path
942            .parent()
943            .ok_or_else(|| "launch file has no parent".to_owned())?;
944        fs::create_dir_all(parent).map_err(|error| error.to_string())?;
945        fs::write(&launch_file.resolved_path, "stale\n").map_err(|error| error.to_string())?;
946        let marker = root.path().join("spawned");
947        let command = CommandPlan {
948            argv: vec![
949                "sh".to_owned(),
950                "-c".to_owned(),
951                format!("printf launched > {}", shell_quote_path(&marker)),
952            ],
953            env: BTreeMap::new(),
954            explicit_env: Vec::new(),
955            pass_env: Vec::new(),
956            cwd: root.path().to_path_buf(),
957        };
958        let launch_files = vec![launch_file.clone()];
959
960        let result = spawn_local(ProcessSpec {
961            launch: &LaunchPlan::Local,
962            command: &command,
963            launch_files: &launch_files,
964            cache_root: &cache,
965            stdout: &root.path().join("stdout.log"),
966            stderr: &root.path().join("stderr.log"),
967            remote_dir: &root.path().join("remote"),
968            container: None,
969        });
970
971        let failure = match result {
972            Err(failure) => failure,
973            Ok(handle) => {
974                let _ = terminate_local(&handle, CleanupTrigger::StartupRollback);
975                return Err("mismatched launch file unexpectedly spawned a process".to_owned());
976            }
977        };
978        assert!(!failure.ownership_unknown, "{failure:?}");
979        assert!(failure.message().contains("does not match"), "{failure:?}");
980        assert!(!marker.exists());
981        assert_eq!(
982            fs::read_to_string(&launch_file.resolved_path).map_err(|error| error.to_string())?,
983            "stale\n"
984        );
985        Ok(())
986    }
987
988    #[test]
989    fn remote_launch_file_script_publishes_stdin_without_replacing_targets() -> Result<(), String> {
990        let root = tempfile::tempdir().map_err(|error| error.to_string())?;
991        let published = launch_file(
992            root.path(),
993            "worker: \u{96ea}\nmode: context\n",
994            "worker.yaml",
995        );
996        let script = remote_launch_file_script(&published).map_err(|error| error.to_string())?;
997
998        let first = run_script_with_input(&script, published.text.as_bytes())?;
999        assert!(
1000            first.status.success(),
1001            "{}",
1002            String::from_utf8_lossy(&first.stderr)
1003        );
1004        let first_metadata =
1005            fs::metadata(&published.resolved_path).map_err(|error| error.to_string())?;
1006        let first_inode = first_metadata.ino();
1007        assert_eq!(first_metadata.permissions().mode() & 0o222, 0);
1008        let reused = run_script_with_input(&script, published.text.as_bytes())?;
1009        assert!(
1010            reused.status.success(),
1011            "{}",
1012            String::from_utf8_lossy(&reused.stderr)
1013        );
1014        assert_eq!(
1015            fs::read_to_string(&published.resolved_path).map_err(|error| error.to_string())?,
1016            published.text
1017        );
1018        assert_eq!(
1019            fs::metadata(&published.resolved_path)
1020                .map_err(|error| error.to_string())?
1021                .ino(),
1022            first_inode
1023        );
1024
1025        let corrupt = launch_file(root.path(), "expected\n", "corrupt.yaml");
1026        let corrupt_parent = corrupt
1027            .resolved_path
1028            .parent()
1029            .ok_or_else(|| "launch file has no parent".to_owned())?;
1030        fs::create_dir_all(corrupt_parent).map_err(|error| error.to_string())?;
1031        fs::write(&corrupt.resolved_path, "stale\n").map_err(|error| error.to_string())?;
1032        let rejected = run_script_with_input(
1033            &remote_launch_file_script(&corrupt).map_err(|error| error.to_string())?,
1034            corrupt.text.as_bytes(),
1035        )?;
1036        assert!(!rejected.status.success());
1037        assert_eq!(
1038            fs::read_to_string(&corrupt.resolved_path).map_err(|error| error.to_string())?,
1039            "stale\n"
1040        );
1041        Ok(())
1042    }
1043
1044    #[test]
1045    fn target_registry_readiness_records_all_expected_targets() -> Result<(), String> {
1046        let (endpoint, server) = target_registry_endpoint(
1047            serde_json::json!({
1048                "workers": [
1049                    {
1050                        "url": "http://prefill:30000",
1051                        "worker_type": "prefill",
1052                        "is_healthy": true,
1053                        "bootstrap_port": 8998
1054                    },
1055                    {
1056                        "url": "http://decode:30001",
1057                        "worker_type": "decode",
1058                        "is_healthy": true
1059                    }
1060                ]
1061            })
1062            .to_string(),
1063        )?;
1064        let expected = vec![
1065            TargetRegistryExpectedTarget {
1066                url: "http://prefill:30000".to_owned(),
1067                role: "prefill".to_owned(),
1068                bootstrap_port: Some(8998),
1069            },
1070            TargetRegistryExpectedTarget {
1071                url: "http://decode:30001".to_owned(),
1072                role: "decode".to_owned(),
1073                bootstrap_port: None,
1074            },
1075        ];
1076
1077        let bound = OperationBound::unbounded();
1078        let evidence = wait_http_target_registry_ready(
1079            |_| alive_status(),
1080            &endpoint,
1081            target_registry_probe(&expected),
1082            1,
1083            &bound,
1084            &mut |_| {},
1085        )
1086        .map_err(|failure| failure.message)?;
1087        server
1088            .join()
1089            .map_err(|_| "target registry fixture panicked".to_owned())??;
1090
1091        let record_value = serde_json::to_value(&evidence).map_err(|error| error.to_string())?;
1092        assert_eq!(record_value["kind"], "http_target_registry");
1093        assert_eq!(
1094            record_value["matched_targets"].as_array().map(Vec::len),
1095            Some(2)
1096        );
1097        let ReadinessEvidence::HttpTargetRegistry {
1098            readiness_url,
1099            registry_url,
1100            attempts,
1101            matched_targets,
1102            timing,
1103            diagnostic_attempts,
1104            ready_unix_ms: _,
1105        } = evidence
1106        else {
1107            return Err("target registry readiness returned the wrong evidence kind".to_owned());
1108        };
1109        assert_eq!(
1110            readiness_url,
1111            format!("http://127.0.0.1:{}/readiness", endpoint.port)
1112        );
1113        assert_eq!(
1114            registry_url,
1115            format!("http://127.0.0.1:{}/workers", endpoint.port)
1116        );
1117        assert_eq!(attempts, 1);
1118        assert_eq!(
1119            timing.budget,
1120            crate::operation_bound::OperationBudgetEvidence::Unbounded
1121        );
1122        assert_eq!(
1123            timing.terminal_cause,
1124            crate::operation_bound::OperationTerminalCause::Succeeded
1125        );
1126        assert_eq!(diagnostic_attempts.len(), 3);
1127        assert!(diagnostic_attempts.iter().all(|attempt| {
1128            attempt.succeeded && (1..=1_000).contains(&attempt.effective_bound_ms)
1129        }));
1130        assert_eq!(
1131            matched_targets,
1132            vec![
1133                TargetRegistryMatchEvidence {
1134                    url: "http://prefill:30000".to_owned(),
1135                    role: "prefill".to_owned(),
1136                    healthy: true,
1137                    bootstrap_port: Some(8998),
1138                },
1139                TargetRegistryMatchEvidence {
1140                    url: "http://decode:30001".to_owned(),
1141                    role: "decode".to_owned(),
1142                    healthy: true,
1143                    bootstrap_port: None,
1144                },
1145            ]
1146        );
1147        Ok(())
1148    }
1149
1150    #[test]
1151    fn finite_target_registry_attempts_record_the_resolved_attempt_budget() -> Result<(), String> {
1152        let (endpoint, server) = target_registry_endpoint(
1153            serde_json::json!({
1154                "workers": [{
1155                    "url": "http://decode:30001",
1156                    "worker_type": "decode",
1157                    "is_healthy": true
1158                }]
1159            })
1160            .to_string(),
1161        )?;
1162        let expected = vec![TargetRegistryExpectedTarget {
1163            url: "http://decode:30001".to_owned(),
1164            role: "decode".to_owned(),
1165            bootstrap_port: None,
1166        }];
1167
1168        let bound = OperationBound::finite(Duration::from_secs(2));
1169        let evidence = wait_http_target_registry_ready(
1170            |_| alive_status(),
1171            &endpoint,
1172            target_registry_probe(&expected),
1173            1,
1174            &bound,
1175            &mut |_| {},
1176        )
1177        .map_err(|failure| failure.message)?;
1178        server
1179            .join()
1180            .map_err(|_| "target registry fixture panicked".to_owned())??;
1181
1182        let ReadinessEvidence::HttpTargetRegistry {
1183            timing,
1184            diagnostic_attempts,
1185            ..
1186        } = evidence
1187        else {
1188            return Err("target registry readiness returned the wrong evidence kind".to_owned());
1189        };
1190        assert_eq!(
1191            timing.budget,
1192            crate::operation_bound::OperationBudgetEvidence::Finite {
1193                configured_ms: 2_000,
1194            }
1195        );
1196        assert_eq!(diagnostic_attempts.len(), 3);
1197        assert!(diagnostic_attempts.iter().all(|attempt| {
1198            attempt.succeeded && (1..=1_000).contains(&attempt.effective_bound_ms)
1199        }));
1200        Ok(())
1201    }
1202
1203    #[test]
1204    fn target_registry_readiness_rejects_partial_registration() -> Result<(), String> {
1205        let (endpoint, server) = target_registry_endpoint(
1206            serde_json::json!({
1207                "workers": [{
1208                    "url": "http://prefill:30000",
1209                    "worker_type": "prefill",
1210                    "is_healthy": true,
1211                    "bootstrap_port": 8998
1212                }]
1213            })
1214            .to_string(),
1215        )?;
1216        let expected = vec![
1217            TargetRegistryExpectedTarget {
1218                url: "http://prefill:30000".to_owned(),
1219                role: "prefill".to_owned(),
1220                bootstrap_port: Some(8998),
1221            },
1222            TargetRegistryExpectedTarget {
1223                url: "http://decode:30001".to_owned(),
1224                role: "decode".to_owned(),
1225                bootstrap_port: None,
1226            },
1227        ];
1228
1229        let mut probe_failures = Vec::new();
1230        let bound = OperationBound::finite(Duration::from_secs(1));
1231        let failure = match wait_http_target_registry_ready(
1232            |_| alive_status(),
1233            &endpoint,
1234            target_registry_probe(&expected),
1235            1,
1236            &bound,
1237            &mut |failure| probe_failures.push(failure.to_owned()),
1238        ) {
1239            Err(failure) => failure,
1240            Ok(evidence) => {
1241                return Err(format!(
1242                    "partial target registration unexpectedly became ready: {evidence:?}"
1243                ));
1244            }
1245        };
1246        server
1247            .join()
1248            .map_err(|_| "target registry fixture panicked".to_owned())??;
1249
1250        assert_eq!(failure.kind, ReadinessFailureKind::Timeout);
1251        let timing = failure
1252            .timing
1253            .as_ref()
1254            .ok_or_else(|| "readiness timeout has no timing evidence".to_owned())?;
1255        assert_eq!(
1256            timing.budget,
1257            crate::operation_bound::OperationBudgetEvidence::Finite {
1258                configured_ms: 1_000,
1259            }
1260        );
1261        assert_eq!(timing.terminal_cause, OperationTerminalCause::TimedOut);
1262        assert!(probe_failures.iter().any(|failure| {
1263            failure.contains("target registry has no \"decode\" target at \"http://decode:30001\"")
1264        }));
1265        Ok(())
1266    }
1267
1268    #[test]
1269    fn termination_waits_for_the_group_after_the_launcher_exits() -> Result<(), String> {
1270        let mut child = Command::new("sh")
1271            .args([
1272                "-c",
1273                "trap 'exit 0' TERM; sh -c 'trap \"\" TERM; exec sleep 30' & wait",
1274            ])
1275            .stdin(Stdio::null())
1276            .stdout(Stdio::null())
1277            .stderr(Stdio::null())
1278            .process_group(0)
1279            .spawn()
1280            .map_err(|error| error.to_string())?;
1281        let handle = HostProcessHandle::new(child.id(), None)?;
1282        thread::sleep(Duration::from_millis(100));
1283        let reaper = thread::spawn(move || child.wait());
1284
1285        let cleanup = terminate_local(&handle, CleanupTrigger::Stop);
1286        if !cleanup.verified {
1287            let _ = Command::new("kill")
1288                .args(["-KILL", "--", &format!("-{}", handle.process_group)])
1289                .status();
1290        }
1291        let _ = reaper.join();
1292
1293        assert!(cleanup.verified, "{cleanup:?}");
1294        assert!(cleanup.forced);
1295        assert!(cleanup.elapsed_ms >= cleanup.term_grace_ms);
1296        assert_eq!(cleanup.status_deadline_ms, 2_000);
1297        assert_eq!(cleanup.term_grace_ms, 2_000);
1298        assert_eq!(cleanup.kill_grace_ms, 10_000);
1299        Ok(())
1300    }
1301
1302    #[test]
1303    fn rejects_a_reused_process_identity() -> Result<(), Box<dyn std::error::Error>> {
1304        let pid = std::process::id();
1305        let actual = process_start_time(pid)
1306            .map_err(std::io::Error::other)?
1307            .ok_or_else(|| std::io::Error::other("test process has no /proc identity"))?;
1308        let recorded = if actual == u64::MAX {
1309            actual - 1
1310        } else {
1311            actual + 1
1312        };
1313        let status = verified_local_status(&HostProcessHandle {
1314            leader_pid: pid,
1315            process_group: pid,
1316            leader_start_time_ticks: recorded,
1317            container: None,
1318        });
1319
1320        assert!(status.queried);
1321        assert!(!status.alive);
1322        assert!(
1323            status
1324                .error
1325                .is_some_and(|error| error.contains("pid was reused"))
1326        );
1327        Ok(())
1328    }
1329}