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 #[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 #[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 #[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 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 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}