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