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