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