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 let (mut command, cleanup_token) = prepare_command(&spec, None)?;
32 let child = command.spawn().map_err(map_spawn_error)?;
33
34 let pid = child.id();
35 let pgid = child_process_group_id(pid);
36 let killer: Arc<dyn ProcessKiller> = Arc::new(RealKiller {
37 pid,
38 cleanup_token: cleanup_token.clone(),
39 });
40
41 Ok(Box::new(RealProcess {
42 pid,
43 pgid,
44 cleanup_token,
45 killer,
46 child: Some(child),
47 stdin: None,
48 stdout: None,
49 stderr: None,
50 stdin_taken: false,
51 stdout_taken: false,
52 stderr_taken: false,
53 }))
54 }
55}
56
57fn prepare_command(
58 spec: &SpawnSpec,
59 cleanup_token: Option<String>,
60) -> Result<(Command, String), ProcessError> {
61 if spec.program.is_empty() {
62 return Err(ProcessError::InvalidArgv(
63 "first element of argv must be a non-empty program name".to_string(),
64 ));
65 }
66
67 let mut command = process_sandbox::std_command_for(&spec.program, &spec.args)
68 .map_err(|e| ProcessError::SandboxSetup(format!("{e:?}")))?;
69
70 if let Some(cwd) = spec.cwd.as_ref() {
71 process_sandbox::enforce_process_cwd(cwd)
72 .map_err(|e| ProcessError::SandboxCwd(format!("{e:?}")))?;
73 command.current_dir(cwd);
74 }
75
76 match spec.env_mode {
77 EnvMode::Replace => {
79 command.env_clear();
80 }
81 EnvMode::InheritClean | EnvMode::Patch => {
88 for (key, _) in std::env::vars_os() {
89 if let Some(name) = key.to_str() {
90 if super::handle::is_sensitive_env_name(name) {
91 command.env_remove(&key);
92 }
93 }
94 }
95 }
96 }
97 for key in &spec.env_remove {
102 command.env_remove(key);
103 }
104 for (key, value) in &spec.env {
105 command.env(key, value);
106 }
107
108 for (key, value) in process_sandbox::active_workspace_tmpdir_env() {
118 if spec.env.contains_key(&key) {
119 continue;
120 }
121 command.env(key, value);
122 }
123
124 if !spec
132 .env
133 .contains_key(process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV)
134 {
135 command.env_remove(process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV);
136 }
137 for (key, value) in process_sandbox::deterministic_message_locale_env() {
138 if spec.env.contains_key(&key) {
139 continue;
140 }
141 command.env(key, value);
142 }
143
144 log_spawn_context(&command, spec.env_mode);
145
146 if spec.configure_process_group {
147 configure_background_process_group(&mut command);
148 }
149 let cleanup_token =
150 cleanup_token.unwrap_or_else(harn_vm::op_interrupt::new_process_cleanup_token);
151 command.env(
152 harn_vm::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV,
153 &cleanup_token,
154 );
155
156 match &spec.output_capture {
157 OutputCapture::Inherit => {
158 command.stdout(Stdio::inherit());
159 command.stderr(Stdio::inherit());
160 }
161 OutputCapture::Pipe => {
162 command.stdout(Stdio::piped());
163 command.stderr(Stdio::piped());
164 }
165 OutputCapture::File {
166 stdout_path,
167 stderr_path,
168 } => {
169 let stdout = OpenOptions::new()
170 .write(true)
171 .truncate(true)
172 .open(stdout_path)
173 .map_err(|error| ProcessError::Spawn(format!("open stdout capture: {error}")))?;
174 let stderr = OpenOptions::new()
175 .write(true)
176 .truncate(true)
177 .open(stderr_path)
178 .map_err(|error| ProcessError::Spawn(format!("open stderr capture: {error}")))?;
179 command.stdout(Stdio::from(stdout));
180 command.stderr(Stdio::from(stderr));
181 }
182 }
183 command.stdin(match (&spec.output_capture, spec.use_stdin) {
184 (OutputCapture::Inherit, true) => Stdio::inherit(),
185 (_, true) => Stdio::piped(),
186 (_, false) => Stdio::null(),
187 });
188
189 Ok((command, cleanup_token))
190}
191
192fn log_spawn_context(command: &Command, env_mode: EnvMode) {
196 let program = command.get_program().to_string_lossy();
197 let cwd = command
198 .get_current_dir()
199 .map(std::path::Path::to_path_buf)
200 .or_else(|| std::env::current_dir().ok());
201 let path = resolved_env_value(command, "PATH", env_mode)
202 .map(|value| value.to_string_lossy().into_owned());
203 tracing::debug!(
204 target: "harn_hostlib::process",
205 shell_or_program = %program,
206 cwd = %cwd.as_deref().map_or_else(|| "<unresolved>".into(), std::path::Path::to_string_lossy),
207 path = %path.as_deref().unwrap_or("<unset>"),
208 env_mode = ?env_mode,
209 "resolved command spawn context"
210 );
211}
212
213fn resolved_env_value(
214 command: &Command,
215 name: &str,
216 env_mode: EnvMode,
217) -> Option<std::ffi::OsString> {
218 for (key, value) in command.get_envs() {
219 if env_key_eq(key, name) {
220 return value.map(std::ffi::OsStr::to_os_string);
221 }
222 }
223 if env_mode == EnvMode::Replace {
224 None
225 } else {
226 std::env::var_os(name)
227 }
228}
229
230fn env_key_eq(key: &std::ffi::OsStr, expected: &str) -> bool {
231 #[cfg(windows)]
232 {
233 key.to_string_lossy().eq_ignore_ascii_case(expected)
234 }
235 #[cfg(not(windows))]
236 {
237 key == expected
238 }
239}
240
241fn map_spawn_error(error: io::Error) -> ProcessError {
242 if let Some(violation) = process_sandbox::process_spawn_error(&error) {
243 return ProcessError::SandboxSpawn(format!("{violation:?}"));
244 }
245 ProcessError::Spawn(error.to_string())
246}
247
248#[cfg(unix)]
251pub fn replace_current_process(spec: SpawnSpec) -> Result<std::convert::Infallible, ProcessError> {
252 use std::os::unix::process::CommandExt;
253
254 super::handle::validate_process_spec(&spec)?;
255 let inherited_cleanup_token = std::env::var(harn_vm::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV)
256 .ok()
257 .filter(|token| !token.is_empty());
258 let (mut command, _cleanup_token) = prepare_command(&spec, inherited_cleanup_token)?;
259 Err(map_spawn_error(command.exec()))
260}
261
262struct RealProcess {
263 pid: u32,
264 pgid: Option<u32>,
265 cleanup_token: String,
266 killer: Arc<dyn ProcessKiller>,
267 child: Option<Child>,
268 stdin: Option<ChildStdin>,
269 stdout: Option<ChildStdout>,
270 stderr: Option<ChildStderr>,
271 stdin_taken: bool,
272 stdout_taken: bool,
273 stderr_taken: bool,
274}
275
276impl RealProcess {
277 fn ensure_pipes_taken(&mut self) {
278 if let Some(child) = self.child.as_mut() {
279 if self.stdin.is_none() && !self.stdin_taken {
280 self.stdin = child.stdin.take();
281 }
282 if self.stdout.is_none() && !self.stdout_taken {
283 self.stdout = child.stdout.take();
284 }
285 if self.stderr.is_none() && !self.stderr_taken {
286 self.stderr = child.stderr.take();
287 }
288 }
289 }
290}
291
292impl ProcessHandle for RealProcess {
293 fn pid(&self) -> Option<u32> {
294 Some(self.pid)
295 }
296
297 fn process_group_id(&self) -> Option<u32> {
298 self.pgid
299 }
300
301 fn killer(&self) -> Arc<dyn ProcessKiller> {
302 Arc::clone(&self.killer)
303 }
304
305 fn take_stdin(&mut self) -> Option<Box<dyn Write + Send>> {
306 self.ensure_pipes_taken();
307 self.stdin_taken = true;
308 self.stdin
309 .take()
310 .map(|s| Box::new(s) as Box<dyn Write + Send>)
311 }
312
313 fn take_stdout(&mut self) -> Option<Box<dyn Read + Send>> {
314 self.ensure_pipes_taken();
315 self.stdout_taken = true;
316 self.stdout
317 .take()
318 .map(|s| Box::new(s) as Box<dyn Read + Send>)
319 }
320
321 fn take_stderr(&mut self) -> Option<Box<dyn Read + Send>> {
322 self.ensure_pipes_taken();
323 self.stderr_taken = true;
324 self.stderr
325 .take()
326 .map(|s| Box::new(s) as Box<dyn Read + Send>)
327 }
328
329 fn wait_with_timeout(
330 &mut self,
331 timeout: Option<Duration>,
332 interrupt: &dyn Fn() -> bool,
333 ) -> io::Result<WaitOutcome> {
334 let killer = Arc::clone(&self.killer);
335 let Some(child) = self.child.as_mut() else {
336 return Err(io::Error::other("child already reaped"));
337 };
338 let deadline = timeout.map(|timeout| Instant::now() + timeout);
339 loop {
340 match child.try_wait()? {
341 Some(status) => return Ok(WaitOutcome::Exited(decode_status(status))),
342 None => {
343 if interrupt() {
344 let (_, report) =
348 harn_vm::op_interrupt::terminate_child_group_with_cleanup_token_report(
349 child,
350 Some(&self.cleanup_token),
351 );
352 return Ok(WaitOutcome::Interrupted(report));
353 }
354 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
355 let mut report = killer.kill();
362 let _ = child.kill();
363 let _ = child.wait();
364 report.refresh_survivor_status();
365 return Ok(WaitOutcome::TimedOut(report));
366 }
367 let sleep = deadline
368 .map(|deadline| deadline.saturating_duration_since(Instant::now()))
369 .unwrap_or(Duration::MAX)
370 .min(Duration::from_millis(20));
371 thread::sleep(sleep);
372 }
373 }
374 }
375 }
376
377 fn wait(&mut self) -> io::Result<ExitStatus> {
378 let child = self
379 .child
380 .as_mut()
381 .ok_or_else(|| io::Error::other("child already reaped"))?;
382 let status = child.wait()?;
383 Ok(decode_status(status))
384 }
385}
386
387struct RealKiller {
388 pid: u32,
389 cleanup_token: String,
390}
391
392impl ProcessKiller for RealKiller {
393 fn kill(&self) -> ProcessCleanupReport {
394 let report = harn_vm::op_interrupt::signal_pid_tree_group_and_token_with_report(
395 self.pid,
396 Some(&self.cleanup_token),
397 9,
398 );
399 #[cfg(target_os = "windows")]
400 terminate_process(self.pid);
401 report
402 }
403}
404
405#[cfg(target_os = "windows")]
406fn terminate_process(pid: u32) {
407 use windows_sys::Win32::Foundation::CloseHandle;
408 use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
409
410 let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) };
411 if handle.is_null() {
412 return;
413 }
414 unsafe {
415 TerminateProcess(handle, 1);
416 CloseHandle(handle);
417 }
418}
419
420#[cfg(unix)]
421fn decode_status(status: std::process::ExitStatus) -> ExitStatus {
422 use std::os::unix::process::ExitStatusExt;
423 if let Some(code) = status.code() {
424 ExitStatus::from_code(code)
425 } else if let Some(sig) = status.signal() {
426 ExitStatus::from_signal(sig)
427 } else {
428 ExitStatus {
429 code: None,
430 signal: None,
431 }
432 }
433}
434
435#[cfg(not(unix))]
436fn decode_status(status: std::process::ExitStatus) -> ExitStatus {
437 ExitStatus::from_code(status.code().unwrap_or(-1))
438}
439
440pub(crate) fn child_process_group_id(pid: u32) -> Option<u32> {
441 #[cfg(unix)]
442 {
443 extern "C" {
444 fn getpgid(pid: i32) -> i32;
445 }
446 let pgid = unsafe { getpgid(pid as i32) };
447 if pgid > 0 {
448 Some(pgid as u32)
449 } else {
450 None
451 }
452 }
453 #[cfg(not(unix))]
454 {
455 Some(pid)
456 }
457}
458
459pub(crate) fn configure_background_process_group(command: &mut std::process::Command) {
460 #[cfg(unix)]
461 unsafe {
462 use std::os::unix::process::CommandExt;
463 command.pre_exec(|| {
464 extern "C" {
465 fn setpgid(pid: i32, pgid: i32) -> i32;
466 }
467 if setpgid(0, 0) == -1 {
468 return Err(std::io::Error::last_os_error());
469 }
470 Ok(())
471 });
472 }
473 #[cfg(not(unix))]
474 {
475 let _ = command;
476 }
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482
483 #[test]
484 fn resolved_path_prefers_the_child_override() {
485 let mut command = Command::new("shell");
486 command.env("PATH", "/resolved/toolchain/bin");
487
488 assert_eq!(
489 resolved_env_value(&command, "PATH", EnvMode::Patch),
490 Some(std::ffi::OsString::from("/resolved/toolchain/bin"))
491 );
492 }
493
494 #[test]
495 fn resolved_path_honors_an_explicit_removal() {
496 let mut command = Command::new("shell");
497 command.env_remove("PATH");
498
499 assert_eq!(resolved_env_value(&command, "PATH", EnvMode::Patch), None);
500 }
501
502 #[test]
503 fn replace_mode_does_not_report_an_inherited_path() {
504 let command = Command::new("shell");
505
506 assert_eq!(resolved_env_value(&command, "PATH", EnvMode::Replace), None);
507 }
508}