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