Skip to main content

vtcode_core/tools/pty/
manager.rs

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
36/// Per-workspace command locks to serialize long-running toolchain commands.
37/// Keyed by canonicalized workspace path to prevent lockfile contention.
38/// This is more granular than a global lock - different workspaces can run concurrently.
39///
40/// Entries are held weakly: once the last `Arc` returned by `get_command_lock` is
41/// dropped (the command that held it completes), the entry is collected on the
42/// next lookup, so the map stays bounded by the number of concurrently-held
43/// locks rather than the number of workspaces ever seen.
44static WORKSPACE_COMMAND_LOCKS: Lazy<Mutex<HashMap<PathBuf, Weak<tokio::sync::Mutex<()>>>>> =
45    Lazy::new(|| Mutex::new(HashMap::new()));
46
47/// Get or create a command lock for the given workspace root
48fn 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
59/// Grace period to wait for threads to exit after killing the process (ms)
60const THREAD_JOIN_GRACE_PERIOD_MS: u64 = 500;
61
62const PTY_OUTPUT_TRUNCATED_MARKER: &[u8] = b"\n[... output truncated: capture limit reached ...]";
63
64/// Reads to EOF but retains at most `max_bytes`; the surplus is still drained so the child never blocks on a full PTY.
65fn 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        // Determine if this command needs serialization to avoid contention
180        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        // Acquire per-workspace lock if needed to prevent lockfile contention.
184        // This prevents "blocking waiting for file lock" errors when the agent
185        // triggers multiple long-running commands before previous ones complete.
186        // Using per-workspace lock allows concurrent commands in different workspaces.
187        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            // Use login shell for command execution to ensure user's PATH and environment
208            // is properly initialized from their shell configuration files (~/.bashrc, ~/.zshrc, etc).
209            // However, we avoid double-wrapping if the command is already a shell invocation.
210            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                // Already a shell command or sandbox wrapper, don't wrap again.
215                (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                    // Kill the process group and the direct child process handle.
268                    // vtcode_bash_runner::graceful_kill_process_group_default now handles
269                    // the robust 'more kills' pattern which ensures descendants do not survive.
270                    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                    // Wait with a grace period - don't hang forever
277                    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                            // Process exited, try to join threads with timeout
281                            // If they don't exit within grace period, detach them
282                        }
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                            // Detach threads by dropping handles - they may leak but we don't hang
291                            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                    // Try to join wait thread (should be quick since process exited)
300                    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                    // Try to join reader thread (may take a moment for PTY to close)
320                    // Use a thread-local timeout via a parking_lot based approach
321                    // For simplicity, just drop the handle if it doesn't complete quickly
322                    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                    // Channel disconnected - process likely crashed
351                    // Try to join with grace period
352                    let grace_period = Duration::from_millis(THREAD_JOIN_GRACE_PERIOD_MS);
353
354                    // Spawn wrapper thread to allow timeout on join
355                    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                    // Also try to get reader output
382                    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            // Fast zero-copy path for valid UTF-8 (the common case); only
411            // allocate a lossy copy when the bytes are actually invalid.
412            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            // Apply max_tokens truncation if specified
417            if let Some(max_tokens) = max_tokens {
418                if max_tokens > 0 {
419                    // Simple byte-based truncation
420                    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                    // Keep original if max_tokens is not valid
427                }
428            }
429            // Keep original if max_tokens is None
430
431            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    /// Lexical cwd shared by launch validation and recovery target lookup.
446    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        // Symlink-aware containment: a symlinked directory inside the
464        // workspace must not move the command cwd outside it.
465        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        // Use entry API to avoid double lookup
528        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        // Use login shell for command execution to ensure user's PATH and environment
543        // is properly initialized from their shell configuration files (~/.bashrc, ~/.zshrc, etc).
544        // However, we avoid double-wrapping if the command is already a shell invocation.
545        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            // Already a shell command, don't wrap again
550            (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            // Verify we have a valid command string
556            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        // Capture the child process ID for process group management
589        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            // Spool files are live-read while the session runs, so every
618            // chunk must reach disk immediately; buffering would hide output
619            // from concurrent readers until `flush`.
620            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                // Spool files are live-read while the session runs, so every
659                // chunk must reach disk immediately; buffering would hide
660                // output from concurrent readers until `flush`.
661                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        // Start unicode monitoring for this session
695        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]; // Increased buffer size for better performance
701                let mut utf8_buffer: Vec<u8> = Vec::with_capacity(8192); // Pre-allocate buffer
702                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    // Quick unicode detection heuristic
733    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    // Periodic buffer cleanup to prevent excessive memory usage
750    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                // Signal that the reader thread has finished pushing all data.
765                // This must be set BEFORE any further scrollback lock acquisitions
766                // so that is_output_drained() sees both pending==false and completed==true
767                // atomically under the scrollback lock.
768                reader_completed_clone.store(true, Ordering::Release);
769
770                // End unicode monitoring for this session
771                UNICODE_MONITOR.end_session();
772
773                // Log unicode statistics if any unicode was detected
774                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        // Use the entry we obtained earlier to insert without additional lookup
805        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;