1use crate::utils::executable::{shell_kind_of, ShellKind};
11use anyhow::{anyhow, Context, Result};
12use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
13use std::collections::hash_map::DefaultHasher;
14use std::hash::{Hash, Hasher};
15use std::io::{Read, Write};
16use std::path::{Path, PathBuf};
17use std::process::Command;
18use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
19use std::sync::mpsc::{self, TryRecvError};
20use std::sync::{Arc, Mutex as StdMutex};
21use std::thread;
22use std::time::{Duration, Instant};
23use tokio::sync::Mutex;
24use tracing::{debug, info, warn};
25
26use crate::state::line_ring::LineRing;
27use crate::state::live_terminal::{LiveTerminal, ScreenUpdate};
28
29pub const DEFAULT_COLS: u16 = 200;
31pub const DEFAULT_ROWS: u16 = 50;
32
33const MAX_OUTPUT_SIZE: usize = 1_000_000;
35
36const SCRATCH_MAX_BYTES: u64 = 50 * 1024 * 1024;
40
41const DISCARD_READ_THROTTLE: Duration = Duration::from_millis(2);
50
51pub const RING_BUFFER_LINES: usize = 2_000;
55
56const MAX_PARTIAL_LINE_BYTES: usize = 64 * 1024;
60
61const WCGW_PROMPT_PATTERN: &str = "◉";
63const WCGW_PROMPT_END: &str = "──➤";
64const PROMPT_BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(5);
68
69fn attachable_command(restricted_mode: bool) -> (CommandBuilder, Option<String>, ShellKind) {
70 let requested = crate::config::env_text("WINX_ATTACH_TERMINAL")
71 .or_else(|| crate::config::env_text("WINX_USE_SCREEN"))
72 .unwrap_or_default();
73 if requested == "tmux"
74 || requested == "screen"
75 || crate::config::parse_bool(&requested) == Some(true)
76 {
77 let session = format!("winx-{}-{}", std::process::id(), timestamp_millis());
78 if requested == "tmux" && command_available("tmux") {
79 let mut cmd = CommandBuilder::new("tmux");
80 cmd.args(["new-session", "-A", "-s", &session, "bash"]);
81 if restricted_mode {
82 cmd.arg("-r");
83 }
84 return (cmd, Some(format!("tmux attach -t {session}")), ShellKind::Bash);
85 }
86 if command_available("screen") {
87 ensure_screenrc();
90 cleanup_orphaned_screens();
91 let mut cmd = CommandBuilder::new("screen");
92 cmd.args(["-q", "-S", &session, "bash"]);
93 if restricted_mode {
94 cmd.arg("-r");
95 }
96 return (cmd, Some(format!("screen -x {session}")), ShellKind::Bash);
97 }
98 }
99
100 let shell = preferred_shell(restricted_mode);
101 let kind = shell_kind_of(&shell);
103 let mut cmd = CommandBuilder::new(&shell);
104 match kind {
105 ShellKind::Bash if restricted_mode => {
107 cmd.arg("-r");
108 }
109 ShellKind::PowerShell => {
112 cmd.arg("-NoLogo");
113 }
114 ShellKind::Bash | ShellKind::Zsh => {}
115 }
116 if let Some(extra) = crate::config::env_text("WINX_SHELL_ARGS") {
119 for arg in extra.split_whitespace() {
120 cmd.arg(arg);
121 }
122 }
123 (cmd, None, kind)
124}
125
126pub fn configured_shell_kind(restricted_mode: bool) -> ShellKind {
130 shell_kind_of(&preferred_shell(restricted_mode))
131}
132
133#[cfg(windows)]
142fn ensure_windows_shell_available(restricted_mode: bool) -> Result<()> {
143 if !restricted_mode {
145 if let Ok(requested) = std::env::var("WINX_SHELL") {
146 let path = Path::new(&requested);
147 if path.is_absolute() && path.is_file() {
148 return Ok(());
149 }
150 }
151 }
152 if crate::utils::executable::bash_executable().is_some() {
153 return Ok(());
154 }
155 if crate::utils::executable::powershell_executable().is_some() {
156 if restricted_mode {
157 return Err(anyhow!(
158 "restricted mode needs bash -r; install Git for Windows or set WINX_SHELL to an \
159 absolute bash.exe path (PowerShell has no equivalent restricted mode)"
160 ));
161 }
162 return Ok(());
163 }
164 Err(anyhow!(
165 "no usable shell found: install Git for Windows (the System32 WSL launcher is not \
166 used) or PowerShell 7, or set WINX_SHELL to an absolute bash.exe or pwsh.exe path"
167 ))
168}
169
170fn preferred_shell(restricted_mode: bool) -> String {
171 if !restricted_mode {
172 if let Ok(requested) = std::env::var("WINX_SHELL") {
173 if requested == "zsh" && command_available("zsh") {
174 return "zsh".to_string();
175 }
176 if matches!(requested.as_str(), "pwsh" | "powershell") {
179 if let Some(found) = crate::utils::executable::powershell_executable() {
180 return found.to_string_lossy().into_owned();
181 }
182 }
183 if Path::new(&requested).is_absolute() && Path::new(&requested).is_file() {
186 return requested;
187 }
188 }
189 }
190 if cfg!(windows) {
191 if let Some(bash) = crate::utils::executable::bash_executable() {
192 return bash.to_string_lossy().into_owned();
193 }
194 if !restricted_mode {
197 if let Some(found) = crate::utils::executable::powershell_executable() {
198 return found.to_string_lossy().into_owned();
199 }
200 }
201 }
202 "bash".to_string()
203}
204
205fn powershell_bootstrap(nonce: &str) -> String {
217 format!(
218 "Remove-Module PSReadLine -ErrorAction SilentlyContinue; \
219 if (Get-Variable PSStyle -ErrorAction SilentlyContinue) {{ $PSStyle.OutputRendering = 'PlainText' }}; \
220 $env:PAGER = 'cat'; $env:GIT_PAGER = 'cat'; $global:LASTEXITCODE = 0; \
221 function global:prompt {{ $ok = $?; $ec = $global:LASTEXITCODE; \
222 $code = if ($ec -is [int] -and $ec -ne 0) {{ $ec }} elseif ($ok) {{ 0 }} else {{ 1 }}; \
223 $global:LASTEXITCODE = 0; \
224 \"\u{25c9} $($ExecutionContext.SessionState.Path.CurrentLocation.ProviderPath)\u{2500}\u{2500}\u{27a4}{nonce}:$code \" }}"
225 )
226}
227
228fn command_available(command: &str) -> bool {
229 crate::utils::executable::is_available(command)
230}
231
232fn ensure_screenrc() {
235 let Some(home) = home::home_dir() else {
236 return;
237 };
238 let screenrc = home.join(".screenrc");
239 if screenrc.exists() {
240 return;
241 }
242 let _ = std::fs::write(
243 &screenrc,
244 "defscrollback 10000\ntermcapinfo xterm* ti@:te@\nstartup_message off\n",
245 );
246}
247
248fn cleanup_orphaned_screens() {
254 let Ok(output) = Command::new("screen").arg("-ls").output() else {
255 return;
256 };
257 let listing = String::from_utf8_lossy(&output.stdout);
259 for line in listing.lines() {
260 let Some(session) = line.split_whitespace().next() else {
261 continue;
262 };
263 let Some((_, name)) = session.split_once('.') else {
265 continue;
266 };
267 if let Some(creator_pid) = winx_creator_pid(name) {
268 if !process_exists(creator_pid) {
269 let _ = Command::new("screen").args(["-S", session, "-X", "quit"]).output();
270 }
271 }
272 }
273}
274
275fn winx_creator_pid(name: &str) -> Option<u32> {
277 name.strip_prefix("winx-")?.split('-').next()?.parse::<u32>().ok()
278}
279
280#[cfg(unix)]
283fn process_exists(pid: u32) -> bool {
284 crate::os::unix::process_exists(pid)
285}
286
287#[cfg(not(unix))]
288fn process_exists(pid: u32) -> bool {
289 crate::os::process_exists(pid)
290}
291
292pub(crate) fn native_cwd_from_prompt(cwd: &str) -> PathBuf {
297 if cfg!(windows) {
298 if let Some(native) = msys_to_native(cwd) {
299 return native;
300 }
301 }
302 PathBuf::from(cwd)
303}
304
305fn msys_to_native(cwd: &str) -> Option<PathBuf> {
306 let rest = cwd.strip_prefix("/cygdrive").unwrap_or(cwd);
307 let mut parts = rest.strip_prefix('/')?.splitn(2, '/');
308 let drive = parts.next()?;
309 if drive.len() != 1 || !drive.chars().all(|character| character.is_ascii_alphabetic()) {
310 return None;
311 }
312 let tail = parts.next().unwrap_or("");
313 let mut native = format!("{}:\\", drive.to_ascii_uppercase());
314 native.push_str(&tail.replace('/', "\\"));
315 Some(PathBuf::from(native))
316}
317
318fn timestamp_millis() -> u128 {
319 std::time::SystemTime::now()
320 .duration_since(std::time::UNIX_EPOCH)
321 .map_or(0, |duration| duration.as_millis())
322}
323
324fn spawn_pty_reader(
327 mut reader: Box<dyn Read + Send>,
328 output_tx: mpsc::SyncSender<String>,
329 live: Arc<StdMutex<LiveTerminal>>,
330 discard_throttle: Arc<AtomicBool>,
331) {
332 thread::spawn(move || {
333 let mut buf = [0u8; 4096];
334 let mut carry: Vec<u8> = Vec::new();
339 loop {
340 match reader.read(&mut buf) {
341 Ok(0) => {
342 debug!("PTY reader reached EOF");
343 if !carry.is_empty() {
345 let _ = output_tx.send(String::from_utf8_lossy(&carry).into_owned());
346 }
347 break;
348 }
349 Ok(n) => {
350 debug!(bytes = n, "PTY reader chunk");
351 {
357 let mut emu =
358 live.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
359 emu.feed(&buf[..n]);
360 }
361 carry.extend_from_slice(&buf[..n]);
364 let (chunk, rest) = decode_keep_incomplete(&carry);
365 carry = rest;
366 if !chunk.is_empty() && output_tx.send(chunk).is_err() {
367 break;
369 }
370 if discard_throttle.load(AtomicOrdering::Relaxed) {
375 thread::sleep(DISCARD_READ_THROTTLE);
376 }
377 }
378 Err(e) => {
379 debug!("PTY reader thread error: {}", e);
380 break;
381 }
382 }
383 }
384 debug!("PTY reader thread exiting");
385 });
386}
387
388pub struct PtyShell {
393 master: Box<dyn MasterPty + Send>,
395 child: Box<dyn Child + Send + Sync>,
397 writer: Box<dyn Write + Send>,
399 output_rx: mpsc::Receiver<String>,
401 size: PtySize,
403 pub last_command: String,
405 pub output_buffer: String,
407 pub command_running: bool,
409 current_cwd: PathBuf,
411 command_started_at: Option<Instant>,
414 command_generation: u64,
417 incarnation: u64,
420 pub last_exit_code: Option<i32>,
424 max_output_size: usize,
426 pub output_truncated: bool,
428 ring: LineRing,
432 pub last_returned_hash: Option<u64>,
435 delivered_output: String,
438 pending_completion_report: bool,
444 pub attach_hint: Option<String>,
446 prompt_end_marker: String,
452 live: Arc<StdMutex<LiveTerminal>>,
460 scratch_workspace_root: Option<PathBuf>,
465 scratch_path: Option<PathBuf>,
468 scratch_bytes: u64,
470 discard_throttle: Arc<AtomicBool>,
475 #[cfg(windows)]
479 job: Option<crate::os::windows::ProcessTreeJob>,
480}
481
482impl std::fmt::Debug for PtyShell {
483 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
484 f.debug_struct("PtyShell")
485 .field("size", &format!("{}x{}", self.size.cols, self.size.rows))
486 .field("last_command", &self.last_command)
487 .field("command_running", &self.command_running)
488 .field("current_cwd", &self.current_cwd)
489 .field("output_truncated", &self.output_truncated)
490 .field("output_buffer_len", &self.output_buffer.len())
491 .field("attach_hint", &self.attach_hint)
492 .finish_non_exhaustive()
493 }
494}
495
496#[cfg(unix)]
507fn killable_group(pid: u32) -> Option<i32> {
508 crate::os::unix::owned_process_group(pid)
509}
510
511#[cfg(unix)]
514fn signal_group(pgid: i32, signal: i32) {
515 let _ = crate::os::unix::signal_group(pgid, signal);
516}
517
518impl Drop for PtyShell {
519 fn drop(&mut self) {
533 #[cfg(unix)]
539 let pgid = if self.attach_hint.is_some() {
540 None
541 } else {
542 self.child.process_id().and_then(killable_group)
543 };
544
545 #[cfg(unix)]
546 if let Some(pgid) = pgid {
547 signal_group(pgid, libc::SIGTERM);
548 }
549
550 #[cfg(windows)]
552 if let Some(job) = self.job.as_ref() {
553 job.terminate();
554 }
555
556 let _ = self.child.kill();
558
559 #[cfg(unix)]
560 if let Some(pgid) = pgid {
561 signal_group(pgid, libc::SIGKILL);
562 }
563
564 let _ = self.child.wait();
565 }
566}
567
568impl PtyShell {
569 pub fn new(initial_dir: &Path, restricted_mode: bool) -> Result<Self> {
578 Self::new_with_agent_paths(initial_dir, restricted_mode, None, None)
579 }
580
581 pub(crate) fn new_with_agent_paths(
584 initial_dir: &Path,
585 restricted_mode: bool,
586 workspace_root: Option<&Path>,
587 temporary_artifact_dir: Option<&Path>,
588 ) -> Result<Self> {
589 info!(
590 "Creating new PTY shell (restricted: {}) in {}",
591 restricted_mode,
592 initial_dir.display()
593 );
594
595 #[cfg(windows)]
599 ensure_windows_shell_available(restricted_mode)?;
600
601 let pty_system = native_pty_system();
603
604 let size =
606 PtySize { rows: DEFAULT_ROWS, cols: DEFAULT_COLS, pixel_width: 0, pixel_height: 0 };
607
608 let pair = pty_system.openpty(size).context("Failed to open PTY pair")?;
610
611 let (mut cmd, attach_hint, shell_kind) = attachable_command(restricted_mode);
613 if shell_kind.is_powershell() && restricted_mode {
614 return Err(anyhow!(
615 "restricted mode needs bash -r; PowerShell has no equivalent restricted mode"
616 ));
617 }
618
619 cmd.env("TERM", "xterm-256color");
621 cmd.env("COLORTERM", "truecolor");
622 cmd.env("PAGER", "cat");
623 cmd.env("GIT_PAGER", "cat");
624 cmd.env("HISTFILE", "");
629 cmd.env("COLUMNS", DEFAULT_COLS.to_string());
630 cmd.env("ROWS", DEFAULT_ROWS.to_string());
631 if let Some(workspace_root) = workspace_root {
632 cmd.env("WINX_WORKSPACE_ROOT", workspace_root);
633 }
634 if let Some(temporary_artifact_dir) = temporary_artifact_dir {
635 cmd.env("WINX_TEMP_DIR", temporary_artifact_dir);
636 }
637 let nonce = format!("{:016x}", rand::random::<u64>());
640 cmd.env(
645 "PROMPT_COMMAND",
646 format!(r#"__winx_ec=$?; printf "◉ %s──➤{nonce}:%s " "$PWD" "$__winx_ec""#),
647 );
648 cmd.cwd(initial_dir);
649
650 let child = pair.slave.spawn_command(cmd).context("Failed to spawn bash in PTY")?;
652 #[cfg(windows)]
653 let job = match crate::os::windows::ProcessTreeJob::new()
654 .and_then(|job| child.process_id().map_or(Ok(()), |pid| job.assign(pid)).map(|()| job))
655 {
656 Ok(job) => Some(job),
657 Err(error) => {
658 warn!(%error, "PTY child is not in a job object; background children may outlive the shell");
659 None
660 }
661 };
662
663 let reader = pair.master.try_clone_reader().context("Failed to clone PTY reader")?;
665 let writer = pair.master.take_writer().context("Failed to take PTY writer")?;
666
667 let (output_tx, output_rx) = mpsc::sync_channel::<String>(1024);
671
672 let live = Arc::new(StdMutex::new(LiveTerminal::new(DEFAULT_ROWS, DEFAULT_COLS)));
675 let live_reader = Arc::clone(&live);
676 let discard_throttle = Arc::new(AtomicBool::new(false));
677 let throttle_reader = Arc::clone(&discard_throttle);
678
679 spawn_pty_reader(reader, output_tx, live_reader, throttle_reader);
680
681 let mut shell = Self {
683 master: pair.master,
684 child,
685 writer,
686 output_rx,
687 size,
688 last_command: String::new(),
689 output_buffer: String::new(),
690 command_running: false,
691 current_cwd: initial_dir.to_path_buf(),
692 command_started_at: None,
693 command_generation: 0,
694 incarnation: rand::random::<u64>().max(1),
695 last_exit_code: None,
696 max_output_size: MAX_OUTPUT_SIZE,
697 output_truncated: false,
698 ring: LineRing::new(RING_BUFFER_LINES, MAX_PARTIAL_LINE_BYTES),
699 last_returned_hash: None,
700 delivered_output: String::new(),
701 pending_completion_report: false,
702 attach_hint,
703 prompt_end_marker: format!("{WCGW_PROMPT_END}{nonce}"),
704 live,
705 scratch_workspace_root: None,
706 scratch_path: None,
707 scratch_bytes: 0,
708 discard_throttle,
709 #[cfg(windows)]
710 job,
711 };
712
713 shell.initialize_prompt(shell_kind, &nonce)?;
715
716 debug!("PTY shell created successfully");
717 Ok(shell)
718 }
719
720 fn initialize_prompt(&mut self, shell_kind: ShellKind, nonce: &str) -> Result<()> {
722 let prompt_statement = match shell_kind {
731 ShellKind::Zsh => format!(
735 r#"export GIT_PAGER=cat PAGER=cat; precmd_functions=(); preexec_functions=(); PROMPT=''; RPROMPT=''; precmd() {{ local __winx_ec=$?; printf "◉ %s──➤{nonce}:%s " "$PWD" "$__winx_ec" }}"#
736 ),
737 ShellKind::Bash => format!(
738 r#"export GIT_PAGER=cat PAGER=cat PROMPT_COMMAND='__winx_ec=$?; printf "◉ %s──➤{nonce}:%s " "$PWD" "$__winx_ec"'; PS1=''"#
739 ),
740 ShellKind::PowerShell => powershell_bootstrap(nonce),
741 };
742
743 if shell_kind.is_powershell() {
744 self.wait_for_initial_powershell_prompt();
750 }
751 self.write_command(&prompt_statement)?;
752
753 let deadline = Instant::now() + PROMPT_BOOTSTRAP_TIMEOUT;
760 let mut bootstrap = String::new();
761 let mut settled = false;
762 while Instant::now() < deadline {
763 match self.output_rx.recv_timeout(Duration::from_millis(50)) {
764 Ok(chunk) => {
765 self.answer_terminal_queries(&chunk);
766 bootstrap.push_str(&chunk);
767 if Self::check_prompt_complete(&bootstrap, &self.prompt_end_marker) {
768 settled = true;
769 break;
770 }
771 }
772 Err(mpsc::RecvTimeoutError::Timeout) => {}
773 Err(mpsc::RecvTimeoutError::Disconnected) => break,
774 }
775 }
776 if settled {
777 std::thread::sleep(Duration::from_millis(30));
778 } else {
779 warn!(
780 timeout_ms = PROMPT_BOOTSTRAP_TIMEOUT.as_millis(),
781 "PTY shell did not print its first prompt in time; continuing anyway"
782 );
783 }
784 bootstrap.push_str(&self.drain_output());
785 debug!(
786 bytes = bootstrap.len(),
787 settled,
788 child_pid = ?self.child.process_id(),
789 "PTY prompt bootstrap drained"
790 );
791
792 Ok(())
793 }
794
795 fn wait_for_initial_powershell_prompt(&mut self) {
798 let deadline = Instant::now() + PROMPT_BOOTSTRAP_TIMEOUT;
799 let mut seen = String::new();
800 while Instant::now() < deadline {
801 match self.output_rx.recv_timeout(Duration::from_millis(50)) {
802 Ok(chunk) => {
803 self.answer_terminal_queries(&chunk);
804 seen.push_str(&chunk);
805 let clean = crate::state::terminal::strip_ansi_codes(&seen);
806 if clean.contains("> ") || clean.contains(">\u{a0}") {
807 std::thread::sleep(Duration::from_millis(100));
809 return;
810 }
811 }
812 Err(mpsc::RecvTimeoutError::Timeout) => {}
813 Err(mpsc::RecvTimeoutError::Disconnected) => return,
814 }
815 }
816 warn!(
817 timeout_ms = PROMPT_BOOTSTRAP_TIMEOUT.as_millis(),
818 "PowerShell did not draw its initial prompt in time; sending the bootstrap anyway"
819 );
820 }
821
822 fn answer_terminal_queries(&mut self, chunk: &str) {
831 const CURSOR_POSITION_REQUEST: &str = "\x1b[6n";
832 if !chunk.contains(CURSOR_POSITION_REQUEST) {
833 return;
834 }
835 debug!("PTY answered a cursor position request");
836 if let Err(error) = self.writer.write_all(b"\x1b[1;1R").and_then(|()| self.writer.flush()) {
837 debug!("PTY cursor position report failed: {error}");
838 }
839 }
840
841 fn write_command(&mut self, command: &str) -> Result<()> {
843 let cmd_with_newline = format!("{command}\r");
849 self.writer.write_all(cmd_with_newline.as_bytes()).context("Failed to write to PTY")?;
850 self.writer.flush().context("Failed to flush PTY")?;
851 Ok(())
852 }
853
854 fn drain_output(&mut self) -> String {
856 let mut output = String::new();
857 let deadline = Instant::now() + Duration::from_millis(200);
858
859 while Instant::now() < deadline {
861 match self.output_rx.try_recv() {
862 Ok(chunk) => {
863 self.answer_terminal_queries(&chunk);
864 output.push_str(&chunk);
865
866 if output.len() > self.max_output_size {
868 self.output_truncated = true;
869 break;
870 }
871 }
872 Err(TryRecvError::Empty) => {
873 thread::sleep(Duration::from_millis(10));
875 }
876 Err(TryRecvError::Disconnected) => {
877 break;
879 }
880 }
881 }
882
883 output
884 }
885
886 pub fn send_command(&mut self, command: &str) -> Result<()> {
888 debug!(bytes = command.len(), "PTY sending command");
889
890 self.output_buffer.clear();
892 self.output_truncated = false;
893 self.reset_scratch();
894 self.last_command = command.to_string();
895 self.command_running = true;
896 self.mark_command_started();
897 self.last_exit_code = None;
898 self.last_returned_hash = None;
901 self.delivered_output.clear();
902 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clear_osc();
905
906 self.write_command(command)?;
908
909 Ok(())
910 }
911
912 pub fn mark_command_started(&mut self) {
914 self.command_generation = self.command_generation.saturating_add(1);
915 self.command_started_at = Some(Instant::now());
916 self.pending_completion_report = true;
917 }
918
919 pub fn completion_report_pending(&self) -> bool {
922 !self.command_running && self.pending_completion_report
923 }
924
925 pub fn mark_completion_reported(&mut self) {
927 self.pending_completion_report = false;
928 }
929
930 pub fn command_generation(&self) -> u64 {
931 self.command_generation
932 }
933
934 pub fn incarnation(&self) -> u64 {
935 self.incarnation
936 }
937
938 pub fn command_elapsed(&self) -> Option<Duration> {
940 self.command_started_at.map(|started_at| started_at.elapsed())
941 }
942
943 pub fn current_cwd(&self) -> &Path {
945 &self.current_cwd
946 }
947
948 pub fn delivered_output(&self) -> String {
950 self.delivered_output.clone()
951 }
952
953 pub fn mark_output_delivered(&mut self, output: &str) {
955 self.delivered_output.clear();
956 self.delivered_output.push_str(output);
957 }
958
959 pub fn collect_scrollback(&self, lines: usize) -> String {
964 let rendered =
970 crate::state::terminal::render_terminal_output(&self.ring.raw(lines)).join("\n");
971 match self.ring.scrollback_notice(lines) {
972 Some(notice) => format!("{notice}\n{rendered}"),
973 None => rendered,
974 }
975 }
976
977 pub fn fingerprint(text: &str) -> u64 {
979 let mut hasher = DefaultHasher::new();
980 text.hash(&mut hasher);
981 hasher.finish()
982 }
983
984 pub fn read_output(&mut self, timeout_secs: f32) -> Result<(String, bool)> {
989 let timeout = Duration::from_secs_f32(timeout_secs.clamp(0.1, 60.0));
990 let start = Instant::now();
991 let mut complete = false;
992 let mut no_data_count = 0;
993 let mut prompt_detected_at: Option<Instant> = None;
994
995 while start.elapsed() < timeout {
996 match self.output_rx.try_recv() {
997 Ok(chunk) => {
998 self.answer_terminal_queries(&chunk);
999 self.output_buffer.push_str(&chunk);
1000 self.ring.push_chunk(&chunk);
1001 no_data_count = 0;
1002
1003 if prompt_detected_at.is_none()
1005 && (Self::check_prompt_complete(&chunk, &self.prompt_end_marker)
1006 || Self::check_prompt_complete(
1007 &self.output_buffer,
1008 &self.prompt_end_marker,
1009 ))
1010 {
1011 prompt_detected_at = Some(Instant::now());
1012 debug!("Prompt detected, draining remaining output...");
1013 }
1014
1015 self.truncate_output_buffer_with_offload();
1017 }
1018 Err(TryRecvError::Empty) => {
1019 thread::sleep(Duration::from_millis(10));
1021 no_data_count += 1;
1022
1023 if let Some(detected_time) = prompt_detected_at {
1025 if detected_time.elapsed() > Duration::from_millis(100) {
1027 complete = true;
1028 debug!("Command completed - prompt detected and drained");
1029 break;
1030 }
1031 } else if no_data_count > 10
1032 && Self::check_prompt_complete(&self.output_buffer, &self.prompt_end_marker)
1033 {
1034 prompt_detected_at = Some(Instant::now());
1036 debug!("Prompt detected after wait, draining...");
1037 }
1038 }
1039 Err(TryRecvError::Disconnected) => {
1040 warn!("PTY reader disconnected");
1042 complete = true;
1043 break;
1044 }
1045 }
1046 }
1047
1048 if complete || prompt_detected_at.is_some() {
1049 self.command_running = false;
1050 self.last_exit_code =
1051 Self::parse_prompt_exit_code(&self.output_buffer, &self.prompt_end_marker);
1052 self.update_current_cwd_from_prompt();
1053 complete = true;
1054 }
1055
1056 Ok((self.output_buffer.clone(), complete))
1057 }
1058
1059 pub fn poll_output_nonblocking(&mut self) -> bool {
1067 let mut prompt_seen = false;
1068 loop {
1069 match self.output_rx.try_recv() {
1070 Ok(chunk) => {
1071 self.answer_terminal_queries(&chunk);
1072 let chunk_has_prompt =
1073 Self::check_prompt_complete(&chunk, &self.prompt_end_marker);
1074 self.output_buffer.push_str(&chunk);
1075 self.ring.push_chunk(&chunk);
1076 if chunk_has_prompt
1077 || Self::check_prompt_complete(&self.output_buffer, &self.prompt_end_marker)
1078 {
1079 prompt_seen = true;
1080 }
1081 self.truncate_output_buffer_with_offload();
1082 }
1083 Err(TryRecvError::Empty) => break,
1084 Err(TryRecvError::Disconnected) => {
1085 self.command_running = false;
1086 return true;
1087 }
1088 }
1089 }
1090 if prompt_seen {
1091 self.command_running = false;
1092 self.last_exit_code =
1093 Self::parse_prompt_exit_code(&self.output_buffer, &self.prompt_end_marker);
1094 self.update_current_cwd_from_prompt();
1095 }
1096 !self.command_running
1101 }
1102
1103 fn update_current_cwd_from_prompt(&mut self) {
1104 let Some(last) =
1105 self.output_buffer.lines().rev().find(|line| line.contains(&self.prompt_end_marker))
1106 else {
1107 return;
1108 };
1109 let clean = crate::state::terminal::strip_ansi_codes(last);
1110 let Some((before_marker, _)) = clean.rsplit_once(&self.prompt_end_marker) else {
1111 return;
1112 };
1113 let Some((_, cwd)) = before_marker.rsplit_once("◉ ") else {
1114 return;
1115 };
1116 let cwd = cwd.trim();
1117 if !cwd.is_empty() {
1118 self.current_cwd = native_cwd_from_prompt(cwd);
1119 }
1120 }
1121
1122 pub fn output_snapshot(&self) -> String {
1126 self.output_buffer.clone()
1127 }
1128
1129 fn check_prompt_complete(text: &str, prompt_end: &str) -> bool {
1137 Self::parse_prompt_exit_code(text, prompt_end).is_some()
1138 }
1139
1140 fn parse_prompt_exit_code(text: &str, prompt_end: &str) -> Option<i32> {
1146 let last = text.lines().rev().find(|line| !line.trim().is_empty())?;
1147 if !last.contains(WCGW_PROMPT_PATTERN) {
1148 return None;
1149 }
1150 let raw_tail = last.rsplit_once(prompt_end)?.1;
1156 let clean_tail = crate::state::terminal::strip_ansi_codes(raw_tail);
1157 let code_text = clean_tail.strip_prefix(':')?;
1158 let digits = code_text
1159 .char_indices()
1160 .take_while(|(index, c)| c.is_ascii_digit() || (*index == 0 && *c == '-'))
1161 .count();
1162 let (code, rest) = code_text.split_at(digits);
1163 if !rest.chars().all(char::is_whitespace) {
1164 return None;
1165 }
1166 let terminated = !rest.is_empty() || raw_tail.len() > clean_tail.len();
1170 if !terminated {
1171 return None;
1172 }
1173 code.parse::<i32>().ok()
1174 }
1175
1176 pub fn set_scratch_root(&mut self, root: &Path) {
1179 self.scratch_workspace_root = Some(root.to_path_buf());
1180 }
1181
1182 pub fn scratch_path(&self) -> Option<&Path> {
1185 self.scratch_path.as_deref()
1186 }
1187
1188 pub fn reset_scratch(&mut self) {
1191 self.scratch_path = None;
1192 self.scratch_bytes = 0;
1193 self.discard_throttle.store(false, AtomicOrdering::Relaxed);
1194 }
1195
1196 fn offload_dropped_head(&mut self, head: &str) {
1200 let Some(root) = self.scratch_workspace_root.clone() else {
1201 return;
1202 };
1203 let remaining = SCRATCH_MAX_BYTES.saturating_sub(self.scratch_bytes);
1207 if remaining == 0 {
1208 self.discard_throttle.store(true, AtomicOrdering::Relaxed);
1212 return;
1213 }
1214 let head = match usize::try_from(remaining) {
1215 Ok(rem) if rem < head.len() => &head[..crate::utils::floor_char_boundary(head, rem)],
1216 _ => head,
1217 };
1218 if self.scratch_path.is_none() {
1219 self.scratch_path = crate::utils::scratch_file::new_scratch_path(&root);
1220 }
1221 let Some(path) = self.scratch_path.clone() else {
1222 return;
1223 };
1224 match crate::utils::scratch_file::append_scratch(&path, head.as_bytes()) {
1225 Ok(0) if self.scratch_bytes == 0 => self.scratch_path = None,
1226 Ok(written) => {
1227 self.scratch_bytes = self.scratch_bytes.saturating_add(written as u64);
1228 if self.scratch_bytes >= SCRATCH_MAX_BYTES {
1229 self.discard_throttle.store(true, AtomicOrdering::Relaxed);
1230 }
1231 }
1232 Err(e) => debug!("scratch: append to {} failed: {e}", path.display()),
1233 }
1234 }
1235
1236 fn truncate_output_buffer_with_offload(&mut self) {
1241 if self.output_buffer.len() <= self.max_output_size {
1242 return;
1243 }
1244 self.output_truncated = true;
1245 let keep_size = self.max_output_size / 2;
1246 let cut = crate::utils::floor_char_boundary(
1249 &self.output_buffer,
1250 self.output_buffer.len() - keep_size,
1251 );
1252 let head = self.output_buffer[..cut].to_owned();
1253 self.offload_dropped_head(&head);
1254 self.output_buffer.replace_range(..cut, "\n(...output truncated...)\n");
1259 }
1260
1261 pub fn send_interrupt(&mut self) -> Result<()> {
1263 debug!("PTY sending Ctrl+C");
1264 self.writer
1265 .write_all(&[0x03]) .context("Failed to send Ctrl+C")?;
1267 self.writer.flush()?;
1268 Ok(())
1269 }
1270
1271 pub fn send_eof(&mut self) -> Result<()> {
1273 debug!("PTY sending Ctrl+D");
1274 self.writer
1275 .write_all(&[0x04]) .context("Failed to send Ctrl+D")?;
1277 self.writer.flush()?;
1278 Ok(())
1279 }
1280
1281 pub fn send_suspend(&mut self) -> Result<()> {
1283 debug!("PTY sending Ctrl+Z");
1284 self.writer
1285 .write_all(&[0x1A]) .context("Failed to send Ctrl+Z")?;
1287 self.writer.flush()?;
1288 Ok(())
1289 }
1290
1291 pub fn send_text(&mut self, text: &str) -> Result<()> {
1293 debug!(bytes = text.len(), "PTY sending text");
1294 self.send_bytes(text.as_bytes()).context("Failed to send text")?;
1295 Ok(())
1296 }
1297
1298 pub fn send_bytes(&mut self, bytes: &[u8]) -> Result<()> {
1300 self.writer.write_all(bytes).context("Failed to send bytes")?;
1301 self.writer.flush()?;
1302 Ok(())
1303 }
1304
1305 pub fn send_special_key(&mut self, key: &str) -> Result<()> {
1307 let bytes: &[u8] = match key {
1308 "Enter" => b"\r",
1309 "Tab" => b"\t",
1310 "Backspace" => b"\x7F",
1311 "Escape" => b"\x1B",
1312 "Up" | "KeyUp" => b"\x1B[A",
1313 "Down" | "KeyDown" => b"\x1B[B",
1314 "Right" | "KeyRight" => b"\x1B[C",
1315 "Left" | "KeyLeft" => b"\x1B[D",
1316 "Home" => b"\x1B[H",
1317 "End" => b"\x1B[F",
1318 "PageUp" => b"\x1B[5~",
1319 "PageDown" => b"\x1B[6~",
1320 "Delete" => b"\x1B[3~",
1321 "Insert" => b"\x1B[2~",
1322 "CtrlC" | "Ctrl-C" => b"\x03",
1323 "CtrlD" | "Ctrl-D" => b"\x04",
1324 "CtrlZ" | "Ctrl-Z" => b"\x1A",
1325 "CtrlL" | "Ctrl-L" => b"\x0C",
1326 _ => return Err(anyhow!("Unknown special key: {key}")),
1327 };
1328
1329 debug!("PTY sending special key: {} ({:?})", key, bytes);
1330 self.send_bytes(bytes)?;
1331 Ok(())
1332 }
1333
1334 pub fn live_snapshot(&self, max_lines: usize) -> Vec<String> {
1339 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).snapshot(max_lines)
1343 }
1344
1345 pub fn live_in_alt_screen(&self) -> bool {
1348 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).in_alt_screen()
1349 }
1350
1351 pub fn live_osc_title(&self) -> String {
1355 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).osc_title().to_string()
1356 }
1357
1358 pub fn live_osc_progress(&self) -> String {
1361 self.live
1362 .lock()
1363 .unwrap_or_else(std::sync::PoisonError::into_inner)
1364 .osc_progress()
1365 .to_string()
1366 }
1367
1368 pub fn live_cursor_position(&self) -> (u16, u16) {
1371 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).cursor_position()
1372 }
1373
1374 pub fn live_snapshot_diff(&self, max_lines: usize, threshold: usize) -> ScreenUpdate {
1378 self.live
1379 .lock()
1380 .unwrap_or_else(std::sync::PoisonError::into_inner)
1381 .snapshot_diff(max_lines, threshold)
1382 }
1383
1384 pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
1386 debug!("PTY resizing to {}x{}", cols, rows);
1387
1388 let new_size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
1389
1390 self.master.resize(new_size).context("Failed to resize PTY")?;
1391
1392 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).resize(rows, cols);
1394
1395 self.size = new_size;
1396 Ok(())
1397 }
1398
1399 pub fn get_size(&self) -> (u16, u16) {
1401 (self.size.cols, self.size.rows)
1402 }
1403
1404 pub fn process_id(&self) -> Option<u32> {
1406 self.child.process_id()
1407 }
1408
1409 pub fn is_alive(&mut self) -> bool {
1411 self.child.try_wait().is_ok_and(|status| status.is_none())
1412 }
1413}
1414
1415pub type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
1417
1418pub fn create_shared_pty(initial_dir: &Path, restricted_mode: bool) -> Result<SharedPtyShell> {
1420 let shell = PtyShell::new(initial_dir, restricted_mode)?;
1421 Ok(Arc::new(Mutex::new(Some(shell))))
1422}
1423
1424fn decode_keep_incomplete(bytes: &[u8]) -> (String, Vec<u8>) {
1433 let mut decoded = String::new();
1434 let mut rest = bytes;
1435 loop {
1436 match std::str::from_utf8(rest) {
1437 Ok(text) => {
1438 decoded.push_str(text);
1439 return (decoded, Vec::new());
1440 }
1441 Err(error) => {
1442 let valid = error.valid_up_to();
1443 decoded.push_str(&String::from_utf8_lossy(&rest[..valid]));
1446 match error.error_len() {
1447 None => return (decoded, rest[valid..].to_vec()),
1449 Some(bad) => {
1451 decoded.push('\u{FFFD}');
1452 rest = &rest[valid + bad..];
1453 }
1454 }
1455 }
1456 }
1457 }
1458}
1459
1460#[cfg(test)]
1461mod tests {
1462 use super::*;
1463 #[cfg(unix)] use tempfile::TempDir;
1465
1466 #[test]
1467 fn decode_passes_clean_utf8_through() {
1468 let (text, carry) = decode_keep_incomplete("hello ✓ café".as_bytes());
1469 assert_eq!(text, "hello ✓ café");
1470 assert!(carry.is_empty(), "expected empty carry");
1471 }
1472
1473 #[test]
1474 fn decode_carries_incomplete_trailing_char_across_reads() {
1475 let (text1, carry1) = decode_keep_incomplete(&[b'a', 0xC3]);
1478 assert_eq!(text1, "a");
1479 assert_eq!(carry1, vec![0xC3]);
1480
1481 let mut next = carry1;
1482 next.push(0xA9); let (text2, carry2) = decode_keep_incomplete(&next);
1484 assert_eq!(text2, "é");
1485 assert!(carry2.is_empty(), "expected empty carry2");
1486 }
1487
1488 #[test]
1489 fn decode_replaces_genuinely_invalid_bytes() {
1490 let (text, carry) = decode_keep_incomplete(&[b'x', 0xFF, b'y']);
1492 assert_eq!(text, "x\u{FFFD}y");
1493 assert!(carry.is_empty(), "expected empty carry");
1494 }
1495
1496 #[test]
1497 fn decode_handles_large_invalid_burst_without_overflow() {
1498 let big = vec![0xFFu8; 200_000];
1502 let (text, carry) = decode_keep_incomplete(&big);
1503 assert!(carry.is_empty(), "expected empty carry");
1504 assert_eq!(text.chars().filter(|&c| c == '\u{FFFD}').count(), 200_000);
1505 }
1506
1507 #[test]
1508 fn screen_session_name_parser_is_strict() {
1509 assert_eq!(super::winx_creator_pid("winx-123-456"), Some(123));
1510 assert_eq!(super::winx_creator_pid("winx-nope-456"), None);
1511 assert_eq!(super::winx_creator_pid("other-123-456"), None);
1512 }
1513
1514 #[cfg(unix)]
1515 #[test]
1516 fn process_probe_recognizes_current_process_and_rejects_reserved_pids() {
1517 assert!(super::process_exists(std::process::id()));
1518 assert!(!super::process_exists(0));
1519 assert!(!super::process_exists(1));
1520 }
1521
1522 #[test]
1523 fn msys_paths_convert_to_native_drives() {
1524 use super::msys_to_native;
1525 assert_eq!(msys_to_native("/c/Users/x/ws"), Some(PathBuf::from("C:\\Users\\x\\ws")));
1526 assert_eq!(msys_to_native("/cygdrive/d/work"), Some(PathBuf::from("D:\\work")));
1527 assert_eq!(msys_to_native("/c"), Some(PathBuf::from("C:\\")));
1528 assert_eq!(msys_to_native("/home/x"), None);
1529 assert_eq!(msys_to_native("relative"), None);
1530 }
1531
1532 #[test]
1533 fn prompt_detection_is_suffix_anchored() {
1534 let end = "──➤deadbeefcafe0001";
1536 assert!(PtyShell::check_prompt_complete("out\nmore\n◉ /home/x──➤deadbeefcafe0001:0 ", end));
1538 assert!(PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001:0 \u{1b}[K", end));
1540 assert!(!PtyShell::check_prompt_complete(
1542 "menu: ◉ start ──➤deadbeefcafe0001 stop\nstill running",
1543 end
1544 ));
1545 assert!(!PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001:0 ls -la", end));
1547 assert!(!PtyShell::check_prompt_complete("just some output\n", end));
1549 assert!(!PtyShell::check_prompt_complete("◉ /fake──➤ ", end));
1552 assert!(!PtyShell::check_prompt_complete("◉ /fake──➤wrongnonce:0 ", end));
1553 assert!(!PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001 ", end));
1554 }
1555
1556 #[test]
1557 fn powershell_bootstrap_defines_a_marker_prompt_without_psreadline() {
1558 let bootstrap = super::powershell_bootstrap("cafe1234");
1559 assert!(bootstrap.contains("Remove-Module PSReadLine"), "{bootstrap}");
1560 assert!(bootstrap.contains("function global:prompt"), "{bootstrap}");
1561 assert!(bootstrap.contains("──➤cafe1234:$code "), "{bootstrap}");
1562 assert!(bootstrap.contains("$global:LASTEXITCODE = 0"), "{bootstrap}");
1563 assert!(!bootstrap.contains('\n'), "bootstrap must be a single input line");
1564 }
1565
1566 #[test]
1567 fn parses_exit_code_from_prompt_marker() {
1568 let end = "──➤cafe1234";
1569 assert_eq!(PtyShell::parse_prompt_exit_code("ok\n◉ /x──➤cafe1234:0 ", end), Some(0));
1570 assert_eq!(PtyShell::parse_prompt_exit_code("oops\n◉ /x──➤cafe1234:1 ", end), Some(1));
1571 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:42 ", end), Some(42));
1572 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:1", end), None);
1574 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:127", end), None);
1575 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:127 ", end), Some(127));
1576 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:127\u{1b}[C", end), Some(127));
1578 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:0\u{1b}[K", end), Some(0));
1579 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:-1 ", end), Some(-1));
1580 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:12a ", end), None);
1581 assert_eq!(PtyShell::parse_prompt_exit_code("building...", end), None);
1583 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤wrong:0 ", end), None);
1585 }
1586
1587 use proptest::prelude::*;
1588
1589 proptest! {
1590 #[test]
1594 fn parse_prompt_exit_code_never_panics(
1595 text in prop::collection::vec(any::<char>(), 0..256),
1596 nonce in "[0-9a-f]{0,16}",
1597 ) {
1598 let s: String = text.into_iter().collect();
1599 let end = format!("{WCGW_PROMPT_END}{nonce}");
1600 let _ = PtyShell::parse_prompt_exit_code(&s, &end);
1602 }
1603
1604 #[test]
1606 fn parse_prompt_exit_code_round_trips(code in any::<i32>(), nonce in "[0-9a-f]{1,16}") {
1607 let end = format!("{WCGW_PROMPT_END}{nonce}");
1608 let line = format!("output line\n{WCGW_PROMPT_PATTERN} /home/x{end}:{code} ");
1609 prop_assert_eq!(PtyShell::parse_prompt_exit_code(&line, &end), Some(code));
1610 }
1611 }
1612
1613 #[cfg(unix)] #[test]
1615 fn test_pty_shell_creation() -> Result<()> {
1616 let temp_dir = TempDir::new()?;
1617 let result = PtyShell::new(temp_dir.path(), false);
1618 assert!(result.is_ok(), "Failed to create PTY shell: {:?}", result.err());
1619 Ok(())
1620 }
1621
1622 #[cfg(unix)] #[test]
1624 fn discard_throttle_engages_at_scratch_cap_and_resets_per_command() -> Result<()> {
1625 let temp_dir = TempDir::new()?;
1626 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1627 assert!(!shell.discard_throttle.load(AtomicOrdering::Relaxed));
1628
1629 shell.scratch_workspace_root = Some(temp_dir.path().to_path_buf());
1632 shell.scratch_bytes = SCRATCH_MAX_BYTES;
1633 shell.offload_dropped_head("dropped beyond the budget");
1634 assert!(shell.discard_throttle.load(AtomicOrdering::Relaxed));
1635
1636 shell.send_command("echo done")?;
1638 assert!(!shell.discard_throttle.load(AtomicOrdering::Relaxed));
1639 Ok(())
1640 }
1641
1642 #[cfg(unix)] #[test]
1644 fn discard_throttle_engages_when_a_write_reaches_the_cap() -> Result<()> {
1645 let temp_dir = TempDir::new()?;
1646 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1647 shell.scratch_workspace_root = Some(temp_dir.path().to_path_buf());
1648 shell.scratch_bytes = SCRATCH_MAX_BYTES - 1;
1650 shell.offload_dropped_head("xx");
1651 assert!(shell.discard_throttle.load(AtomicOrdering::Relaxed));
1652 Ok(())
1653 }
1654
1655 #[cfg(unix)] #[test]
1657 fn test_pty_shell_echo() -> Result<()> {
1658 let temp_dir = TempDir::new()?;
1659 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1660
1661 shell.send_command("echo 'hello pty'")?;
1662 let (output, complete) = shell.read_output(2.0)?;
1663
1664 assert!(output.contains("hello pty"), "Output should contain 'hello pty': {output}");
1665 assert!(complete, "first command must complete: {output}");
1669 assert!(!output.contains("PROMPT_COMMAND"), "bootstrap leaked into output: {output}");
1670 assert_eq!(shell.last_exit_code, Some(0), "{output}");
1671 Ok(())
1672 }
1673
1674 #[cfg(unix)] #[test]
1676 fn agent_paths_are_exported_to_the_pty() -> Result<()> {
1677 let workspace = TempDir::new()?;
1678 let temporary_artifact_dir = workspace.path().join(".winx/tmp/session-test");
1679 let mut shell = PtyShell::new_with_agent_paths(
1680 workspace.path(),
1681 false,
1682 Some(workspace.path()),
1683 Some(&temporary_artifact_dir),
1684 )?;
1685
1686 shell.send_command("printf '%s\\n%s\\n' \"$WINX_WORKSPACE_ROOT\" \"$WINX_TEMP_DIR\"")?;
1687 let (output, _complete) = shell.read_output(2.0)?;
1688
1689 assert!(output.contains(&workspace.path().to_string_lossy().to_string()), "{output}");
1690 assert!(output.contains(&temporary_artifact_dir.to_string_lossy().to_string()), "{output}");
1691 Ok(())
1692 }
1693
1694 #[cfg(unix)] #[test]
1696 fn completed_nonblocking_poll_is_level_triggered() -> Result<()> {
1697 let temp_dir = TempDir::new()?;
1698 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1699
1700 shell.send_command("true")?;
1701 let deadline = Instant::now() + Duration::from_secs(2);
1702 while Instant::now() < deadline && !shell.poll_output_nonblocking() {
1703 std::thread::sleep(Duration::from_millis(10));
1704 }
1705
1706 assert!(!shell.command_running, "shell did not complete before the deadline");
1707 assert!(
1708 shell.poll_output_nonblocking(),
1709 "a later consumer must still observe the completed state"
1710 );
1711 Ok(())
1712 }
1713
1714 #[cfg(unix)] #[test]
1716 fn test_pty_shell_pwd() -> Result<()> {
1717 let temp_dir = TempDir::new()?;
1718 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1719
1720 shell.send_command("pwd && echo 'pwd_done'")?;
1723 let (output, _complete) = shell.read_output(2.0)?;
1724
1725 assert!(output.contains("pwd_done"), "Output should contain 'pwd_done': {output}");
1727 Ok(())
1728 }
1729
1730 #[cfg(unix)] #[test]
1732 fn test_pty_resize() -> Result<()> {
1733 let temp_dir = TempDir::new()?;
1734 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1735
1736 let result = shell.resize(120, 40);
1737 assert!(result.is_ok());
1738
1739 let (cols, rows) = shell.get_size();
1740 assert_eq!(cols, 120);
1741 assert_eq!(rows, 40);
1742 Ok(())
1743 }
1744
1745 #[cfg(unix)]
1749 fn pid_alive(pid: i32) -> bool {
1750 u32::try_from(pid).is_ok_and(crate::os::unix::process_exists)
1751 }
1752
1753 #[cfg(unix)]
1754 #[test]
1755 fn dropping_shell_kills_orphaned_background_child() -> Result<()> {
1756 let dir = TempDir::new()?;
1761 let pidfile = dir.path().join("child.pid");
1762 let mut shell = PtyShell::new(dir.path(), false)?;
1763
1764 shell
1765 .send_command(&format!("sleep 300 & echo $! > {} ; echo spawned", pidfile.display()))?;
1766
1767 let mut child_pid = None;
1772 for _ in 0..150 {
1773 let _ = shell.read_output(0.1);
1774 if let Some(pid) =
1775 std::fs::read_to_string(&pidfile).ok().and_then(|c| c.trim().parse::<i32>().ok())
1776 {
1777 child_pid = Some(pid);
1778 break;
1779 }
1780 thread::sleep(Duration::from_millis(20));
1781 }
1782 let child_pid =
1783 child_pid.ok_or_else(|| anyhow!("background child pid never materialized"))?;
1784 assert!(pid_alive(child_pid), "background child should be running before drop");
1785
1786 drop(shell);
1787
1788 let mut gone = false;
1790 for _ in 0..150 {
1791 if !pid_alive(child_pid) {
1792 gone = true;
1793 break;
1794 }
1795 thread::sleep(Duration::from_millis(20));
1796 }
1797 assert!(gone, "background child {child_pid} survived shell teardown (orphan leak)");
1798 Ok(())
1799 }
1800}