1use std::fs::OpenOptions;
5use std::io::{self, Read, Write};
6use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, Stdio};
7use std::sync::{Arc, LazyLock};
8use std::thread;
9use std::time::{Duration, Instant};
10
11use harn_vm::process_sandbox;
12
13use super::handle::{
14 EnvMode, ExitStatus, OutputCapture, ProcessCleanupReport, ProcessError, ProcessHandle,
15 ProcessKiller, ProcessSpawner, SpawnSpec, WaitOutcome,
16};
17
18pub struct RealSpawner;
20
21static REAL_SPAWNER: LazyLock<Arc<dyn ProcessSpawner>> =
22 LazyLock::new(|| Arc::new(RealSpawner) as Arc<dyn ProcessSpawner>);
23
24pub fn default_spawner() -> Arc<dyn ProcessSpawner> {
26 Arc::clone(&REAL_SPAWNER)
27}
28
29impl ProcessSpawner for RealSpawner {
30 fn spawn(&self, spec: SpawnSpec) -> Result<Box<dyn ProcessHandle>, ProcessError> {
31 #[cfg(unix)]
32 if spec.owner_death == super::OwnerDeathPolicy::KillContainment {
33 if spec.use_stdin {
34 return Err(ProcessError::InvalidArgv(
35 "owner-death containment reserves stdin for the liveness pipe".to_string(),
36 ));
37 }
38 if !matches!(spec.output_capture, OutputCapture::Pipe) {
39 return Err(ProcessError::InvalidArgv(
40 "owner-death containment requires piped output".to_string(),
41 ));
42 }
43 if !spec.configure_process_group {
44 return Err(ProcessError::InvalidArgv(
45 "owner-death containment requires an independent process group".to_string(),
46 ));
47 }
48 let cleanup_token = harn_vm::op_interrupt::new_process_cleanup_token();
49 let mut command = super::owner_death::prepare_guardian(&spec, cleanup_token.clone())?;
50 let mut child = match command.spawn() {
51 Ok(child) => child,
52 Err(error) => {
53 harn_vm::op_interrupt::remove_process_owner_group_journal(&cleanup_token);
54 return Err(map_spawn_error(error));
55 }
56 };
57 if let Err(error) =
58 harn_vm::op_interrupt::record_current_process_owner_group(child.id())
59 {
60 let _ = harn_vm::op_interrupt::signal_pid_tree_and_group_with_report(child.id(), 9);
61 let _ = child.wait();
62 harn_vm::op_interrupt::remove_process_owner_group_journal(&cleanup_token);
63 return Err(ProcessError::Spawn(format!(
64 "record guardian owner group: {error}"
65 )));
66 }
67 let liveness = match child.stdin.take() {
68 Some(liveness) => liveness,
69 None => {
70 let _ =
71 harn_vm::op_interrupt::signal_pid_tree_and_group_with_report(child.id(), 9);
72 let _ = child.wait();
73 harn_vm::op_interrupt::remove_process_owner_group_journal(&cleanup_token);
74 return Err(ProcessError::Spawn(
75 "guardian liveness pipe was not created".to_string(),
76 ));
77 }
78 };
79 let (stderr, guardian_pid, payload_pid) =
80 match super::owner_death::await_startup(&mut child) {
81 Ok(startup) => startup,
82 Err(error) => {
83 let _ = harn_vm::op_interrupt::signal_pid_tree_and_group_with_report(
84 child.id(),
85 9,
86 );
87 let _ = child.wait();
88 harn_vm::op_interrupt::remove_process_owner_group_journal(&cleanup_token);
89 return Err(error);
90 }
91 };
92 return Ok(real_process(
93 child,
94 cleanup_token,
95 Some(liveness),
96 Some(stderr),
97 Some(guardian_pid),
98 Some(payload_pid),
99 None,
100 ));
101 }
102
103 let (mut command, cleanup_token) = prepare_command(&spec, None)?;
104 #[cfg(target_os = "windows")]
105 let owner_job = if spec.owner_death == super::OwnerDeathPolicy::KillContainment
106 || spec.configure_process_group
107 {
108 let job = super::windows::KillOnCloseJob::new().map_err(|error| {
109 ProcessError::Spawn(format!("create process-tree Job Object: {error}"))
110 })?;
111 super::windows::configure_suspended(&mut command);
112 Some(Arc::new(job))
113 } else {
114 None
115 };
116 #[cfg(not(target_os = "windows"))]
117 let owner_job = None;
118 let mut child = command.spawn().map_err(map_spawn_error)?;
119 if let Err(error) = harn_vm::op_interrupt::record_current_process_owner_group(child.id()) {
120 let _ = harn_vm::op_interrupt::signal_pid_tree_and_group_with_report(child.id(), 9);
121 let _ = child.wait();
122 return Err(ProcessError::Spawn(format!(
123 "record process owner group: {error}"
124 )));
125 }
126
127 #[cfg(target_os = "windows")]
128 if let Some(job) = &owner_job {
129 if let Err(error) = job
130 .assign_process(child.id())
131 .and_then(|()| super::windows::resume_process(child.id()))
132 {
133 let mut child = child;
134 let _ = job.terminate();
135 let _ = child.kill();
136 let _ = child.wait();
137 return Err(ProcessError::Spawn(format!(
138 "contain suspended worker in owner Job Object: {error}"
139 )));
140 }
141 }
142
143 Ok(real_process(
144 child,
145 cleanup_token,
146 None,
147 None,
148 None,
149 None,
150 owner_job,
151 ))
152 }
153}
154
155pub(crate) fn prepare_command(
156 spec: &SpawnSpec,
157 cleanup_token: Option<String>,
158) -> Result<(Command, String), ProcessError> {
159 if spec.program.is_empty() {
160 return Err(ProcessError::InvalidArgv(
161 "first element of argv must be a non-empty program name".to_string(),
162 ));
163 }
164
165 let mut command = process_sandbox::std_command_for(&spec.program, &spec.args)
166 .map_err(|e| ProcessError::SandboxSetup(format!("{e:?}")))?;
167
168 if let Some(cwd) = spec.cwd.as_ref() {
169 process_sandbox::enforce_process_cwd(cwd)
170 .map_err(|e| ProcessError::SandboxCwd(format!("{e:?}")))?;
171 command.current_dir(cwd);
172 }
173
174 match spec.env_mode {
175 EnvMode::Replace => {
177 command.env_clear();
178 }
179 EnvMode::InheritClean | EnvMode::Patch => {
186 for (key, _) in std::env::vars_os() {
187 if let Some(name) = key.to_str() {
188 if super::handle::is_sensitive_env_name(name) {
189 command.env_remove(&key);
190 }
191 }
192 }
193 }
194 }
195 for key in &spec.env_remove {
200 command.env_remove(key);
201 }
202 for (key, value) in &spec.env {
203 command.env(key, value);
204 }
205
206 for (key, value) in process_sandbox::active_workspace_process_env() {
210 if spec.env.contains_key(&key) {
211 continue;
212 }
213 command.env(key, value);
214 }
215
216 if !spec
224 .env
225 .contains_key(process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV)
226 {
227 command.env_remove(process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV);
228 }
229 for (key, value) in process_sandbox::deterministic_message_locale_env() {
230 if spec.env.contains_key(&key) {
231 continue;
232 }
233 command.env(key, value);
234 }
235
236 log_spawn_context(&command, spec.env_mode);
237
238 if spec.configure_process_group {
239 configure_background_process_group(&mut command);
240 }
241 let cleanup_token =
242 cleanup_token.unwrap_or_else(harn_vm::op_interrupt::new_process_cleanup_token);
243 command.env(
244 harn_vm::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV,
245 &cleanup_token,
246 );
247 harn_vm::op_interrupt::preserve_process_owner_token(&mut command);
248
249 match &spec.output_capture {
250 OutputCapture::Inherit => {
251 command.stdout(Stdio::inherit());
252 command.stderr(Stdio::inherit());
253 }
254 OutputCapture::Pipe => {
255 command.stdout(Stdio::piped());
256 command.stderr(Stdio::piped());
257 }
258 OutputCapture::File {
259 stdout_path,
260 stderr_path,
261 } => {
262 let stdout = OpenOptions::new()
263 .write(true)
264 .truncate(true)
265 .open(stdout_path)
266 .map_err(|error| ProcessError::Spawn(format!("open stdout capture: {error}")))?;
267 let stderr = OpenOptions::new()
268 .write(true)
269 .truncate(true)
270 .open(stderr_path)
271 .map_err(|error| ProcessError::Spawn(format!("open stderr capture: {error}")))?;
272 command.stdout(Stdio::from(stdout));
273 command.stderr(Stdio::from(stderr));
274 }
275 }
276 command.stdin(match (&spec.output_capture, spec.use_stdin) {
277 (OutputCapture::Inherit, true) => Stdio::inherit(),
278 (_, true) => Stdio::piped(),
279 (_, false) => Stdio::null(),
280 });
281
282 Ok((command, cleanup_token))
283}
284
285fn log_spawn_context(command: &Command, env_mode: EnvMode) {
289 let program = command.get_program().to_string_lossy();
290 let cwd = command
291 .get_current_dir()
292 .map(std::path::Path::to_path_buf)
293 .or_else(|| std::env::current_dir().ok());
294 let path = resolved_env_value(command, "PATH", env_mode)
295 .map(|value| value.to_string_lossy().into_owned());
296 tracing::debug!(
297 target: "harn_hostlib::process",
298 shell_or_program = %program,
299 cwd = %cwd.as_deref().map_or_else(|| "<unresolved>".into(), std::path::Path::to_string_lossy),
300 path = %path.as_deref().unwrap_or("<unset>"),
301 env_mode = ?env_mode,
302 "resolved command spawn context"
303 );
304}
305
306fn resolved_env_value(
307 command: &Command,
308 name: &str,
309 env_mode: EnvMode,
310) -> Option<std::ffi::OsString> {
311 for (key, value) in command.get_envs() {
312 if env_key_eq(key, name) {
313 return value.map(std::ffi::OsStr::to_os_string);
314 }
315 }
316 if env_mode == EnvMode::Replace {
317 None
318 } else {
319 std::env::var_os(name)
320 }
321}
322
323fn env_key_eq(key: &std::ffi::OsStr, expected: &str) -> bool {
324 #[cfg(windows)]
325 {
326 key.to_string_lossy().eq_ignore_ascii_case(expected)
327 }
328 #[cfg(not(windows))]
329 {
330 key == expected
331 }
332}
333
334fn map_spawn_error(error: io::Error) -> ProcessError {
335 if let Some(violation) = process_sandbox::process_spawn_error(&error) {
336 return ProcessError::SandboxSpawn(format!("{violation:?}"));
337 }
338 ProcessError::SpawnIo {
339 kind: harn_vm::value::io_error_kind_str(&error),
340 message: error.to_string(),
341 }
342}
343
344#[cfg(unix)]
347pub fn replace_current_process(spec: SpawnSpec) -> Result<std::convert::Infallible, ProcessError> {
348 use std::os::unix::process::CommandExt;
349
350 super::handle::validate_process_spec(&spec)?;
351 let inherited_cleanup_token = std::env::var(harn_vm::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV)
352 .ok()
353 .filter(|token| !token.is_empty());
354 let (mut command, _cleanup_token) = prepare_command(&spec, inherited_cleanup_token)?;
355 Err(map_spawn_error(command.exec()))
356}
357
358struct RealProcess {
359 pid: u32,
360 pgid: Option<u32>,
361 #[cfg(not(target_os = "windows"))]
362 cleanup_token: String,
363 killer: Arc<dyn ProcessKiller>,
364 child: Option<Child>,
365 stdin: Option<ChildStdin>,
366 stdout: Option<ChildStdout>,
367 stderr: Option<ChildStderr>,
368 owner_liveness: Option<ChildStdin>,
369 stdin_taken: bool,
370 stdout_taken: bool,
371 stderr_taken: bool,
372}
373
374fn real_process(
375 child: Child,
376 cleanup_token: String,
377 owner_liveness: Option<ChildStdin>,
378 stderr: Option<ChildStderr>,
379 reported_pid: Option<u32>,
380 killer_pid: Option<u32>,
381 #[cfg(target_os = "windows")] owner_job: Option<Arc<super::windows::KillOnCloseJob>>,
382 #[cfg(not(target_os = "windows"))] _owner_job: Option<()>,
383) -> Box<dyn ProcessHandle> {
384 let pid = reported_pid.unwrap_or_else(|| child.id());
385 let pgid = child_process_group_id(pid);
386 let killer: Arc<dyn ProcessKiller> = Arc::new(RealKiller {
387 pid: killer_pid.unwrap_or(pid),
388 cleanup_token: cleanup_token.clone(),
389 #[cfg(target_os = "windows")]
390 owner_job,
391 });
392 Box::new(RealProcess {
393 pid,
394 pgid,
395 #[cfg(not(target_os = "windows"))]
396 cleanup_token,
397 killer,
398 child: Some(child),
399 stdin: None,
400 stdout: None,
401 stderr,
402 owner_liveness,
403 stdin_taken: false,
404 stdout_taken: false,
405 stderr_taken: false,
406 })
407}
408
409impl RealProcess {
410 fn ensure_pipes_taken(&mut self) {
411 if let Some(child) = self.child.as_mut() {
412 if self.owner_liveness.is_none() && self.stdin.is_none() && !self.stdin_taken {
413 self.stdin = child.stdin.take();
414 }
415 if self.stdout.is_none() && !self.stdout_taken {
416 self.stdout = child.stdout.take();
417 }
418 if self.stderr.is_none() && !self.stderr_taken {
419 self.stderr = child.stderr.take();
420 }
421 }
422 }
423}
424
425impl ProcessHandle for RealProcess {
426 fn pid(&self) -> Option<u32> {
427 Some(self.pid)
428 }
429
430 fn process_group_id(&self) -> Option<u32> {
431 self.pgid
432 }
433
434 fn killer(&self) -> Arc<dyn ProcessKiller> {
435 Arc::clone(&self.killer)
436 }
437
438 fn take_stdin(&mut self) -> Option<Box<dyn Write + Send>> {
439 self.ensure_pipes_taken();
440 self.stdin_taken = true;
441 self.stdin
442 .take()
443 .map(|s| Box::new(s) as Box<dyn Write + Send>)
444 }
445
446 fn take_stdout(&mut self) -> Option<Box<dyn Read + Send>> {
447 self.ensure_pipes_taken();
448 self.stdout_taken = true;
449 self.stdout
450 .take()
451 .map(|s| Box::new(s) as Box<dyn Read + Send>)
452 }
453
454 fn take_stderr(&mut self) -> Option<Box<dyn Read + Send>> {
455 self.ensure_pipes_taken();
456 self.stderr_taken = true;
457 self.stderr
458 .take()
459 .map(|s| Box::new(s) as Box<dyn Read + Send>)
460 }
461
462 fn wait_with_timeout(
463 &mut self,
464 timeout: Option<Duration>,
465 interrupt: &dyn Fn() -> bool,
466 ) -> io::Result<WaitOutcome> {
467 let killer = Arc::clone(&self.killer);
468 let owner_death_contained = self.owner_liveness.is_some();
469 let Some(child) = self.child.as_mut() else {
470 return Err(io::Error::other("child already reaped"));
471 };
472 let deadline = timeout.map(|timeout| Instant::now() + timeout);
473 loop {
474 match child.try_wait()? {
475 Some(status) => return Ok(WaitOutcome::Exited(decode_status(status))),
476 None => {
477 if interrupt() {
478 if owner_death_contained {
479 let report = killer.kill();
480 let _ = child.wait();
481 return Ok(WaitOutcome::Interrupted(report));
482 }
483 #[cfg(target_os = "windows")]
484 {
485 let mut report = killer.kill();
490 let _ = child.kill();
491 let _ = child.wait();
492 report.refresh_survivor_status();
493 return Ok(WaitOutcome::Interrupted(report));
494 }
495 #[cfg(not(target_os = "windows"))]
499 let (_, report) =
500 harn_vm::op_interrupt::terminate_child_group_with_cleanup_token_report(
501 child,
502 Some(&self.cleanup_token),
503 );
504 #[cfg(not(target_os = "windows"))]
505 return Ok(WaitOutcome::Interrupted(report));
506 }
507 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
508 let mut report = killer.kill();
515 if !owner_death_contained {
516 let _ = child.kill();
517 }
518 let _ = child.wait();
519 report.refresh_survivor_status();
520 return Ok(WaitOutcome::TimedOut(report));
521 }
522 let sleep = deadline
523 .map(|deadline| deadline.saturating_duration_since(Instant::now()))
524 .unwrap_or(Duration::MAX)
525 .min(Duration::from_millis(20));
526 thread::sleep(sleep);
527 }
528 }
529 }
530 }
531
532 fn wait(&mut self) -> io::Result<ExitStatus> {
533 let child = self
534 .child
535 .as_mut()
536 .ok_or_else(|| io::Error::other("child already reaped"))?;
537 let status = child.wait()?;
538 Ok(decode_status(status))
539 }
540}
541
542struct RealKiller {
543 pid: u32,
544 cleanup_token: String,
545 #[cfg(target_os = "windows")]
546 owner_job: Option<Arc<super::windows::KillOnCloseJob>>,
547}
548
549impl ProcessKiller for RealKiller {
550 fn kill(&self) -> ProcessCleanupReport {
551 let report = harn_vm::op_interrupt::signal_pid_tree_group_and_token_with_report(
552 self.pid,
553 Some(&self.cleanup_token),
554 9,
555 );
556 #[cfg(target_os = "windows")]
557 if let Some(job) = &self.owner_job {
558 let _ = job.terminate();
559 } else {
560 terminate_process(self.pid);
561 }
562 report
563 }
564}
565
566#[cfg(target_os = "windows")]
567fn terminate_process(pid: u32) {
568 use windows_sys::Win32::Foundation::CloseHandle;
569 use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
570
571 let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) };
572 if handle.is_null() {
573 return;
574 }
575 unsafe {
576 TerminateProcess(handle, 1);
577 CloseHandle(handle);
578 }
579}
580
581#[cfg(unix)]
582fn decode_status(status: std::process::ExitStatus) -> ExitStatus {
583 use std::os::unix::process::ExitStatusExt;
584 if let Some(code) = status.code() {
585 ExitStatus::from_code(code)
586 } else if let Some(sig) = status.signal() {
587 ExitStatus::from_signal(sig)
588 } else {
589 ExitStatus {
590 code: None,
591 signal: None,
592 }
593 }
594}
595
596#[cfg(not(unix))]
597fn decode_status(status: std::process::ExitStatus) -> ExitStatus {
598 ExitStatus::from_code(status.code().unwrap_or(-1))
599}
600
601pub(crate) fn child_process_group_id(pid: u32) -> Option<u32> {
602 #[cfg(unix)]
603 {
604 extern "C" {
605 fn getpgid(pid: i32) -> i32;
606 }
607 let pgid = unsafe { getpgid(pid as i32) };
608 if pgid > 0 {
609 Some(pgid as u32)
610 } else {
611 None
612 }
613 }
614 #[cfg(not(unix))]
615 {
616 Some(pid)
617 }
618}
619
620pub(crate) fn configure_background_process_group(command: &mut std::process::Command) {
621 #[cfg(unix)]
622 {
623 use std::os::unix::process::CommandExt;
624 command.process_group(0);
625 }
626 #[cfg(not(unix))]
627 {
628 let _ = command;
629 }
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635
636 #[test]
637 fn resolved_path_prefers_the_child_override() {
638 let mut command = Command::new("shell");
639 command.env("PATH", "/resolved/toolchain/bin");
640
641 assert_eq!(
642 resolved_env_value(&command, "PATH", EnvMode::Patch),
643 Some(std::ffi::OsString::from("/resolved/toolchain/bin"))
644 );
645 }
646
647 #[test]
648 fn resolved_path_honors_an_explicit_removal() {
649 let mut command = Command::new("shell");
650 command.env_remove("PATH");
651
652 assert_eq!(resolved_env_value(&command, "PATH", EnvMode::Patch), None);
653 }
654
655 #[test]
656 fn replace_mode_does_not_report_an_inherited_path() {
657 let command = Command::new("shell");
658
659 assert_eq!(resolved_env_value(&command, "PATH", EnvMode::Replace), None);
660 }
661}