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 self.write_command(&prompt_statement)?;
744
745 let deadline = Instant::now() + PROMPT_BOOTSTRAP_TIMEOUT;
752 let mut bootstrap = String::new();
753 let mut settled = false;
754 while Instant::now() < deadline {
755 match self.output_rx.recv_timeout(Duration::from_millis(50)) {
756 Ok(chunk) => {
757 self.answer_terminal_queries(&chunk);
758 bootstrap.push_str(&chunk);
759 if Self::check_prompt_complete(&bootstrap, &self.prompt_end_marker) {
760 settled = true;
761 break;
762 }
763 }
764 Err(mpsc::RecvTimeoutError::Timeout) => {}
765 Err(mpsc::RecvTimeoutError::Disconnected) => break,
766 }
767 }
768 if settled {
769 std::thread::sleep(Duration::from_millis(30));
770 } else {
771 warn!(
772 timeout_ms = PROMPT_BOOTSTRAP_TIMEOUT.as_millis(),
773 "PTY shell did not print its first prompt in time; continuing anyway"
774 );
775 }
776 bootstrap.push_str(&self.drain_output());
777 debug!(
778 bytes = bootstrap.len(),
779 settled,
780 child_pid = ?self.child.process_id(),
781 "PTY prompt bootstrap drained"
782 );
783
784 Ok(())
785 }
786
787 fn answer_terminal_queries(&mut self, chunk: &str) {
796 const CURSOR_POSITION_REQUEST: &str = "\x1b[6n";
797 if !chunk.contains(CURSOR_POSITION_REQUEST) {
798 return;
799 }
800 debug!("PTY answered a cursor position request");
801 if let Err(error) = self.writer.write_all(b"\x1b[1;1R").and_then(|()| self.writer.flush()) {
802 debug!("PTY cursor position report failed: {error}");
803 }
804 }
805
806 fn write_command(&mut self, command: &str) -> Result<()> {
808 let cmd_with_newline = format!("{command}\r");
814 self.writer.write_all(cmd_with_newline.as_bytes()).context("Failed to write to PTY")?;
815 self.writer.flush().context("Failed to flush PTY")?;
816 Ok(())
817 }
818
819 fn drain_output(&mut self) -> String {
821 let mut output = String::new();
822 let deadline = Instant::now() + Duration::from_millis(200);
823
824 while Instant::now() < deadline {
826 match self.output_rx.try_recv() {
827 Ok(chunk) => {
828 self.answer_terminal_queries(&chunk);
829 output.push_str(&chunk);
830
831 if output.len() > self.max_output_size {
833 self.output_truncated = true;
834 break;
835 }
836 }
837 Err(TryRecvError::Empty) => {
838 thread::sleep(Duration::from_millis(10));
840 }
841 Err(TryRecvError::Disconnected) => {
842 break;
844 }
845 }
846 }
847
848 output
849 }
850
851 pub fn send_command(&mut self, command: &str) -> Result<()> {
853 debug!(bytes = command.len(), "PTY sending command");
854
855 self.output_buffer.clear();
857 self.output_truncated = false;
858 self.reset_scratch();
859 self.last_command = command.to_string();
860 self.command_running = true;
861 self.mark_command_started();
862 self.last_exit_code = None;
863 self.last_returned_hash = None;
866 self.delivered_output.clear();
867 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clear_osc();
870
871 self.write_command(command)?;
873
874 Ok(())
875 }
876
877 pub fn mark_command_started(&mut self) {
879 self.command_generation = self.command_generation.saturating_add(1);
880 self.command_started_at = Some(Instant::now());
881 self.pending_completion_report = true;
882 }
883
884 pub fn completion_report_pending(&self) -> bool {
887 !self.command_running && self.pending_completion_report
888 }
889
890 pub fn mark_completion_reported(&mut self) {
892 self.pending_completion_report = false;
893 }
894
895 pub fn command_generation(&self) -> u64 {
896 self.command_generation
897 }
898
899 pub fn incarnation(&self) -> u64 {
900 self.incarnation
901 }
902
903 pub fn command_elapsed(&self) -> Option<Duration> {
905 self.command_started_at.map(|started_at| started_at.elapsed())
906 }
907
908 pub fn current_cwd(&self) -> &Path {
910 &self.current_cwd
911 }
912
913 pub fn delivered_output(&self) -> String {
915 self.delivered_output.clone()
916 }
917
918 pub fn mark_output_delivered(&mut self, output: &str) {
920 self.delivered_output.clear();
921 self.delivered_output.push_str(output);
922 }
923
924 pub fn collect_scrollback(&self, lines: usize) -> String {
929 let rendered =
935 crate::state::terminal::render_terminal_output(&self.ring.raw(lines)).join("\n");
936 match self.ring.scrollback_notice(lines) {
937 Some(notice) => format!("{notice}\n{rendered}"),
938 None => rendered,
939 }
940 }
941
942 pub fn fingerprint(text: &str) -> u64 {
944 let mut hasher = DefaultHasher::new();
945 text.hash(&mut hasher);
946 hasher.finish()
947 }
948
949 pub fn read_output(&mut self, timeout_secs: f32) -> Result<(String, bool)> {
954 let timeout = Duration::from_secs_f32(timeout_secs.clamp(0.1, 60.0));
955 let start = Instant::now();
956 let mut complete = false;
957 let mut no_data_count = 0;
958 let mut prompt_detected_at: Option<Instant> = None;
959
960 while start.elapsed() < timeout {
961 match self.output_rx.try_recv() {
962 Ok(chunk) => {
963 self.answer_terminal_queries(&chunk);
964 self.output_buffer.push_str(&chunk);
965 self.ring.push_chunk(&chunk);
966 no_data_count = 0;
967
968 if prompt_detected_at.is_none()
970 && (Self::check_prompt_complete(&chunk, &self.prompt_end_marker)
971 || Self::check_prompt_complete(
972 &self.output_buffer,
973 &self.prompt_end_marker,
974 ))
975 {
976 prompt_detected_at = Some(Instant::now());
977 debug!("Prompt detected, draining remaining output...");
978 }
979
980 self.truncate_output_buffer_with_offload();
982 }
983 Err(TryRecvError::Empty) => {
984 thread::sleep(Duration::from_millis(10));
986 no_data_count += 1;
987
988 if let Some(detected_time) = prompt_detected_at {
990 if detected_time.elapsed() > Duration::from_millis(100) {
992 complete = true;
993 debug!("Command completed - prompt detected and drained");
994 break;
995 }
996 } else if no_data_count > 10
997 && Self::check_prompt_complete(&self.output_buffer, &self.prompt_end_marker)
998 {
999 prompt_detected_at = Some(Instant::now());
1001 debug!("Prompt detected after wait, draining...");
1002 }
1003 }
1004 Err(TryRecvError::Disconnected) => {
1005 warn!("PTY reader disconnected");
1007 complete = true;
1008 break;
1009 }
1010 }
1011 }
1012
1013 if complete || prompt_detected_at.is_some() {
1014 self.command_running = false;
1015 self.last_exit_code =
1016 Self::parse_prompt_exit_code(&self.output_buffer, &self.prompt_end_marker);
1017 self.update_current_cwd_from_prompt();
1018 complete = true;
1019 }
1020
1021 Ok((self.output_buffer.clone(), complete))
1022 }
1023
1024 pub fn poll_output_nonblocking(&mut self) -> bool {
1032 let mut prompt_seen = false;
1033 loop {
1034 match self.output_rx.try_recv() {
1035 Ok(chunk) => {
1036 self.answer_terminal_queries(&chunk);
1037 let chunk_has_prompt =
1038 Self::check_prompt_complete(&chunk, &self.prompt_end_marker);
1039 self.output_buffer.push_str(&chunk);
1040 self.ring.push_chunk(&chunk);
1041 if chunk_has_prompt
1042 || Self::check_prompt_complete(&self.output_buffer, &self.prompt_end_marker)
1043 {
1044 prompt_seen = true;
1045 }
1046 self.truncate_output_buffer_with_offload();
1047 }
1048 Err(TryRecvError::Empty) => break,
1049 Err(TryRecvError::Disconnected) => {
1050 self.command_running = false;
1051 return true;
1052 }
1053 }
1054 }
1055 if prompt_seen {
1056 self.command_running = false;
1057 self.last_exit_code =
1058 Self::parse_prompt_exit_code(&self.output_buffer, &self.prompt_end_marker);
1059 self.update_current_cwd_from_prompt();
1060 }
1061 !self.command_running
1066 }
1067
1068 fn update_current_cwd_from_prompt(&mut self) {
1069 let Some(last) =
1070 self.output_buffer.lines().rev().find(|line| line.contains(&self.prompt_end_marker))
1071 else {
1072 return;
1073 };
1074 let clean = crate::state::terminal::strip_ansi_codes(last);
1075 let Some((before_marker, _)) = clean.rsplit_once(&self.prompt_end_marker) else {
1076 return;
1077 };
1078 let Some((_, cwd)) = before_marker.rsplit_once("◉ ") else {
1079 return;
1080 };
1081 let cwd = cwd.trim();
1082 if !cwd.is_empty() {
1083 self.current_cwd = native_cwd_from_prompt(cwd);
1084 }
1085 }
1086
1087 pub fn output_snapshot(&self) -> String {
1091 self.output_buffer.clone()
1092 }
1093
1094 fn check_prompt_complete(text: &str, prompt_end: &str) -> bool {
1102 Self::parse_prompt_exit_code(text, prompt_end).is_some()
1103 }
1104
1105 fn parse_prompt_exit_code(text: &str, prompt_end: &str) -> Option<i32> {
1111 let last = text.lines().rev().find(|line| !line.trim().is_empty())?;
1112 if !last.contains(WCGW_PROMPT_PATTERN) {
1113 return None;
1114 }
1115 let raw_tail = last.rsplit_once(prompt_end)?.1;
1121 let clean_tail = crate::state::terminal::strip_ansi_codes(raw_tail);
1122 let code_text = clean_tail.strip_prefix(':')?;
1123 let digits = code_text
1124 .char_indices()
1125 .take_while(|(index, c)| c.is_ascii_digit() || (*index == 0 && *c == '-'))
1126 .count();
1127 let (code, rest) = code_text.split_at(digits);
1128 if !rest.chars().all(char::is_whitespace) {
1129 return None;
1130 }
1131 let terminated = !rest.is_empty() || raw_tail.len() > clean_tail.len();
1135 if !terminated {
1136 return None;
1137 }
1138 code.parse::<i32>().ok()
1139 }
1140
1141 pub fn set_scratch_root(&mut self, root: &Path) {
1144 self.scratch_workspace_root = Some(root.to_path_buf());
1145 }
1146
1147 pub fn scratch_path(&self) -> Option<&Path> {
1150 self.scratch_path.as_deref()
1151 }
1152
1153 pub fn reset_scratch(&mut self) {
1156 self.scratch_path = None;
1157 self.scratch_bytes = 0;
1158 self.discard_throttle.store(false, AtomicOrdering::Relaxed);
1159 }
1160
1161 fn offload_dropped_head(&mut self, head: &str) {
1165 let Some(root) = self.scratch_workspace_root.clone() else {
1166 return;
1167 };
1168 let remaining = SCRATCH_MAX_BYTES.saturating_sub(self.scratch_bytes);
1172 if remaining == 0 {
1173 self.discard_throttle.store(true, AtomicOrdering::Relaxed);
1177 return;
1178 }
1179 let head = match usize::try_from(remaining) {
1180 Ok(rem) if rem < head.len() => &head[..crate::utils::floor_char_boundary(head, rem)],
1181 _ => head,
1182 };
1183 if self.scratch_path.is_none() {
1184 self.scratch_path = crate::utils::scratch_file::new_scratch_path(&root);
1185 }
1186 let Some(path) = self.scratch_path.clone() else {
1187 return;
1188 };
1189 match crate::utils::scratch_file::append_scratch(&path, head.as_bytes()) {
1190 Ok(0) if self.scratch_bytes == 0 => self.scratch_path = None,
1191 Ok(written) => {
1192 self.scratch_bytes = self.scratch_bytes.saturating_add(written as u64);
1193 if self.scratch_bytes >= SCRATCH_MAX_BYTES {
1194 self.discard_throttle.store(true, AtomicOrdering::Relaxed);
1195 }
1196 }
1197 Err(e) => debug!("scratch: append to {} failed: {e}", path.display()),
1198 }
1199 }
1200
1201 fn truncate_output_buffer_with_offload(&mut self) {
1206 if self.output_buffer.len() <= self.max_output_size {
1207 return;
1208 }
1209 self.output_truncated = true;
1210 let keep_size = self.max_output_size / 2;
1211 let cut = crate::utils::floor_char_boundary(
1214 &self.output_buffer,
1215 self.output_buffer.len() - keep_size,
1216 );
1217 let head = self.output_buffer[..cut].to_owned();
1218 self.offload_dropped_head(&head);
1219 self.output_buffer.replace_range(..cut, "\n(...output truncated...)\n");
1224 }
1225
1226 pub fn send_interrupt(&mut self) -> Result<()> {
1228 debug!("PTY sending Ctrl+C");
1229 self.writer
1230 .write_all(&[0x03]) .context("Failed to send Ctrl+C")?;
1232 self.writer.flush()?;
1233 Ok(())
1234 }
1235
1236 pub fn send_eof(&mut self) -> Result<()> {
1238 debug!("PTY sending Ctrl+D");
1239 self.writer
1240 .write_all(&[0x04]) .context("Failed to send Ctrl+D")?;
1242 self.writer.flush()?;
1243 Ok(())
1244 }
1245
1246 pub fn send_suspend(&mut self) -> Result<()> {
1248 debug!("PTY sending Ctrl+Z");
1249 self.writer
1250 .write_all(&[0x1A]) .context("Failed to send Ctrl+Z")?;
1252 self.writer.flush()?;
1253 Ok(())
1254 }
1255
1256 pub fn send_text(&mut self, text: &str) -> Result<()> {
1258 debug!(bytes = text.len(), "PTY sending text");
1259 self.send_bytes(text.as_bytes()).context("Failed to send text")?;
1260 Ok(())
1261 }
1262
1263 pub fn send_bytes(&mut self, bytes: &[u8]) -> Result<()> {
1265 self.writer.write_all(bytes).context("Failed to send bytes")?;
1266 self.writer.flush()?;
1267 Ok(())
1268 }
1269
1270 pub fn send_special_key(&mut self, key: &str) -> Result<()> {
1272 let bytes: &[u8] = match key {
1273 "Enter" => b"\r",
1274 "Tab" => b"\t",
1275 "Backspace" => b"\x7F",
1276 "Escape" => b"\x1B",
1277 "Up" | "KeyUp" => b"\x1B[A",
1278 "Down" | "KeyDown" => b"\x1B[B",
1279 "Right" | "KeyRight" => b"\x1B[C",
1280 "Left" | "KeyLeft" => b"\x1B[D",
1281 "Home" => b"\x1B[H",
1282 "End" => b"\x1B[F",
1283 "PageUp" => b"\x1B[5~",
1284 "PageDown" => b"\x1B[6~",
1285 "Delete" => b"\x1B[3~",
1286 "Insert" => b"\x1B[2~",
1287 "CtrlC" | "Ctrl-C" => b"\x03",
1288 "CtrlD" | "Ctrl-D" => b"\x04",
1289 "CtrlZ" | "Ctrl-Z" => b"\x1A",
1290 "CtrlL" | "Ctrl-L" => b"\x0C",
1291 _ => return Err(anyhow!("Unknown special key: {key}")),
1292 };
1293
1294 debug!("PTY sending special key: {} ({:?})", key, bytes);
1295 self.send_bytes(bytes)?;
1296 Ok(())
1297 }
1298
1299 pub fn live_snapshot(&self, max_lines: usize) -> Vec<String> {
1304 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).snapshot(max_lines)
1308 }
1309
1310 pub fn live_in_alt_screen(&self) -> bool {
1313 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).in_alt_screen()
1314 }
1315
1316 pub fn live_osc_title(&self) -> String {
1320 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).osc_title().to_string()
1321 }
1322
1323 pub fn live_osc_progress(&self) -> String {
1326 self.live
1327 .lock()
1328 .unwrap_or_else(std::sync::PoisonError::into_inner)
1329 .osc_progress()
1330 .to_string()
1331 }
1332
1333 pub fn live_cursor_position(&self) -> (u16, u16) {
1336 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).cursor_position()
1337 }
1338
1339 pub fn live_snapshot_diff(&self, max_lines: usize, threshold: usize) -> ScreenUpdate {
1343 self.live
1344 .lock()
1345 .unwrap_or_else(std::sync::PoisonError::into_inner)
1346 .snapshot_diff(max_lines, threshold)
1347 }
1348
1349 pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> {
1351 debug!("PTY resizing to {}x{}", cols, rows);
1352
1353 let new_size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
1354
1355 self.master.resize(new_size).context("Failed to resize PTY")?;
1356
1357 self.live.lock().unwrap_or_else(std::sync::PoisonError::into_inner).resize(rows, cols);
1359
1360 self.size = new_size;
1361 Ok(())
1362 }
1363
1364 pub fn get_size(&self) -> (u16, u16) {
1366 (self.size.cols, self.size.rows)
1367 }
1368
1369 pub fn process_id(&self) -> Option<u32> {
1371 self.child.process_id()
1372 }
1373
1374 pub fn is_alive(&mut self) -> bool {
1376 self.child.try_wait().is_ok_and(|status| status.is_none())
1377 }
1378}
1379
1380pub type SharedPtyShell = Arc<Mutex<Option<PtyShell>>>;
1382
1383pub fn create_shared_pty(initial_dir: &Path, restricted_mode: bool) -> Result<SharedPtyShell> {
1385 let shell = PtyShell::new(initial_dir, restricted_mode)?;
1386 Ok(Arc::new(Mutex::new(Some(shell))))
1387}
1388
1389fn decode_keep_incomplete(bytes: &[u8]) -> (String, Vec<u8>) {
1398 let mut decoded = String::new();
1399 let mut rest = bytes;
1400 loop {
1401 match std::str::from_utf8(rest) {
1402 Ok(text) => {
1403 decoded.push_str(text);
1404 return (decoded, Vec::new());
1405 }
1406 Err(error) => {
1407 let valid = error.valid_up_to();
1408 decoded.push_str(&String::from_utf8_lossy(&rest[..valid]));
1411 match error.error_len() {
1412 None => return (decoded, rest[valid..].to_vec()),
1414 Some(bad) => {
1416 decoded.push('\u{FFFD}');
1417 rest = &rest[valid + bad..];
1418 }
1419 }
1420 }
1421 }
1422 }
1423}
1424
1425#[cfg(test)]
1426mod tests {
1427 use super::*;
1428 #[cfg(unix)] use tempfile::TempDir;
1430
1431 #[test]
1432 fn decode_passes_clean_utf8_through() {
1433 let (text, carry) = decode_keep_incomplete("hello ✓ café".as_bytes());
1434 assert_eq!(text, "hello ✓ café");
1435 assert!(carry.is_empty(), "expected empty carry");
1436 }
1437
1438 #[test]
1439 fn decode_carries_incomplete_trailing_char_across_reads() {
1440 let (text1, carry1) = decode_keep_incomplete(&[b'a', 0xC3]);
1443 assert_eq!(text1, "a");
1444 assert_eq!(carry1, vec![0xC3]);
1445
1446 let mut next = carry1;
1447 next.push(0xA9); let (text2, carry2) = decode_keep_incomplete(&next);
1449 assert_eq!(text2, "é");
1450 assert!(carry2.is_empty(), "expected empty carry2");
1451 }
1452
1453 #[test]
1454 fn decode_replaces_genuinely_invalid_bytes() {
1455 let (text, carry) = decode_keep_incomplete(&[b'x', 0xFF, b'y']);
1457 assert_eq!(text, "x\u{FFFD}y");
1458 assert!(carry.is_empty(), "expected empty carry");
1459 }
1460
1461 #[test]
1462 fn decode_handles_large_invalid_burst_without_overflow() {
1463 let big = vec![0xFFu8; 200_000];
1467 let (text, carry) = decode_keep_incomplete(&big);
1468 assert!(carry.is_empty(), "expected empty carry");
1469 assert_eq!(text.chars().filter(|&c| c == '\u{FFFD}').count(), 200_000);
1470 }
1471
1472 #[test]
1473 fn screen_session_name_parser_is_strict() {
1474 assert_eq!(super::winx_creator_pid("winx-123-456"), Some(123));
1475 assert_eq!(super::winx_creator_pid("winx-nope-456"), None);
1476 assert_eq!(super::winx_creator_pid("other-123-456"), None);
1477 }
1478
1479 #[cfg(unix)]
1480 #[test]
1481 fn process_probe_recognizes_current_process_and_rejects_reserved_pids() {
1482 assert!(super::process_exists(std::process::id()));
1483 assert!(!super::process_exists(0));
1484 assert!(!super::process_exists(1));
1485 }
1486
1487 #[test]
1488 fn msys_paths_convert_to_native_drives() {
1489 use super::msys_to_native;
1490 assert_eq!(msys_to_native("/c/Users/x/ws"), Some(PathBuf::from("C:\\Users\\x\\ws")));
1491 assert_eq!(msys_to_native("/cygdrive/d/work"), Some(PathBuf::from("D:\\work")));
1492 assert_eq!(msys_to_native("/c"), Some(PathBuf::from("C:\\")));
1493 assert_eq!(msys_to_native("/home/x"), None);
1494 assert_eq!(msys_to_native("relative"), None);
1495 }
1496
1497 #[test]
1498 fn prompt_detection_is_suffix_anchored() {
1499 let end = "──➤deadbeefcafe0001";
1501 assert!(PtyShell::check_prompt_complete("out\nmore\n◉ /home/x──➤deadbeefcafe0001:0 ", end));
1503 assert!(PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001:0 \u{1b}[K", end));
1505 assert!(!PtyShell::check_prompt_complete(
1507 "menu: ◉ start ──➤deadbeefcafe0001 stop\nstill running",
1508 end
1509 ));
1510 assert!(!PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001:0 ls -la", end));
1512 assert!(!PtyShell::check_prompt_complete("just some output\n", end));
1514 assert!(!PtyShell::check_prompt_complete("◉ /fake──➤ ", end));
1517 assert!(!PtyShell::check_prompt_complete("◉ /fake──➤wrongnonce:0 ", end));
1518 assert!(!PtyShell::check_prompt_complete("◉ /home/x──➤deadbeefcafe0001 ", end));
1519 }
1520
1521 #[test]
1522 fn powershell_bootstrap_defines_a_marker_prompt_without_psreadline() {
1523 let bootstrap = super::powershell_bootstrap("cafe1234");
1524 assert!(bootstrap.contains("Remove-Module PSReadLine"), "{bootstrap}");
1525 assert!(bootstrap.contains("function global:prompt"), "{bootstrap}");
1526 assert!(bootstrap.contains("──➤cafe1234:$code "), "{bootstrap}");
1527 assert!(bootstrap.contains("$global:LASTEXITCODE = 0"), "{bootstrap}");
1528 assert!(!bootstrap.contains('\n'), "bootstrap must be a single input line");
1529 }
1530
1531 #[test]
1532 fn parses_exit_code_from_prompt_marker() {
1533 let end = "──➤cafe1234";
1534 assert_eq!(PtyShell::parse_prompt_exit_code("ok\n◉ /x──➤cafe1234:0 ", end), Some(0));
1535 assert_eq!(PtyShell::parse_prompt_exit_code("oops\n◉ /x──➤cafe1234:1 ", end), Some(1));
1536 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:42 ", end), Some(42));
1537 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:1", end), None);
1539 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:127", end), None);
1540 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:127 ", end), Some(127));
1541 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:127\u{1b}[C", end), Some(127));
1543 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:0\u{1b}[K", end), Some(0));
1544 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:-1 ", end), Some(-1));
1545 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤cafe1234:12a ", end), None);
1546 assert_eq!(PtyShell::parse_prompt_exit_code("building...", end), None);
1548 assert_eq!(PtyShell::parse_prompt_exit_code("◉ /x──➤wrong:0 ", end), None);
1550 }
1551
1552 use proptest::prelude::*;
1553
1554 proptest! {
1555 #[test]
1559 fn parse_prompt_exit_code_never_panics(
1560 text in prop::collection::vec(any::<char>(), 0..256),
1561 nonce in "[0-9a-f]{0,16}",
1562 ) {
1563 let s: String = text.into_iter().collect();
1564 let end = format!("{WCGW_PROMPT_END}{nonce}");
1565 let _ = PtyShell::parse_prompt_exit_code(&s, &end);
1567 }
1568
1569 #[test]
1571 fn parse_prompt_exit_code_round_trips(code in any::<i32>(), nonce in "[0-9a-f]{1,16}") {
1572 let end = format!("{WCGW_PROMPT_END}{nonce}");
1573 let line = format!("output line\n{WCGW_PROMPT_PATTERN} /home/x{end}:{code} ");
1574 prop_assert_eq!(PtyShell::parse_prompt_exit_code(&line, &end), Some(code));
1575 }
1576 }
1577
1578 #[cfg(unix)] #[test]
1580 fn test_pty_shell_creation() -> Result<()> {
1581 let temp_dir = TempDir::new()?;
1582 let result = PtyShell::new(temp_dir.path(), false);
1583 assert!(result.is_ok(), "Failed to create PTY shell: {:?}", result.err());
1584 Ok(())
1585 }
1586
1587 #[cfg(unix)] #[test]
1589 fn discard_throttle_engages_at_scratch_cap_and_resets_per_command() -> Result<()> {
1590 let temp_dir = TempDir::new()?;
1591 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1592 assert!(!shell.discard_throttle.load(AtomicOrdering::Relaxed));
1593
1594 shell.scratch_workspace_root = Some(temp_dir.path().to_path_buf());
1597 shell.scratch_bytes = SCRATCH_MAX_BYTES;
1598 shell.offload_dropped_head("dropped beyond the budget");
1599 assert!(shell.discard_throttle.load(AtomicOrdering::Relaxed));
1600
1601 shell.send_command("echo done")?;
1603 assert!(!shell.discard_throttle.load(AtomicOrdering::Relaxed));
1604 Ok(())
1605 }
1606
1607 #[cfg(unix)] #[test]
1609 fn discard_throttle_engages_when_a_write_reaches_the_cap() -> Result<()> {
1610 let temp_dir = TempDir::new()?;
1611 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1612 shell.scratch_workspace_root = Some(temp_dir.path().to_path_buf());
1613 shell.scratch_bytes = SCRATCH_MAX_BYTES - 1;
1615 shell.offload_dropped_head("xx");
1616 assert!(shell.discard_throttle.load(AtomicOrdering::Relaxed));
1617 Ok(())
1618 }
1619
1620 #[cfg(unix)] #[test]
1622 fn test_pty_shell_echo() -> Result<()> {
1623 let temp_dir = TempDir::new()?;
1624 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1625
1626 shell.send_command("echo 'hello pty'")?;
1627 let (output, complete) = shell.read_output(2.0)?;
1628
1629 assert!(output.contains("hello pty"), "Output should contain 'hello pty': {output}");
1630 assert!(complete, "first command must complete: {output}");
1634 assert!(!output.contains("PROMPT_COMMAND"), "bootstrap leaked into output: {output}");
1635 assert_eq!(shell.last_exit_code, Some(0), "{output}");
1636 Ok(())
1637 }
1638
1639 #[cfg(unix)] #[test]
1641 fn agent_paths_are_exported_to_the_pty() -> Result<()> {
1642 let workspace = TempDir::new()?;
1643 let temporary_artifact_dir = workspace.path().join(".winx/tmp/session-test");
1644 let mut shell = PtyShell::new_with_agent_paths(
1645 workspace.path(),
1646 false,
1647 Some(workspace.path()),
1648 Some(&temporary_artifact_dir),
1649 )?;
1650
1651 shell.send_command("printf '%s\\n%s\\n' \"$WINX_WORKSPACE_ROOT\" \"$WINX_TEMP_DIR\"")?;
1652 let (output, _complete) = shell.read_output(2.0)?;
1653
1654 assert!(output.contains(&workspace.path().to_string_lossy().to_string()), "{output}");
1655 assert!(output.contains(&temporary_artifact_dir.to_string_lossy().to_string()), "{output}");
1656 Ok(())
1657 }
1658
1659 #[cfg(unix)] #[test]
1661 fn completed_nonblocking_poll_is_level_triggered() -> Result<()> {
1662 let temp_dir = TempDir::new()?;
1663 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1664
1665 shell.send_command("true")?;
1666 let deadline = Instant::now() + Duration::from_secs(2);
1667 while Instant::now() < deadline && !shell.poll_output_nonblocking() {
1668 std::thread::sleep(Duration::from_millis(10));
1669 }
1670
1671 assert!(!shell.command_running, "shell did not complete before the deadline");
1672 assert!(
1673 shell.poll_output_nonblocking(),
1674 "a later consumer must still observe the completed state"
1675 );
1676 Ok(())
1677 }
1678
1679 #[cfg(unix)] #[test]
1681 fn test_pty_shell_pwd() -> Result<()> {
1682 let temp_dir = TempDir::new()?;
1683 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1684
1685 shell.send_command("pwd && echo 'pwd_done'")?;
1688 let (output, _complete) = shell.read_output(2.0)?;
1689
1690 assert!(output.contains("pwd_done"), "Output should contain 'pwd_done': {output}");
1692 Ok(())
1693 }
1694
1695 #[cfg(unix)] #[test]
1697 fn test_pty_resize() -> Result<()> {
1698 let temp_dir = TempDir::new()?;
1699 let mut shell = PtyShell::new(temp_dir.path(), false)?;
1700
1701 let result = shell.resize(120, 40);
1702 assert!(result.is_ok());
1703
1704 let (cols, rows) = shell.get_size();
1705 assert_eq!(cols, 120);
1706 assert_eq!(rows, 40);
1707 Ok(())
1708 }
1709
1710 #[cfg(unix)]
1714 fn pid_alive(pid: i32) -> bool {
1715 u32::try_from(pid).is_ok_and(crate::os::unix::process_exists)
1716 }
1717
1718 #[cfg(unix)]
1719 #[test]
1720 fn dropping_shell_kills_orphaned_background_child() -> Result<()> {
1721 let dir = TempDir::new()?;
1726 let pidfile = dir.path().join("child.pid");
1727 let mut shell = PtyShell::new(dir.path(), false)?;
1728
1729 shell
1730 .send_command(&format!("sleep 300 & echo $! > {} ; echo spawned", pidfile.display()))?;
1731
1732 let mut child_pid = None;
1737 for _ in 0..150 {
1738 let _ = shell.read_output(0.1);
1739 if let Some(pid) =
1740 std::fs::read_to_string(&pidfile).ok().and_then(|c| c.trim().parse::<i32>().ok())
1741 {
1742 child_pid = Some(pid);
1743 break;
1744 }
1745 thread::sleep(Duration::from_millis(20));
1746 }
1747 let child_pid =
1748 child_pid.ok_or_else(|| anyhow!("background child pid never materialized"))?;
1749 assert!(pid_alive(child_pid), "background child should be running before drop");
1750
1751 drop(shell);
1752
1753 let mut gone = false;
1755 for _ in 0..150 {
1756 if !pid_alive(child_pid) {
1757 gone = true;
1758 break;
1759 }
1760 thread::sleep(Duration::from_millis(20));
1761 }
1762 assert!(gone, "background child {child_pid} survived shell teardown (orphan leak)");
1763 Ok(())
1764 }
1765}