use std::io::{Read, Write}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; use portable_pty::{native_pty_system, ChildKiller, MasterPty, PtySize}; use tauri::ipc::{Channel, Response}; use super::shell_init; const POLL_INTERVAL: Duration = Duration::from_millis(4); const BURST_MAX_AGE: Duration = Duration::from_millis(16); const BURST_THRESHOLD: usize = 64 * 1024; const READ_BUF: usize = 16 * 1024; // Cap on buffered-but-not-yet-flushed bytes. On overflow we discard the // entire pending buffer and emit an SGR-reset + notice in its place. // Dropping a partial prefix would slice a CSI sequence in half and corrupt // xterm's screen state. 4 MiB is ~1000 full 80x24 screens. const MAX_PENDING: usize = 4 * 1024 * 1024; // Hard reset (ESC c) + dim notice. Written verbatim into the stream when // we're forced to discard backlog. const OVERFLOW_NOTICE: &[u8] = b"\x1bc\x1b[2m[terax: dropped output due to backpressure]\x1b[0m\r\n"; pub struct Session { // Field drop order is intentional. Rust drops fields top-to-bottom: // 1. `_job` — on Windows, closing the Job HANDLE fires // KILL_ON_JOB_CLOSE, terminating the pwsh tree before the master // pipe drops. Without this, ClosePseudoConsole in `master`'s Drop // can block waiting for conhost to drain pending output, freezing // the Tauri worker thread that triggered the close. // 2. `killer` — best-effort kill (redundant on Windows once Job // closed, but harmless and required on Unix where there is no Job). // 3. `writer` — closes the input side of the master pipe. // 4. `master` — last; ClosePseudoConsole on Windows. By now the child // is dead and conhost has nothing left to drain. #[cfg(windows)] _job: Option, pub killer: Mutex>, pub writer: Mutex>, pub master: Mutex>, } impl Drop for Session { fn drop(&mut self) { // If the session Arc is dropped without an explicit pty_close (e.g. // frontend disconnected, window crashed, dev HMR), the reader/flusher // threads would otherwise stay alive forever holding the child. Kill // the child here so the reader hits EOF and the threads unwind. if let Ok(mut k) = self.killer.lock() { let _ = k.kill(); } } } static SPAWN_LOCK: Mutex<()> = Mutex::new(()); pub fn spawn( cols: u16, rows: u16, cwd: Option, on_data: Channel, on_exit: Channel, ) -> Result<(Arc, PtySize), String> { let _spawn_guard = SPAWN_LOCK.lock().unwrap(); let pty_system = native_pty_system(); let size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0, }; let pair = pty_system.openpty(size).map_err(|e| e.to_string())?; let cmd = shell_init::build_command(cwd)?; let mut child = pair.slave.spawn_command(cmd).map_err(|e| e.to_string())?; drop(pair.slave); let killer = child.clone_killer(); let mut reader = pair.master.try_clone_reader().map_err(|e| e.to_string())?; let writer = pair.master.take_writer().map_err(|e| e.to_string())?; #[cfg(windows)] let job = match child.process_id() { Some(pid) => match super::job::PtyJob::create_for(pid) { Ok(j) => Some(j), Err(e) => { log::warn!("pty job-object setup failed for pid={pid}: {e}"); None } }, None => None, }; let session = Arc::new(Session { #[cfg(windows)] _job: job, killer: Mutex::new(killer), writer: Mutex::new(writer), master: Mutex::new(pair.master), }); let pending: Arc>> = Arc::new(Mutex::new(Vec::with_capacity(READ_BUF))); let done = Arc::new(AtomicBool::new(false)); let spawn_at = Instant::now(); let pending_r = pending.clone(); let reader_thread = thread::Builder::new() .name("terax-pty-reader".into()) .spawn(move || { let mut buf = [0u8; READ_BUF]; let mut dropped_bytes: u64 = 0; let mut logged_first = false; loop { match reader.read(&mut buf) { Ok(0) => break, Ok(n) => { if !logged_first { logged_first = true; log::info!("pty first byte after {}ms", spawn_at.elapsed().as_millis()); } let mut g = pending_r.lock().unwrap(); if g.len() + n > MAX_PENDING { // Discard the whole backlog rather than slicing // through escape sequences. Emit a hard reset so // xterm doesn't carry stale SGR/cursor state. dropped_bytes += g.len() as u64; g.clear(); g.extend_from_slice(OVERFLOW_NOTICE); } g.extend_from_slice(&buf[..n]); } Err(e) => { // Normal on child exit: the slave fd is closed and // read(2) returns EIO on some platforms. Kept at debug // to avoid noise in the common case. log::debug!("pty reader ended: {e}"); break; } } } if dropped_bytes > 0 { log::warn!("pty backpressure: dropped {dropped_bytes} bytes (cap {MAX_PENDING})"); } }) .expect("spawn pty reader thread"); let on_data_flush = on_data.clone(); let pending_f = pending.clone(); let done_f = done.clone(); thread::Builder::new() .name("terax-pty-flusher".into()) .spawn(move || { let mut idle = true; let mut batch_started = Instant::now(); loop { thread::sleep(POLL_INTERVAL); let chunk = { let mut g = pending_f.lock().unwrap(); if g.is_empty() { idle = true; if done_f.load(Ordering::Acquire) { break; } continue; } if idle { // First chunk after idle: drain now for snappy // prompt/keystroke echo latency. idle = false; } else if g.len() < BURST_THRESHOLD && batch_started.elapsed() < BURST_MAX_AGE { // Still in a burst — keep accumulating. continue; } batch_started = Instant::now(); std::mem::take(&mut *g) }; // Raw bytes — arrives in JS as ArrayBuffer, no base64/JSON. if let Err(e) = on_data_flush.send(Response::new(chunk)) { log::debug!("pty flusher exiting, channel closed: {e}"); break; } } }) .expect("spawn pty flusher thread"); let on_data_exit = on_data; let pending_e = pending; let done_e = done; thread::Builder::new() .name("terax-pty-waiter".into()) .spawn(move || { let code = match child.wait() { Ok(status) => status.exit_code() as i32, Err(e) => { log::warn!("pty child wait failed: {e}"); -1 } }; // Wait for the reader to hit EOF before taking a final snapshot of // `pending`, so the last line of output never races the Exit event. if let Err(e) = reader_thread.join() { log::error!("pty reader thread panicked: {e:?}"); } let tail = std::mem::take(&mut *pending_e.lock().unwrap()); if !tail.is_empty() { if let Err(e) = on_data_exit.send(Response::new(tail)) { log::debug!("pty final-data send failed (channel closed): {e}"); } } done_e.store(true, Ordering::Release); if let Err(e) = on_exit.send(code) { log::debug!("pty exit send failed (channel closed): {e}"); } }) .expect("spawn pty waiter thread"); Ok((session, size)) }