1use fs2::FileExt;
3use std::io::Read;
4use std::process::Stdio;
5use std::sync::{Arc, Mutex, atomic::AtomicBool, mpsc};
6use std::time::{Duration, Instant};
7
8const READER_RESULT_TIMEOUT: Duration = Duration::from_secs(2);
9
10const TIMEOUT_MARKER: &str = "ERROR: command timed out after ";
12
13fn is_build_or_test_command(command: &str) -> bool {
14 let tokens: Vec<_> = command
15 .split(|ch: char| ch.is_ascii_whitespace() || matches!(ch, ';' | '&' | '|'))
16 .filter(|token| !token.is_empty())
17 .collect();
18 tokens.windows(2).any(|pair| {
19 let tool = std::path::Path::new(pair[0])
20 .file_name()
21 .and_then(|name| name.to_str())
22 .unwrap_or(pair[0]);
23 (tool == "cargo"
24 && matches!(
25 pair[1],
26 "build" | "check" | "clippy" | "test" | "run" | "bench" | "install" | "fast"
27 ))
28 || (matches!(tool, "npm" | "pnpm" | "yarn" | "bun")
29 && matches!(pair[1], "test" | "build" | "check"))
30 || (tool == "lean-ctx" && pair[1] == "dev-install")
31 || (matches!(tool, "make" | "gmake")
32 && matches!(
33 pair[1],
34 "build"
35 | "check"
36 | "dev"
37 | "install"
38 | "install-release"
39 | "test"
40 | "preflight"
41 | "preflight-fast"
42 ))
43 }) || tokens.windows(3).any(|triple| {
44 matches!(triple[0], "npm" | "pnpm" | "yarn" | "bun")
45 && triple[1] == "run"
46 && matches!(triple[2], "test" | "build" | "check" | "lint" | "typecheck")
47 }) || tokens.iter().any(|token| {
48 std::path::Path::new(token)
49 .file_name()
50 .and_then(|name| name.to_str())
51 .is_some_and(|name| matches!(name, "rustc" | "dev-install.sh" | "install.sh"))
52 })
53}
54
55fn acquire_build_lease(
56 command: &str,
57 cancel: Option<&AtomicBool>,
58) -> Result<Option<std::fs::File>, String> {
59 let config = lean_ctx::core::config::Config::load();
60 if !config.agents.serialize_build_commands || !is_build_or_test_command(command) {
61 return Ok(None);
62 }
63 let dir = lean_ctx::core::data_dir::lean_ctx_data_dir()?.join("resources");
64 std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
65 let path = dir.join("build.lock");
66 let file = std::fs::OpenOptions::new()
67 .create(true)
68 .truncate(false)
69 .read(true)
70 .write(true)
71 .open(&path)
72 .map_err(|error| format!("open build lease {}: {error}", path.display()))?;
73 loop {
74 match file.try_lock_exclusive() {
75 Ok(()) => return Ok(Some(file)),
76 Err(error) if lock_is_contended(&error) => {
77 if cancel.is_some_and(|flag| flag.load(std::sync::atomic::Ordering::Acquire)) {
78 return Err(
79 "build cancelled while waiting for the machine-wide slot".to_string()
80 );
81 }
82 std::thread::sleep(Duration::from_millis(100));
83 }
84 Err(error) => {
85 return Err(format!("acquire build lease {}: {error}", path.display()));
86 }
87 }
88 }
89}
90
91fn lock_is_contended(error: &std::io::Error) -> bool {
92 error.kind() == std::io::ErrorKind::WouldBlock
93 || cfg!(windows) && matches!(error.raw_os_error(), Some(32 | 33))
94}
95
96pub(crate) fn output_before_timeout_marker(output: &str) -> Option<&str> {
106 output.find(TIMEOUT_MARKER).map(|idx| output[..idx].trim())
107}
108
109#[cfg(test)]
110pub(crate) fn execute_command_in(command: &str, cwd: &str) -> (String, i32) {
111 execute_command_with_env(command, cwd, &std::collections::HashMap::new(), None)
112}
113
114#[cfg(test)]
115pub(crate) fn execute_command_with_env(
116 command: &str,
117 cwd: &str,
118 extra_env: &std::collections::HashMap<String, String>,
119 timeout_ms: Option<u64>,
120) -> (String, i32) {
121 execute_command_with_env_cancellable(command, cwd, extra_env, timeout_ms, None, false, None)
122}
123
124pub(crate) struct ShellExecution {
142 pub output: String,
143 pub exit_code: i32,
144 pub completeness: lean_ctx::core::context_admission::OutputCompleteness,
145 pub notice: Option<&'static str>,
151}
152
153pub(crate) fn with_notice(notice: Option<&'static str>, text: String) -> String {
156 match notice {
157 Some(notice) => format!("{notice}\n{text}"),
158 None => text,
159 }
160}
161
162#[cfg(unix)]
166fn split_notice(text: String) -> (Option<&'static str>, String) {
167 match leading_notice(&text) {
168 Some(notice) => {
169 let rest = text.get(notice.len() + 1..).unwrap_or_default().to_string();
170 (Some(notice), rest)
171 }
172 None => (None, text),
173 }
174}
175
176#[cfg(unix)]
178pub(crate) fn leading_notice(text: &str) -> Option<&'static str> {
179 use lean_ctx::shell::containment::{END_NOTICE, HELPER_NOTICE, RELEASE_NOTICE, UNCLEAR_NOTICE};
180 [HELPER_NOTICE, END_NOTICE, UNCLEAR_NOTICE, RELEASE_NOTICE]
181 .into_iter()
182 .find(|notice| {
183 text.strip_prefix(notice)
184 .is_some_and(|rest| rest.is_empty() || rest.starts_with('\n'))
185 })
186}
187
188#[cfg(not(unix))]
190pub(crate) fn leading_notice(_text: &str) -> Option<&'static str> {
191 None
192}
193
194pub(crate) fn keep_notice(notice: Option<&'static str>, text: String) -> String {
198 if leading_notice(&text).is_some() {
199 text
200 } else {
201 with_notice(notice, text)
202 }
203}
204
205impl ShellExecution {
206 fn complete(output: String, exit_code: i32) -> Self {
207 Self {
208 output,
209 exit_code,
210 completeness: lean_ctx::core::context_admission::OutputCompleteness::complete(),
211 notice: None,
212 }
213 }
214
215 #[cfg(unix)]
218 fn unknown_capture(output: String, exit_code: i32) -> Self {
219 Self {
220 output,
221 exit_code,
222 completeness: lean_ctx::core::context_admission::OutputCompleteness {
223 reader_incomplete: true,
224 ..lean_ctx::core::context_admission::OutputCompleteness::complete()
225 },
226 notice: None,
227 }
228 }
229}
230
231#[cfg(unix)]
235pub fn register_protected_shell_runner() {
236 lean_ctx::core::protected_execution::register_shell_runner(
237 execute_command_with_env_cancellable,
238 );
239}
240
241#[cfg(any(unix, test))]
244pub(crate) fn execute_command_with_env_cancellable(
245 command: &str,
246 cwd: &str,
247 extra_env: &std::collections::HashMap<String, String>,
248 timeout_ms: Option<u64>,
249 cancel: Option<&AtomicBool>,
250 idle_keyed: bool,
251 live: Option<&std::sync::Mutex<String>>,
252) -> (String, i32) {
253 let _ = live;
256 let execution = execute_command_with_env_cancellable_captured(
257 command, cwd, extra_env, timeout_ms, cancel, idle_keyed,
258 );
259 (
261 with_notice(execution.notice, execution.output),
262 execution.exit_code,
263 )
264}
265
266pub(crate) fn execute_command_with_env_captured(
267 command: &str,
268 cwd: &str,
269 extra_env: &std::collections::HashMap<String, String>,
270 timeout_ms: Option<u64>,
271) -> ShellExecution {
272 execute_command_with_env_cancellable_captured(command, cwd, extra_env, timeout_ms, None, false)
273}
274
275pub(crate) fn execute_command_with_env_cancellable_captured(
276 command: &str,
277 cwd: &str,
278 extra_env: &std::collections::HashMap<String, String>,
279 timeout_ms: Option<u64>,
280 cancel: Option<&AtomicBool>,
281 idle_keyed: bool,
282) -> ShellExecution {
283 #[cfg(unix)]
298 let protected_prefix: Option<Vec<String>> = {
299 use lean_ctx::core::protected_execution::{Mode, launch_mode, sandbox_prefix};
300 match launch_mode() {
301 Err(error) => return ShellExecution::complete(format!("ERROR: {error}"), 1),
302 Ok(Mode::Brokered(client)) => {
303 let (output, exit_code) = client.shell(
304 command, cwd, extra_env, timeout_ms, cancel, idle_keyed, None,
305 );
306 let (notice, output) = split_notice(output);
307 return ShellExecution {
308 notice,
309 ..ShellExecution::unknown_capture(output, exit_code)
310 };
311 }
312 Ok(Mode::Sandboxed(profile)) => Some(sandbox_prefix(&profile)),
313 Ok(Mode::Direct) => None,
314 }
315 };
316 #[cfg(not(unix))]
317 let protected_prefix = match lean_ctx::core::protected_store::protected_child_prefix() {
318 Ok(prefix) => prefix,
319 Err(error) => return ShellExecution::complete(format!("ERROR: {error}"), 1),
320 };
321
322 let _build_lease = match acquire_build_lease(command, cancel) {
323 Ok(lease) => lease,
324 Err(error) => return ShellExecution::complete(format!("ERROR: {error}"), 130),
325 };
326 let resource_config = lean_ctx::core::config::Config::load().agents;
327 let (shell, flag) = lean_ctx::shell::shell_and_flag();
328 let normalized_cmd = crate::tools::ctx_shell::normalize_command_for_shell(command);
329 let normalized_cmd = lean_ctx::shell::platform::zsh_safe_command(&normalized_cmd, &shell);
330 let dir = std::path::Path::new(cwd);
331 let mut cmd = std::process::Command::new(&shell);
332 if let Some([program, arguments @ ..]) = protected_prefix.as_deref() {
333 cmd = std::process::Command::new(program);
334 cmd.args(arguments).arg(&shell);
335 }
336 if cfg!(windows) && lean_ctx::shell::platform::is_powershell(&shell) {
337 cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass"]);
338 }
339 cmd.arg(&flag)
340 .arg(&normalized_cmd)
341 .env("GIT_TERMINAL_PROMPT", "0")
342 .stdin(Stdio::null());
343 lean_ctx::shell::reentry::mark_child(&mut cmd);
344
345 if !extra_env.contains_key("GIT_PAGER") {
346 cmd.env("GIT_PAGER", "cat");
347 }
348 if !extra_env.contains_key("PAGER") {
349 cmd.env("PAGER", "cat");
350 }
351
352 ensure_utf8_locale(&mut cmd, extra_env);
353 lean_ctx::shell::platform::apply_profile_free_env(&mut cmd);
354
355 let mut forwarded: std::collections::HashSet<String> = std::collections::HashSet::new();
364 for (key, val) in std::env::vars() {
365 if lean_ctx::core::agent_runtime_env::is_forwardable(&key) {
366 cmd.env(&key, &val);
367 forwarded.insert(key);
368 }
369 }
370 for (key, val) in lean_ctx::core::agent_runtime_env::load() {
371 if !forwarded.contains(&key) {
372 cmd.env(&key, &val);
373 }
374 }
375
376 for (key, val) in extra_env {
378 cmd.env(key, val);
379 }
380 if is_build_or_test_command(command) {
381 if resource_config.cargo_build_jobs > 0 && !extra_env.contains_key("CARGO_BUILD_JOBS") {
382 cmd.env(
383 "CARGO_BUILD_JOBS",
384 resource_config.cargo_build_jobs.to_string(),
385 );
386 }
387 if resource_config.shared_cargo_target
388 && !extra_env.contains_key("CARGO_TARGET_DIR")
389 && let Ok(data_dir) = lean_ctx::core::data_dir::lean_ctx_data_dir()
390 {
391 cmd.env(
392 "CARGO_TARGET_DIR",
393 data_dir.join("build-cache/cargo-target"),
394 );
395 }
396 }
397 if dir.is_dir() {
398 cmd.current_dir(dir);
399 } else {
400 return ShellExecution::complete(
401 format!("ERROR: working directory does not exist or is not a directory: {cwd}"),
402 1,
403 );
404 }
405 let cap = lean_ctx::core::limits::max_shell_bytes();
406
407 lean_ctx::shell::process_tree::ProcessTree::prepare(&mut cmd);
429
430 let started = match lean_ctx::shell::process_tree::ShellChild::start(&mut cmd) {
431 Ok(started) => started,
432 Err(e) => return ShellExecution::complete(format!("ERROR: {e}"), 1),
433 };
434 let mut child = started.child;
435 let (out_buf, out_done) = spawn_capture(started.stdout, cap);
439 let (err_buf, err_done) = spawn_capture(started.stderr, cap);
440
441 let timeout = command_timeout(command, timeout_ms);
442 let start = Instant::now();
443 let hard_deadline = idle_keyed.then(|| start + streaming_max_lifetime());
444 let mut last_len = 0usize;
445 let mut last_output_at = start;
446 let mut still_running: Vec<String> = Vec::new();
448 let (code, timed_out, cancelled) = loop {
449 match child.try_wait() {
450 Ok(Some(status)) => {
451 break (
452 lean_ctx::shell::exit_status::exit_code(status),
453 false,
454 false,
455 );
456 }
457 Ok(None) => {
458 if cancel.is_some_and(|signal| signal.load(std::sync::atomic::Ordering::Acquire)) {
459 child.kill();
460 child.wait();
461 break (130, false, true);
462 }
463 let idle_for = if idle_keyed {
464 let len = captured_len(&out_buf) + captured_len(&err_buf);
465 if len != last_len {
466 last_len = len;
467 last_output_at = Instant::now();
468 }
469 last_output_at.elapsed()
470 } else {
471 start.elapsed()
472 };
473 if idle_for >= timeout || hard_deadline.is_some_and(|d| Instant::now() >= d) {
474 still_running = running_segments(child.id(), &normalized_cmd);
475 child.kill();
476 child.wait();
477 break (124, true, false);
478 }
479 std::thread::sleep(Duration::from_millis(25));
480 }
481 Err(_) => {
482 child.kill();
483 break (1, false, false);
484 }
485 }
486 };
487 let lost_notice = child.lost_notice();
492 let release_notice = child.release();
496 let containment_notice = started.notice.or(lost_notice).or(release_notice);
497
498 let reader_deadline = Instant::now() + READER_RESULT_TIMEOUT;
503 let out_complete = wait_for_reader(&out_done, reader_deadline);
504 let err_complete = wait_for_reader(&err_done, reader_deadline);
505 let (out_bytes, out_trunc, out_read_failed) = snapshot(&out_buf);
506 let (err_bytes, err_trunc, err_read_failed) = snapshot(&err_buf);
507 let reader_incomplete = !out_complete || !err_complete || out_read_failed || err_read_failed;
508 let decode_lossy =
511 std::str::from_utf8(&out_bytes).is_err() || std::str::from_utf8(&err_bytes).is_err();
512
513 let stdout =
514 lean_ctx::shell::resolve_carriage_returns(&lean_ctx::shell::decode_output(&out_bytes));
515 let stderr =
516 lean_ctx::shell::resolve_carriage_returns(&lean_ctx::shell::decode_output(&err_bytes));
517 let mut text = lean_ctx::shell::combine_streams(&stdout, &stderr, code);
520
521 if out_trunc || err_trunc {
522 text.push_str(&format!(
523 "\n[truncated: cap={}B stdout={}B stderr={}B]",
524 cap,
525 out_bytes.len(),
526 err_bytes.len()
527 ));
528 }
529 if reader_incomplete && !timed_out {
534 if !text.is_empty() && !text.ends_with('\n') {
535 text.push('\n');
536 }
537 text.push_str(&format!(
538 "[lean-ctx: output reader still draining after {}s — a background process is likely \
539 holding the pipe open; output above may be partial]",
540 READER_RESULT_TIMEOUT.as_secs()
541 ));
542 }
543 if timed_out {
544 if !text.ends_with('\n') && !text.is_empty() {
545 text.push('\n');
546 }
547 text.push_str(&format!(
548 "{TIMEOUT_MARKER}{}ms{}",
549 timeout.as_millis(),
550 if idle_keyed {
551 " without new output"
552 } else {
553 ""
554 }
555 ));
556 if !still_running.is_empty() {
566 text.push_str(&format!(
567 "\n[still running at timeout: {}]",
568 still_running.join("; ")
569 ));
570 }
571 }
572 if let Some(hint) = super::walk_hint::walk_hint(command, cwd) {
584 if !text.is_empty() && !text.ends_with('\n') {
585 text.push('\n');
586 }
587 text.push_str(&hint);
588 }
589 if cancelled {
590 if !text.ends_with('\n') && !text.is_empty() {
591 text.push('\n');
592 }
593 text.push_str("[cancelled: command stopped on request]");
595 }
596
597 ShellExecution {
598 output: text,
599 exit_code: code,
600 notice: containment_notice,
602 completeness: lean_ctx::core::context_admission::OutputCompleteness {
603 truncated: out_trunc || err_trunc,
604 timed_out,
605 reader_incomplete: reader_incomplete || lost_notice.is_some(),
606 cancelled,
607 decode_lossy,
608 },
609 }
610}
611
612#[derive(Default)]
618struct CaptureBuf {
619 bytes: Vec<u8>,
620 truncated: bool,
621 read_failed: bool,
622}
623
624fn spawn_capture<R: Read + Send + 'static>(
629 pipe: Option<R>,
630 cap: usize,
631) -> (Arc<Mutex<CaptureBuf>>, mpsc::Receiver<()>) {
632 let shared = Arc::new(Mutex::new(CaptureBuf::default()));
633 let (done_tx, done_rx) = mpsc::channel();
634 let writer = Arc::clone(&shared);
635 std::thread::spawn(move || {
636 if let Some(mut r) = pipe {
637 let mut buf = [0u8; 8192];
638 loop {
639 match r.read(&mut buf) {
640 Ok(0) => break,
641 Err(ref error) if error.kind() == std::io::ErrorKind::Interrupted => {}
642 Err(_) => {
643 writer
644 .lock()
645 .unwrap_or_else(std::sync::PoisonError::into_inner)
646 .read_failed = true;
647 break;
648 }
649 Ok(n) => {
650 let mut s = writer
651 .lock()
652 .unwrap_or_else(std::sync::PoisonError::into_inner);
653 if s.bytes.len() < cap {
654 let remaining = cap - s.bytes.len();
655 let take = remaining.min(n);
656 s.bytes.extend_from_slice(&buf[..take]);
657 if take < n {
658 s.truncated = true;
659 }
660 } else {
661 s.truncated = true;
662 }
663 }
664 }
665 }
666 }
667 let _ = done_tx.send(());
668 });
669 (shared, done_rx)
670}
671
672fn snapshot(buf: &Arc<Mutex<CaptureBuf>>) -> (Vec<u8>, bool, bool) {
675 let s = buf
676 .lock()
677 .unwrap_or_else(std::sync::PoisonError::into_inner);
678 (s.bytes.clone(), s.truncated, s.read_failed)
679}
680
681fn captured_len(buf: &Arc<Mutex<CaptureBuf>>) -> usize {
684 buf.lock()
685 .unwrap_or_else(std::sync::PoisonError::into_inner)
686 .bytes
687 .len()
688}
689
690fn streaming_max_lifetime() -> Duration {
693 Duration::from_millis(
694 std::env::var("LEAN_CTX_SHELL_BG_MAX_MS")
695 .ok()
696 .and_then(|v| v.parse().ok())
697 .filter(|&ms: &u64| ms > 0)
698 .unwrap_or(3_600_000),
699 )
700}
701
702#[cfg(unix)]
704struct LiveProc {
705 pid: u32,
706 ppid: u32,
707 args: String,
708}
709
710#[cfg(unix)]
726fn running_segments(pgid: u32, command: &str) -> Vec<String> {
727 let Ok(out) = std::process::Command::new("ps")
731 .args(["-A", "-o", "pid=,ppid=,pgid=,args="])
732 .output()
733 else {
734 return Vec::new();
735 };
736 let needle = command.split_whitespace().collect::<Vec<_>>().join(" ");
737 let live: Vec<LiveProc> = String::from_utf8_lossy(&out.stdout)
738 .lines()
739 .filter_map(|line| parse_ps_row(line, pgid, &needle))
740 .collect();
741
742 describe_live(&live)
743}
744
745#[cfg(unix)]
750fn describe_live(live: &[LiveProc]) -> Vec<String> {
751 let pids: std::collections::HashSet<u32> = live.iter().map(|p| p.pid).collect();
752 live.iter()
753 .filter(|p| !pids.contains(&p.ppid))
754 .map(|parent| {
755 let kids: Vec<&str> = live
756 .iter()
757 .filter(|c| c.ppid == parent.pid)
758 .map(|c| c.args.as_str())
759 .collect();
760 if kids.is_empty() {
761 parent.args.clone()
762 } else {
763 format!("{} (spawned: {})", parent.args, kids.join(", "))
764 }
765 })
766 .collect()
767}
768
769#[cfg(unix)]
773fn parse_ps_row(line: &str, pgid: u32, needle: &str) -> Option<LiveProc> {
774 let rest = line.trim_start();
775 let (pid, rest) = rest.split_once(char::is_whitespace)?;
776 let rest = rest.trim_start();
777 let (ppid, rest) = rest.split_once(char::is_whitespace)?;
778 let args = rest
779 .trim_start()
780 .split_once(char::is_whitespace)
781 .and_then(|(row_pgid, args)| {
782 (row_pgid.parse::<u32>().ok()? == pgid).then_some(args.trim())
783 })?;
784 if args.is_empty() || args.contains(needle) {
785 return None;
786 }
787 Some(LiveProc {
788 pid: pid.parse().ok()?,
789 ppid: ppid.parse().ok()?,
790 args: args.chars().take(200).collect(),
791 })
792}
793
794#[cfg(not(unix))]
795fn running_segments(_pgid: u32, _command: &str) -> Vec<String> {
796 Vec::new()
798}
799
800fn wait_for_reader(done: &mpsc::Receiver<()>, deadline: Instant) -> bool {
803 let remaining = deadline.saturating_duration_since(Instant::now());
804 done.recv_timeout(remaining).is_ok()
805}
806
807fn ensure_utf8_locale(
808 cmd: &mut std::process::Command,
809 extra_env: &std::collections::HashMap<String, String>,
810) {
811 if extra_env.contains_key("LC_ALL") || extra_env.contains_key("LC_CTYPE") {
812 return;
813 }
814 lean_ctx::shell::platform::apply_utf8_locale(cmd);
815}
816
817fn command_timeout(command: &str, timeout_ms: Option<u64>) -> Duration {
818 lean_ctx::shell::shell_timeout_with_override(command, timeout_ms)
822}
823
824#[cfg(all(test, target_os = "macos"))]
825#[path = "execute_supervised_tests.rs"]
826mod supervised_tests;
827
828#[cfg(all(test, unix))]
829#[path = "execute_containment_tests.rs"]
830mod containment_tests;
831
832#[cfg(test)]
833mod tests {
834 use super::{
835 command_timeout, ensure_utf8_locale, execute_command_in, is_build_or_test_command,
836 };
837
838 #[test]
839 fn resource_broker_build_slot_classifier_is_precise() {
840 for command in [
841 "cargo test --lib",
842 "cargo fast",
843 "cd rust && cargo fast --features dev-tools",
844 "make dev",
845 "make build",
846 "make preflight-fast",
847 "make install-release",
848 "./install.sh --build-only",
849 "bash rust/dev-install.sh --fast",
850 "./rust/dev-install.sh --release",
851 "lean-ctx dev-install",
852 "/opt/bin/lean-ctx dev-install --fast",
853 "cd rust && cargo clippy --all-features",
854 "/opt/bin/cargo build --release",
855 "npm test",
856 "npm run typecheck",
857 "pnpm build",
858 "rustc main.rs",
859 ] {
860 assert!(is_build_or_test_command(command), "{command}");
861 }
862 for command in [
863 "git status",
864 "cargo metadata",
865 "make help",
866 "lean-ctx --version",
867 "npm view lean-ctx",
868 "rg cargo",
869 ] {
870 assert!(!is_build_or_test_command(command), "{command}");
871 }
872 }
873
874 #[test]
875 fn resource_broker_serializes_build_leases() {
876 let isolation = lean_ctx::core::data_dir::isolated_data_dir();
877 assert!(
879 super::acquire_build_lease("cargo test", None)
880 .expect("lease")
881 .is_none()
882 );
883 std::fs::write(
884 isolation.path().join("config.toml"),
885 "[agents]\nserialize_build_commands = true\n",
886 )
887 .expect("opt in");
888 let first = super::acquire_build_lease("cargo test first", None)
889 .expect("first lease")
890 .expect("build command gets lease");
891 let acquired = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
892 let worker_acquired = std::sync::Arc::clone(&acquired);
893 let worker = std::thread::spawn(move || {
894 let _second = super::acquire_build_lease("cargo test second", None)
895 .expect("second lease")
896 .expect("build command gets lease");
897 worker_acquired.store(true, std::sync::atomic::Ordering::Release);
898 });
899 std::thread::sleep(std::time::Duration::from_millis(50));
900 assert!(!acquired.load(std::sync::atomic::Ordering::Acquire));
901 drop(first);
902 worker.join().expect("lease waiter");
903 assert!(acquired.load(std::sync::atomic::Ordering::Acquire));
904 }
905
906 #[test]
907 fn command_timeout_delegates_to_shell_timeout() {
908 let _lock = lean_ctx::core::data_dir::test_env_lock();
912 let saved = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
913 lean_ctx::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
914
915 assert!(
916 command_timeout("cargo install --path .", None) > command_timeout("git status", None)
917 );
918
919 lean_ctx::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
920 assert_eq!(
921 command_timeout("cargo install --path .", None),
922 std::time::Duration::from_secs(5)
923 );
924 assert_eq!(
925 command_timeout("git status", None),
926 std::time::Duration::from_secs(5)
927 );
928
929 lean_ctx::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
930 if let Some(v) = saved {
931 lean_ctx::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
932 }
933 }
934
935 #[cfg(target_os = "macos")]
940 #[test]
941 fn the_supervisor_runs_the_shell_under_the_pinned_child_rules() {
942 let _lock = lean_ctx::core::data_dir::test_env_lock();
943 let isolation = lean_ctx::core::data_dir::isolated_data_dir();
944 let fact = isolation.path().join("knowledge/knowledge.json");
945 std::fs::create_dir_all(fact.parent().unwrap()).unwrap();
946 std::fs::write(&fact, "synthetic-provider-fact").unwrap();
947 let work = tempfile::tempdir().unwrap();
948 let cwd = work.path().to_str().unwrap();
949 let read_fact = format!("cat {}", fact.display());
950 let write_fact = format!("printf downgraded > {}", fact.display());
951
952 lean_ctx::core::protected_store::unpin_synthetic_session();
954 let (text, code) = execute_command_in(&read_fact, cwd);
955 assert_eq!(code, 0, "{text}");
956 assert!(text.contains("synthetic-provider-fact"), "{text}");
957
958 let _session = lean_ctx::core::protected_store::pin_synthetic_session(isolation.path())
959 .expect("pinned rules");
960 lean_ctx::core::protected_execution::with_test_supervisor(|| {
961 let (text, code) = execute_command_in(&read_fact, cwd);
962 assert_ne!(code, 0, "{text}");
963 assert!(!text.contains("synthetic-provider-fact"), "{text}");
964 let (text, code) = execute_command_in(&write_fact, cwd);
965 assert_ne!(code, 0, "{text}");
966 let (text, code) =
968 execute_command_in("printf 'fn main() {}' > main.rs && cat main.rs", cwd);
969 assert_eq!(code, 0, "{text}");
970 assert!(text.contains("fn main()"), "{text}");
971 });
972 assert_eq!(
974 std::fs::read_to_string(&fact).unwrap(),
975 "synthetic-provider-fact"
976 );
977 std::fs::write(&fact, "engine-write").unwrap();
978
979 lean_ctx::test_env::remove_var(lean_ctx::core::protected_store::CHILD_PROFILE_ENV);
982 let (text, code) = execute_command_in("printf ran-unconfined", cwd);
983 assert_ne!(code, 0, "{text}");
984 assert!(text.starts_with("ERROR:"), "{text}");
985 assert!(!text.contains("ran-unconfined"), "{text}");
986 }
987
988 #[test]
992 #[cfg_attr(windows, ignore)]
993 fn idle_keyed_timeout_survives_a_trickling_loop() {
994 let _lock = lean_ctx::core::data_dir::test_env_lock();
995 lean_ctx::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
996 let env = std::collections::HashMap::new();
997 let cmd = "for i in 1 2 3 4 5 6 7 8 9; do printf T; sleep 0.1; done; printf DONE";
999
1000 let (idle_out, idle_code) = super::execute_command_with_env_cancellable(
1001 cmd,
1002 ".",
1003 &env,
1004 Some(400),
1005 None,
1006 true,
1007 None,
1008 );
1009 assert_eq!(
1010 idle_code, 0,
1011 "output kept resetting the idle clock: {idle_out}"
1012 );
1013 assert!(idle_out.contains("DONE"));
1014
1015 let (wall_out, wall_code) = super::execute_command_with_env_cancellable(
1016 cmd,
1017 ".",
1018 &env,
1019 Some(400),
1020 None,
1021 false,
1022 None,
1023 );
1024 assert_eq!(wall_code, 124, "wall-clock mode must still enforce the cap");
1025 assert!(wall_out.contains("timed out"));
1026 }
1027
1028 #[test]
1031 #[cfg_attr(windows, ignore)]
1032 fn timeout_names_the_still_running_segment() {
1033 let _lock = lean_ctx::core::data_dir::test_env_lock();
1034 lean_ctx::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
1035 let env = std::collections::HashMap::new();
1036 let (out, code) = super::execute_command_with_env_cancellable(
1037 "printf FIRST_OK; sleep 47",
1038 ".",
1039 &env,
1040 Some(600),
1041 None,
1042 false,
1043 None,
1044 );
1045 assert_eq!(code, 124);
1046 assert!(
1047 out.contains("FIRST_OK"),
1048 "partial output must survive: {out}"
1049 );
1050 assert!(
1051 out.contains("still running at timeout") && out.contains("47"),
1052 "the hung segment must be named: {out}"
1053 );
1054 }
1055
1056 #[test]
1057 #[cfg(unix)]
1058 fn ps_row_names_segments_and_drops_the_whole_command() {
1059 let whole = "printf hi; grep -rn needle /repo";
1061 assert_eq!(
1062 super::parse_ps_row(" 4242 4200 4200 grep -rn needle /repo", 4200, whole)
1063 .map(|p| p.args),
1064 Some("grep -rn needle /repo".to_string()),
1065 "a segment must be named"
1066 );
1067 assert!(
1069 super::parse_ps_row(
1070 "4200 1 4200 /bin/sh -c printf hi; grep -rn needle /repo",
1071 4200,
1072 whole
1073 )
1074 .is_none()
1075 );
1076 assert!(super::parse_ps_row("4242 4200 9999 sleep 5", 4200, whole).is_none());
1078 assert!(super::parse_ps_row("garbage", 4200, whole).is_none());
1079 }
1080
1081 #[test]
1088 #[cfg(unix)]
1089 fn a_spawned_child_is_labelled_not_presented_as_a_stage() {
1090 let live = vec![
1091 super::LiveProc {
1092 pid: 100,
1093 ppid: 1,
1094 args: "go run ./ --template x".into(),
1095 },
1096 super::LiveProc {
1097 pid: 101,
1098 ppid: 100,
1099 args: "/var/folders/T/go-build/b001/exe/evcc --template x".into(),
1100 },
1101 ];
1102 let out = super::describe_live(&live);
1103 assert_eq!(out.len(), 1, "the child is not a peer entry: {out:?}");
1104 assert!(out[0].starts_with("go run ./"), "{out:?}");
1105 assert!(
1106 out[0].contains("(spawned: /var/folders/T/go-build/b001/exe/evcc"),
1107 "the descendant is named as one: {out:?}"
1108 );
1109 }
1110
1111 #[test]
1115 #[cfg(unix)]
1116 fn genuine_pipeline_siblings_both_stay_top_level() {
1117 let live = vec![
1118 super::LiveProc {
1119 pid: 200,
1120 ppid: 50,
1121 args: "grep -rn needle /repo".into(),
1122 },
1123 super::LiveProc {
1124 pid: 201,
1125 ppid: 50,
1126 args: "head -30".into(),
1127 },
1128 ];
1129 let mut out = super::describe_live(&live);
1130 out.sort();
1131 assert_eq!(out, vec!["grep -rn needle /repo", "head -30"]);
1132 }
1133
1134 #[test]
1137 #[cfg(unix)]
1138 fn the_timeout_line_does_not_imply_a_pipeline() {
1139 let joined = ["a".to_string(), "b".to_string()].join("; ");
1140 assert!(!joined.contains('|'), "no pipe may be invented: {joined}");
1141 }
1142
1143 #[test]
1144 fn ensure_utf8_locale_sets_fallback_when_none_inherited() {
1145 let _env_lock = lean_ctx::core::data_dir::test_env_lock();
1146 let empty: std::collections::HashMap<String, String> = std::collections::HashMap::new();
1147 let mut cmd = std::process::Command::new("true");
1148
1149 let saved = (
1151 std::env::var("LC_ALL").ok(),
1152 std::env::var("LC_CTYPE").ok(),
1153 std::env::var("LANG").ok(),
1154 );
1155 lean_ctx::test_env::remove_var("LC_ALL");
1156 lean_ctx::test_env::remove_var("LC_CTYPE");
1157 lean_ctx::test_env::remove_var("LANG");
1158
1159 ensure_utf8_locale(&mut cmd, &empty);
1160
1161 if let Some(v) = saved.0 {
1163 lean_ctx::test_env::set_var("LC_ALL", v);
1164 }
1165 if let Some(v) = saved.1 {
1166 lean_ctx::test_env::set_var("LC_CTYPE", v);
1167 }
1168 if let Some(v) = saved.2 {
1169 lean_ctx::test_env::set_var("LANG", v);
1170 }
1171
1172 }
1175
1176 #[test]
1177 fn ensure_utf8_locale_skips_when_extra_env_has_lc_all() {
1178 let mut extra = std::collections::HashMap::new();
1179 extra.insert("LC_ALL".to_string(), "C".to_string());
1180 let mut cmd = std::process::Command::new("true");
1181 ensure_utf8_locale(&mut cmd, &extra);
1182 }
1184
1185 #[test]
1186 #[cfg_attr(windows, ignore)]
1187 fn utf8_bytes_survive_shell_roundtrip() {
1188 let _lock = lean_ctx::core::data_dir::test_env_lock();
1189 let (output, code) = execute_command_in(
1190 "printf '\\xD0\\x9F\\xD1\\x80\\xD0\\xB8\\xD0\\xB2\\xD0\\xB5\\xD1\\x82'",
1191 ".",
1192 );
1193 assert_eq!(code, 0, "printf failed: {output}");
1194 assert_eq!(output, "Привет", "Cyrillic bytes must survive roundtrip");
1195 }
1196
1197 #[test]
1198 #[cfg_attr(windows, ignore)] fn execute_command_closes_stdin() {
1200 let _lock = lean_ctx::core::data_dir::test_env_lock();
1201 let command = "sh -c 'if read -t 1 line; then echo 67890; else echo 12345; fi'";
1202 let (output, code) = execute_command_in(command, ".");
1203 assert_eq!(code, 0, "command failed: {output}");
1204 assert!(
1205 output.contains("12345"),
1206 "child process should receive EOF on stdin, got: {output}"
1207 );
1208 }
1209
1210 #[test]
1213 #[cfg(unix)]
1214 fn execute_command_reports_signal_termination_as_128_plus_signal() {
1215 let (output, code) = execute_command_in("echo before-kill; kill -TERM $$", ".");
1216 assert_eq!(code, 143, "SIGTERM must map to 143, got {code}: {output}");
1217 assert!(output.contains("before-kill"), "output kept: {output}");
1218
1219 let (_, code) = execute_command_in("kill -KILL $$", ".");
1220 assert_eq!(code, 137, "SIGKILL must map to 137");
1221 }
1222
1223 #[test]
1229 #[cfg_attr(windows, ignore)] fn background_pipe_holder_keeps_foreground_output() {
1231 let _lock = lean_ctx::core::data_dir::test_env_lock();
1232 let (output, code) = execute_command_in("echo REPRO_CANARY_945; sleep 4 &", ".");
1233 assert_eq!(code, 0, "command should succeed: {output:?}");
1234 assert!(
1235 output.contains("REPRO_CANARY_945"),
1236 "foreground stdout must survive a lingering background pipe holder, got: {output:?}"
1237 );
1238 assert!(
1239 output.contains("output reader still draining"),
1240 "an incomplete reader must be flagged, got: {output:?}"
1241 );
1242 }
1243
1244 #[test]
1248 #[cfg(unix)]
1249 fn command_has_no_controlling_terminal() {
1250 let (output, code) = execute_command_in(
1251 "if (exec 3</dev/tty) 2>/dev/null; then echo HAS_TTY; else echo NO_TTY; fi",
1252 ".",
1253 );
1254 assert_eq!(code, 0, "{output:?}");
1255 assert!(output.contains("NO_TTY"), "got: {output:?}");
1256 }
1257
1258 #[test]
1259 #[cfg_attr(windows, ignore)]
1260 fn forwards_captured_agent_runtime_env() {
1261 let _iso = lean_ctx::core::data_dir::isolated_data_dir();
1266
1267 lean_ctx::test_env::remove_var("CODEX_THREAD_ID");
1269 lean_ctx::test_env::set_var("CODEX_THREAD_ID", "thread-from-hook");
1270 lean_ctx::core::agent_runtime_env::capture();
1271 lean_ctx::test_env::remove_var("CODEX_THREAD_ID");
1273
1274 let (output, code) = execute_command_in("printf 'TID=%s' \"$CODEX_THREAD_ID\"", ".");
1275
1276 assert_eq!(code, 0, "command failed: {output}");
1277 assert!(
1278 output.contains("TID=thread-from-hook"),
1279 "captured agent runtime var must be forwarded, got: {output}"
1280 );
1281 }
1282
1283 #[test]
1286 #[cfg_attr(windows, ignore)] fn per_call_timeout_kills_long_command() {
1288 let _lock = lean_ctx::core::data_dir::test_env_lock();
1289 let (output, code) = super::execute_command_with_env(
1290 "sleep 3",
1291 ".",
1292 &std::collections::HashMap::new(),
1293 Some(200),
1294 );
1295 assert_eq!(code, 124, "timed-out command must exit 124: {output}");
1296 assert!(
1297 output.contains("timed out after 200ms"),
1298 "timeout message must carry the per-call budget, got: {output}"
1299 );
1300 }
1301
1302 #[test]
1305 #[cfg_attr(windows, ignore)] fn per_call_timeout_preserves_partial_output() {
1307 let _lock = lean_ctx::core::data_dir::test_env_lock();
1308 let (output, code) = super::execute_command_with_env(
1309 "printf TIMEOUT_PARTIAL_995; sleep 3",
1310 ".",
1311 &std::collections::HashMap::new(),
1312 Some(200),
1313 );
1314 assert_eq!(code, 124, "timed-out command must exit 124: {output}");
1315 assert!(
1316 output.contains("TIMEOUT_PARTIAL_995"),
1317 "stdout emitted before timeout must be preserved: {output:?}"
1318 );
1319 assert!(
1320 output.contains("timed out after 200ms"),
1321 "timeout notice must remain explicit: {output:?}"
1322 );
1323 }
1324
1325 #[test]
1326 fn git_version_returns_when_git_is_available() {
1327 let _lock = lean_ctx::core::data_dir::test_env_lock();
1328 let git_available = std::process::Command::new("git")
1329 .arg("--version")
1330 .stdout(std::process::Stdio::null())
1331 .stderr(std::process::Stdio::null())
1332 .status()
1333 .is_ok();
1334 if !git_available {
1335 return;
1336 }
1337
1338 let (output, code) = execute_command_in("git --version", ".");
1339 assert_eq!(code, 0, "git command failed: {output}");
1340 assert!(
1341 output.to_ascii_lowercase().contains("git version"),
1342 "unexpected git output: {output}"
1343 );
1344 }
1345}