1use hashbrown::HashMap;
2use std::io::Read;
3use std::path::{Path, PathBuf};
4use std::sync::{
5 Arc, Weak,
6 atomic::{AtomicBool, AtomicU64, Ordering},
7 mpsc,
8};
9use std::thread;
10use std::time::{Duration, Instant};
11
12use anyhow::{Context, Result, anyhow};
13use chrono::Utc;
14use parking_lot::{Mutex, RwLock};
15use portable_pty::{CommandBuilder, PtySize, native_pty_system};
16use sha2::{Digest, Sha256};
17use shell_words::join;
18use tokio::io::AsyncWriteExt;
19use tokio::sync::Mutex as TokioMutex;
20use tokio::sync::mpsc as tokio_mpsc;
21use tracing::{debug, info, warn};
22
23use super::command_utils::{
24 is_long_running_command, is_long_running_command_string, is_sandbox_wrapper_program, is_shell_program,
25};
26use super::formatting::sanitize_session_id;
27use super::manager_utils::{clamp_timeout, exit_status_code, set_command_environment_with_sandbox};
28
29use super::screen_backend::PtyScreenState;
30use super::scrollback::PtyScrollback;
31use super::session::PtySessionHandle;
32use super::types::{PtyCommandRequest, PtyCommandResult};
33
34use once_cell::sync::Lazy;
35
36static WORKSPACE_COMMAND_LOCKS: Lazy<Mutex<HashMap<PathBuf, Weak<tokio::sync::Mutex<()>>>>> =
45 Lazy::new(|| Mutex::new(HashMap::new()));
46
47fn get_command_lock(workspace_root: &Path) -> Arc<tokio::sync::Mutex<()>> {
49 let mut locks = WORKSPACE_COMMAND_LOCKS.lock();
50 let key = workspace_root.to_path_buf();
51 if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) {
52 return lock;
53 }
54 let lock = Arc::new(tokio::sync::Mutex::new(()));
55 locks.insert(key, Arc::downgrade(&lock));
56 lock
57}
58
59const THREAD_JOIN_GRACE_PERIOD_MS: u64 = 500;
61
62const PTY_OUTPUT_TRUNCATED_MARKER: &[u8] = b"\n[... output truncated: capture limit reached ...]";
63
64fn read_pty_output_capped(mut reader: impl Read, max_bytes: usize) -> Result<Vec<u8>> {
66 let mut buffer = [0u8; 4096];
67 let mut collected = Vec::new();
68 let mut truncated = false;
69
70 loop {
71 match reader.read(&mut buffer) {
72 Ok(0) => break,
73 Ok(bytes_read) => {
74 let room = max_bytes.saturating_sub(collected.len());
75 if bytes_read > room {
76 truncated = true;
77 }
78 collected.extend_from_slice(&buffer[..bytes_read.min(room)]);
79 }
80 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
81 Err(error) => return Err(error).context("failed to read PTY command output"),
82 }
83 }
84
85 if truncated {
86 collected.extend_from_slice(PTY_OUTPUT_TRUNCATED_MARKER);
87 }
88 Ok(collected)
89}
90
91use crate::audit::PermissionAuditLog;
92use crate::config::{CommandsConfig, PtyConfig};
93use crate::telemetry::perf;
94use crate::tools::exec_session::create_live_spool_file;
95use crate::tools::output_spooler::{SpoolIntegrity, SpoolLineCounter, encode_digest_hex};
96use crate::tools::path_env;
97use crate::tools::shell::resolve_fallback_shell;
98use crate::tools::types::VTCodePtySession;
99use crate::utils::gatekeeper;
100use crate::utils::path::ensure_path_within_workspace;
101use crate::utils::unicode_monitor::UNICODE_MONITOR;
102use vtcode_commons::canonicalize;
103
104mod session_ops;
105
106#[derive(Clone)]
107pub struct PtyManager {
108 workspace_root: PathBuf,
109 config: PtyConfig,
110 inner: Arc<PtyState>,
111 audit_log: Option<Arc<TokioMutex<PermissionAuditLog>>>,
112 extra_paths: Arc<RwLock<Arc<Vec<PathBuf>>>>,
113}
114
115#[derive(Default)]
116struct PtyState {
117 sessions: Mutex<HashMap<String, Arc<PtySessionHandle>>>,
118}
119
120impl PtyManager {
121 pub fn new(workspace_root: PathBuf, config: PtyConfig) -> Self {
122 let resolved_root = canonicalize(&workspace_root).unwrap_or_else(|_| workspace_root.clone());
123
124 let default_paths =
125 path_env::compute_extra_search_paths(&CommandsConfig::default().extra_path_entries, &resolved_root);
126
127 Self {
128 workspace_root: resolved_root,
129 config,
130 inner: Arc::new(PtyState::default()),
131 audit_log: None,
132 extra_paths: Arc::new(RwLock::new(Arc::new(default_paths))),
133 }
134 }
135
136 pub fn with_audit_log(mut self, audit_log: Arc<TokioMutex<PermissionAuditLog>>) -> Self {
137 self.audit_log = Some(audit_log);
138 self
139 }
140
141 pub fn config(&self) -> &PtyConfig {
142 &self.config
143 }
144
145 pub fn apply_commands_config(&self, commands_config: &CommandsConfig) {
146 let new_paths = path_env::compute_extra_search_paths(&commands_config.extra_path_entries, &self.workspace_root);
147 *self.extra_paths.write() = Arc::new(new_paths);
148 }
149
150 pub fn describe_working_dir(&self, path: &Path) -> String {
151 self.format_working_dir(path)
152 }
153
154 pub async fn run_command(&self, mut request: PtyCommandRequest) -> Result<PtyCommandResult> {
155 if request.command.is_empty() {
156 return Err(anyhow!("PTY command cannot be empty"));
157 }
158
159 let mut command = std::mem::take(&mut request.command);
160 let program = command.remove(0);
161 let args = command;
162 let timeout = clamp_timeout(request.timeout);
163 let work_dir = std::mem::take(&mut request.working_dir);
164 let size = request.size;
165 let start = Instant::now();
166
167 let mut tags = HashMap::new();
168 tags.insert("subsystem".to_string(), "pty".to_string());
169 tags.insert("program".to_string(), program.clone());
170 perf::record_value("vtcode.perf.spawn_count", 1.0, tags);
171
172 gatekeeper::check_quarantine_for_program(&program);
173 self.ensure_within_workspace(&work_dir)?;
174 let workspace_root = self.workspace_root.clone();
175 let extra_paths = self.extra_paths.read().clone();
176 let max_tokens = request.max_tokens;
177 let max_capture_bytes = self.config.max_scrollback_bytes;
178
179 let needs_lock = is_long_running_command(&program)
181 || (is_shell_program(&program) && args.iter().any(|arg| is_long_running_command_string(arg)));
182
183 let command_lock = if needs_lock {
188 Some(get_command_lock(&workspace_root))
189 } else {
190 None
191 };
192 let _command_guard = if let Some(ref lock) = command_lock {
193 debug!(
194 target: "vtcode.pty.command_lock",
195 program = %program,
196 workspace = %workspace_root.display(),
197 "Acquiring per-workspace command lock to serialize long-running invocations"
198 );
199 Some(lock.lock().await)
200 } else {
201 None
202 };
203
204 let result = tokio::task::spawn_blocking(move || -> Result<PtyCommandResult> {
205 let timeout_duration = Duration::from_millis(timeout);
206
207 let (exec_program, exec_args, display_program, _use_shell_wrapper) = if (is_shell_program(&program)
211 && args.iter().any(|arg| arg == "-c" || arg == "/C"))
212 || is_sandbox_wrapper_program(&program, &args)
213 {
214 (program.clone(), args.clone(), program.clone(), false)
216 } else {
217 let shell = resolve_fallback_shell();
218 let full_command = join(std::iter::once(program.clone()).chain(args.iter().cloned()));
219 (shell.clone(), vec!["-lc".to_owned(), full_command.clone()], program.clone(), true)
220 };
221
222 let mut builder = CommandBuilder::new(exec_program.clone());
223 for arg in &exec_args {
224 builder.arg(arg);
225 }
226 builder.cwd(&work_dir);
227 let extra_env = HashMap::new();
228 let sandbox_active = is_sandbox_wrapper_program(&program, &args);
229 set_command_environment_with_sandbox(
230 &mut builder,
231 &display_program,
232 size,
233 &workspace_root,
234 &extra_paths,
235 &extra_env,
236 &HashMap::new(),
237 sandbox_active,
238 );
239
240 let pty_system = native_pty_system();
241 let pair = pty_system.openpty(size).context("failed to allocate PTY pair")?;
242
243 let mut child = pair
244 .slave
245 .spawn_command(builder)
246 .with_context(|| format!("failed to spawn PTY command '{display_program}'"))?;
247 let child_pid = child.process_id();
248 let mut killer = child.clone_killer();
249 drop(pair.slave);
250
251 let reader = pair.master.try_clone_reader().context("failed to clone PTY reader")?;
252
253 let (wait_tx, wait_rx) = mpsc::channel();
254 let wait_thread = thread::spawn(move || {
255 let status = child.wait();
256 let _ = wait_tx.send(());
257 status
258 });
259
260 let reader_thread = thread::spawn(move || read_pty_output_capped(reader, max_capture_bytes));
261
262 let wait_result = match wait_rx.recv_timeout(timeout_duration) {
263 Ok(()) => wait_thread
264 .join()
265 .map_err(|panic| anyhow!("PTY command wait thread panicked: {panic:?}"))?,
266 Err(mpsc::RecvTimeoutError::Timeout) => {
267 if let Some(pid) = child_pid {
271 let _ = vtcode_bash_runner::graceful_kill_process_group_default(pid);
272 } else {
273 let _ = killer.kill();
274 }
275
276 let grace_period = Duration::from_millis(THREAD_JOIN_GRACE_PERIOD_MS);
278 match wait_rx.recv_timeout(grace_period) {
279 Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => {
280 }
283 Err(mpsc::RecvTimeoutError::Timeout) => {
284 warn!(
285 target: "vtcode.pty.timeout",
286 timeout_ms = timeout,
287 grace_ms = THREAD_JOIN_GRACE_PERIOD_MS,
288 "PTY command did not exit within grace period after kill, detaching threads"
289 );
290 drop(wait_thread);
292 drop(reader_thread);
293 return Err(anyhow!(
294 "PTY command timed out after {timeout} milliseconds and did not respond to kill signal"
295 ));
296 }
297 }
298
299 match wait_thread.join() {
301 Ok(result) => {
302 if let Err(error) = result {
303 warn!(
304 target: "vtcode.pty.timeout",
305 error = %error,
306 "PTY command wait error after timeout"
307 );
308 }
309 }
310 Err(panic) => {
311 warn!(
312 target: "vtcode.pty.timeout",
313 "PTY wait thread panicked: {:?}",
314 panic
315 );
316 }
317 }
318
319 let reader_handle = thread::spawn(move || reader_thread.join());
323 match reader_handle.join() {
324 Ok(Ok(Ok(_))) => {}
325 Ok(Ok(Err(e))) => {
326 warn!(
327 target: "vtcode.pty.timeout",
328 error = %e,
329 "PTY reader error after timeout"
330 );
331 }
332 Ok(Err(panic)) => {
333 warn!(
334 target: "vtcode.pty.timeout",
335 "PTY reader thread panicked: {:?}",
336 panic
337 );
338 }
339 Err(_) => {
340 warn!(
341 target: "vtcode.pty.timeout",
342 "Failed to join PTY reader thread wrapper"
343 );
344 }
345 }
346
347 return Err(anyhow!("PTY command timed out after {timeout} milliseconds"));
348 }
349 Err(mpsc::RecvTimeoutError::Disconnected) => {
350 let grace_period = Duration::from_millis(THREAD_JOIN_GRACE_PERIOD_MS);
353
354 let wait_wrapper = thread::spawn(move || wait_thread.join());
356 thread::sleep(grace_period);
357 if wait_wrapper.is_finished() {
358 match wait_wrapper.join() {
359 Ok(Ok(result)) => {
360 if let Err(error) = result {
361 return Err(error)
362 .context("failed to wait for PTY command after channel disconnect");
363 }
364 }
365 Ok(Err(panic)) => {
366 return Err(anyhow!("PTY wait thread panicked: {panic:?}"));
367 }
368 Err(_) => {
369 return Err(anyhow!("PTY wait channel disconnected and thread join failed"));
370 }
371 }
372 } else {
373 warn!(
374 target: "vtcode.pty.disconnect",
375 "PTY wait thread did not exit within grace period, detaching"
376 );
377 drop(reader_thread);
378 return Err(anyhow!("PTY command wait channel disconnected unexpectedly"));
379 }
380
381 match reader_thread.join() {
383 Ok(Ok(_)) => {}
384 Ok(Err(e)) => {
385 warn!(
386 target: "vtcode.pty.disconnect",
387 error = %e,
388 "PTY reader error after channel disconnect"
389 );
390 }
391 Err(panic) => {
392 warn!(
393 target: "vtcode.pty.disconnect",
394 "PTY reader panicked: {:?}",
395 panic
396 );
397 }
398 }
399
400 return Err(anyhow!("PTY command wait channel disconnected unexpectedly"));
401 }
402 };
403
404 let status = wait_result.context("failed to wait for PTY command to exit")?;
405
406 let output_bytes = reader_thread
407 .join()
408 .map_err(|panic| anyhow!("PTY command reader thread panicked: {panic:?}"))?
409 .context("failed to read PTY command output")?;
410 let mut output =
413 String::from_utf8(output_bytes).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned());
414 let exit_code = exit_status_code(status);
415
416 if let Some(max_tokens) = max_tokens {
418 if max_tokens > 0 {
419 if output.len() > max_tokens * 4 {
421 let truncate_point = (max_tokens * 4).min(output.len());
422 output.truncate(truncate_point);
423 output.push_str("\n[... truncated by max_tokens ...]");
424 }
425 } else {
426 }
428 }
429 Ok(PtyCommandResult {
432 exit_code,
433 output,
434 duration: start.elapsed(),
435 size,
436 applied_max_tokens: max_tokens,
437 })
438 })
439 .await
440 .context("failed to join PTY command task")??;
441
442 Ok(result)
443 }
444
445 pub(crate) fn working_dir_candidate(&self, requested: Option<&str>) -> Result<PathBuf> {
447 let requested = match requested {
448 Some(dir) if !dir.trim().is_empty() => dir.trim(),
449 _ => return Ok(self.workspace_root.clone()),
450 };
451
452 let candidate = self.workspace_root.join(requested);
453 ensure_path_within_workspace(&candidate, &self.workspace_root)
454 .map_err(|e| anyhow!("Working directory '{}' escapes the workspace root: {e}", candidate.display()))
455 }
456
457 pub async fn resolve_working_dir(&self, requested: Option<&str>) -> Result<PathBuf> {
458 let normalized = self.working_dir_candidate(requested)?;
459 if requested.is_none_or(|dir| dir.trim().is_empty()) {
460 return Ok(normalized);
461 }
462 let candidate = &normalized;
463 vtcode_commons::paths::ensure_path_within_workspace_resolved(&normalized, &self.workspace_root)
466 .await
467 .map_err(|e| anyhow!("Working directory '{}' escapes the workspace root: {e}", candidate.display()))?;
468 let metadata = tokio::fs::metadata(&normalized)
469 .await
470 .with_context(|| format!("Working directory '{}' does not exist", normalized.display()))?;
471 if !metadata.is_dir() {
472 return Err(anyhow!("Working directory '{}' is not a directory", normalized.display()));
473 }
474 Ok(normalized)
475 }
476
477 pub fn create_session(
478 &self,
479 session_id: String,
480 command: Vec<String>,
481 working_dir: PathBuf,
482 size: PtySize,
483 ) -> Result<VTCodePtySession> {
484 self.create_session_with_bridge(session_id, command, working_dir, size, HashMap::new(), None)
485 }
486
487 fn create_session_with_bridge(
488 &self,
489 session_id: String,
490 command: Vec<String>,
491 working_dir: PathBuf,
492 size: PtySize,
493 extra_env: HashMap<String, String>,
494 zsh_exec_bridge: Option<crate::zsh_exec_bridge::ZshExecBridgeSession>,
495 ) -> Result<VTCodePtySession> {
496 self.create_session_with_bridge_sandboxed(
497 session_id,
498 command,
499 working_dir,
500 size,
501 extra_env,
502 zsh_exec_bridge,
503 HashMap::new(),
504 false,
505 )
506 }
507
508 pub(crate) fn create_session_with_bridge_sandboxed(
509 &self,
510 session_id: String,
511 command: Vec<String>,
512 working_dir: PathBuf,
513 size: PtySize,
514 extra_env: HashMap<String, String>,
515 zsh_exec_bridge: Option<crate::zsh_exec_bridge::ZshExecBridgeSession>,
516 trusted_env: HashMap<String, String>,
517 sandbox_active: bool,
518 ) -> Result<VTCodePtySession> {
519 if command.is_empty() {
520 return Err(anyhow!(
521 "PTY session command cannot be empty.\n\
522 This is an internal error - command validation should have caught this earlier.\n\
523 Please report this with the command-session parameters used."
524 ));
525 }
526
527 let mut sessions = self.inner.sessions.lock();
529 use hashbrown::hash_map::Entry;
530 let entry = match sessions.entry(session_id.clone()) {
531 Entry::Occupied(_) => {
532 return Err(anyhow!("PTY session '{session_id}' already exists"));
533 }
534 Entry::Vacant(e) => e,
535 };
536
537 let mut command_parts = command;
538 let program = command_parts.remove(0);
539 let args = command_parts;
540 let extra_paths = self.extra_paths.read().clone();
541
542 let (exec_program, exec_args, display_program) = if (is_shell_program(&program)
546 && args.iter().any(|arg| arg == "-c" || arg == "/C"))
547 || is_sandbox_wrapper_program(&program, &args)
548 {
549 (program.clone(), args.clone(), program.clone())
551 } else {
552 let shell = resolve_fallback_shell();
553 let full_command = join(std::iter::once(program.clone()).chain(args.iter().cloned()));
554
555 if full_command.is_empty() {
557 return Err(anyhow!("Failed to construct command string from program '{program}' and args {args:?}"));
558 }
559
560 (shell.clone(), vec!["-lc".to_owned(), full_command.clone()], program.clone())
561 };
562
563 let pty_system = native_pty_system();
564 let pair = pty_system.openpty(size).context("failed to allocate PTY pair")?;
565
566 let mut builder = CommandBuilder::new(exec_program.clone());
567 for arg in &exec_args {
568 builder.arg(arg);
569 }
570 builder.cwd(&working_dir);
571 self.ensure_within_workspace(&working_dir)?;
572 set_command_environment_with_sandbox(
573 &mut builder,
574 &display_program,
575 size,
576 &self.workspace_root,
577 &extra_paths,
578 &extra_env,
579 &trusted_env,
580 sandbox_active,
581 );
582
583 let child = pair
584 .slave
585 .spawn_command(builder)
586 .with_context(|| format!("failed to spawn PTY session command '{display_program}'"))?;
587
588 let child_pid = child.process_id();
590
591 drop(pair.slave);
592
593 let master = pair.master;
594 let mut reader = master.try_clone_reader().context("failed to clone PTY reader")?;
595 let writer = master.take_writer().context("failed to take PTY writer")?;
596
597 let screen_state = Arc::new(Mutex::new(PtyScreenState::new(size, self.config.scrollback_lines)));
598 let scrollback =
599 Arc::new(Mutex::new(PtyScrollback::new(self.config.scrollback_lines, self.config.max_scrollback_bytes)));
600
601 let (output_spool_relative_path, output_spool_file) =
602 create_live_spool_file(&self.workspace_root, &sanitize_session_id(&session_id));
603 let output_spool_path = output_spool_relative_path.to_string_lossy().replace('\\', "/");
604 let (output_spool_tx, mut output_spool_rx) = tokio_mpsc::channel::<Vec<u8>>(64);
605 let output_total_bytes = Arc::new(AtomicU64::new(0));
606 let output_total_bytes_for_reader = Arc::clone(&output_total_bytes);
607 let output_spool_failed = Arc::new(AtomicBool::new(false));
608 let output_spool_failed_for_task = Arc::clone(&output_spool_failed);
609 let output_spool_ready = Arc::new(AtomicBool::new(false));
610 let output_spool_ready_for_task = Arc::clone(&output_spool_ready);
611 let output_spool_finished = Arc::new(AtomicBool::new(false));
612 let output_spool_finished_for_task = Arc::clone(&output_spool_finished);
613 let output_spool_integrity = Arc::new(Mutex::new(None));
614 let output_spool_integrity_for_task = Arc::clone(&output_spool_integrity);
615
616 let output_spool_task = async move {
617 let mut spool_file = output_spool_file.map(tokio::fs::File::from_std);
621
622 if spool_file.is_none() {
623 output_spool_failed_for_task.store(true, Ordering::Release);
624 } else {
625 output_spool_ready_for_task.store(true, Ordering::Release);
626 }
627 let mut spool_redactor = vtcode_commons::sanitizer::StreamingSecretRedactor::default();
628 let mut spool_hasher = Sha256::new();
629 let mut spool_byte_count = 0_u64;
630 let mut spool_lines = SpoolLineCounter::default();
631
632 while let Some(chunk) = output_spool_rx.recv().await {
633 let Some(file) = spool_file.as_mut() else {
634 continue;
635 };
636 let text = String::from_utf8_lossy(&chunk);
637 let sanitized = spool_redactor.push(&text);
638 if !sanitized.is_empty() {
639 if file.write_all(sanitized.as_bytes()).await.is_err() {
640 output_spool_failed_for_task.store(true, Ordering::Release);
641 spool_file = None;
642 } else {
643 spool_hasher.update(sanitized.as_bytes());
644 spool_lines.append(sanitized.as_bytes());
645 spool_byte_count = spool_byte_count.saturating_add(sanitized.len() as u64);
646 }
647 }
648 }
649
650 if let Some(file) = spool_file.as_mut() {
651 let sanitized = spool_redactor.finish();
652 let write_failed = !sanitized.is_empty() && file.write_all(sanitized.as_bytes()).await.is_err();
653 if !write_failed && !sanitized.is_empty() {
654 spool_hasher.update(sanitized.as_bytes());
655 spool_lines.append(sanitized.as_bytes());
656 spool_byte_count = spool_byte_count.saturating_add(sanitized.len() as u64);
657 }
658 if write_failed || file.sync_all().await.is_err() {
662 output_spool_failed_for_task.store(true, Ordering::Release);
663 } else {
664 *output_spool_integrity_for_task.lock() = Some(SpoolIntegrity {
665 byte_count: spool_byte_count,
666 line_count: spool_lines.line_count(),
667 sha256: encode_digest_hex(spool_hasher.finalize()),
668 });
669 }
670 }
671 output_spool_finished_for_task.store(true, Ordering::Release);
672 };
673
674 if let Ok(handle) = tokio::runtime::Handle::try_current() {
675 handle.spawn(output_spool_task);
676 } else {
677 output_spool_failed.store(true, Ordering::Release);
678 output_spool_finished.store(true, Ordering::Release);
679 }
680
681 debug!(
682 session_id = %session_id,
683 rows = size.rows,
684 cols = size.cols,
685 "Created PTY session"
686 );
687 let screen_state_clone = Arc::clone(&screen_state);
688 let scrollback_clone = Arc::clone(&scrollback);
689 let reader_completed = Arc::new(AtomicBool::new(false));
690 let reader_completed_clone = Arc::clone(&reader_completed);
691 let session_name = session_id.clone();
692 let output_spool_failed_for_reader = Arc::clone(&output_spool_failed);
693 let mut output_spool_tx_for_reader = Some(output_spool_tx);
694 UNICODE_MONITOR.start_session();
696
697 let reader_thread = thread::Builder::new()
698 .name(format!("vtcode-pty-reader-{session_name}"))
699 .spawn(move || {
700 let mut buffer = [0u8; 8192]; let mut utf8_buffer: Vec<u8> = Vec::with_capacity(8192); let mut total_bytes = 0usize;
703 let mut unicode_detection_hits = 0usize;
704
705 loop {
706 match reader.read(&mut buffer) {
707Ok(0) => {
708 if !utf8_buffer.is_empty() {
709 let mut scrollback = scrollback_clone.lock();
710 scrollback.push_utf8(&mut utf8_buffer, true);
711 }
712 debug!("PTY session '{}' reader reached EOF (processed {} bytes, {} unicode detections)",
713 session_name, total_bytes, unicode_detection_hits);
714 break;
715}
716 Ok(bytes_read) => {
717 let chunk = &buffer[..bytes_read];
718 total_bytes += bytes_read;
719 output_total_bytes_for_reader.fetch_add(bytes_read as u64, Ordering::Relaxed);
720 let send_result = output_spool_tx_for_reader
721 .as_ref()
722 .map(|sender| sender.blocking_send(chunk.to_vec()));
723 if matches!(send_result, Some(Err(_))) {
724 output_spool_failed_for_reader.store(true, Ordering::Release);
725 warn!(
726 "PTY session '{}' spool receiver closed after {} bytes; retaining screen and scrollback output",
727 session_name, total_bytes
728 );
729 output_spool_tx_for_reader = None;
730 }
731
732 let likely_unicode = chunk.iter().any(|&b| b >= 0x80);
734 if likely_unicode {
735 unicode_detection_hits += 1;
736 }
737
738 {
739 let mut screen_state = screen_state_clone.lock();
740 screen_state.process(chunk);
741 }
742
743 utf8_buffer.extend_from_slice(chunk);
744 {
745 let mut scrollback = scrollback_clone.lock();
746 scrollback.push_utf8(&mut utf8_buffer, false);
747 }
748
749 if utf8_buffer.capacity() > 32768 && utf8_buffer.len() < 1024 {
751 utf8_buffer.shrink_to_fit();
752 }
753}
754Err(error) => {
755 warn!("PTY session '{}' reader error: {} (processed {} bytes)",
756 session_name, error, total_bytes);
757 break;
758}
759 }
760 }
761 debug!("PTY session '{}' reader thread finished (total: {} bytes, unicode detections: {})",
762 session_name, total_bytes, unicode_detection_hits);
763
764 reader_completed_clone.store(true, Ordering::Release);
769
770 UNICODE_MONITOR.end_session();
772
773 if unicode_detection_hits > 0 {
775 let scrollback = scrollback_clone.lock();
776 let metrics = scrollback.metrics();
777 if metrics.unicode_errors > 0 {
778warn!("PTY session '{}' had {} unicode errors during processing",
779 session_name, metrics.unicode_errors);
780 }
781 if metrics.total_unicode_chars > 0 {
782info!("PTY session '{}' processed {} unicode characters across {} sessions with {} buffer remainder",
783 session_name, metrics.total_unicode_chars, metrics.unicode_sessions, metrics.utf8_buffer_size);
784 }
785 }
786 })
787 .context("failed to spawn PTY reader thread")?;
788
789 let metadata = VTCodePtySession {
790 id: session_id.clone(),
791 command: program,
792 args,
793 working_dir: Some(self.format_working_dir(&working_dir)),
794 rows: size.rows,
795 cols: size.cols,
796 child_pid,
797 started_at: Some(Utc::now()),
798 lifecycle_state: Some(crate::tools::types::VTCodeSessionLifecycleState::Running),
799 exit_code: None,
800 screen_contents: None,
801 scrollback: None,
802 };
803
804 entry.insert(Arc::new(PtySessionHandle {
806 master: Mutex::new(master),
807 child: Mutex::new(child),
808 child_pid,
809 writer: Mutex::new(Some(writer)),
810 screen_state,
811 scrollback,
812 reader_thread: Mutex::new(Some(reader_thread)),
813 reader_completed,
814 metadata: metadata.clone(),
815 last_input: Mutex::new(None),
816 _zsh_exec_bridge: zsh_exec_bridge,
817 output_total_bytes,
818 output_spool_failed,
819 output_spool_ready,
820 output_spool_finished,
821 output_spool_path,
822 output_spool_integrity,
823 }));
824
825 Ok(metadata)
826 }
827
828 fn format_working_dir(&self, path: &Path) -> String {
829 match path.strip_prefix(&self.workspace_root) {
830 Ok(relative) if relative.as_os_str().is_empty() => ".".into(),
831 Ok(relative) => relative.to_string_lossy().replace("\\", "/"),
832 Err(_) => path.to_string_lossy().into_owned(),
833 }
834 }
835
836 fn ensure_within_workspace(&self, candidate: &Path) -> Result<()> {
837 ensure_path_within_workspace(candidate, &self.workspace_root).map(|_| ())
838 }
839}
840
841#[cfg(test)]
842mod tests;