Skip to main content

orchflow_terminal/
pty_manager.rs

1// Clean PTY Manager Implementation
2//
3// Uses blocking thread pool for PTY operations to avoid Send/Sync issues
4
5use bytes::Bytes;
6use portable_pty::{native_pty_system, CommandBuilder, PtySize};
7use std::collections::HashMap;
8use std::io::{Read, Write};
9use std::sync::Arc;
10use tokio::sync::{broadcast, mpsc, Mutex};
11
12#[derive(Clone)]
13pub struct PtyHandle {
14    pub id: String,
15    pub output_tx: broadcast::Sender<Bytes>,
16    input_tx: mpsc::UnboundedSender<Bytes>,
17    control_tx: mpsc::UnboundedSender<ControlMsg>,
18    process_info_tx: mpsc::UnboundedSender<ProcessInfoRequest>,
19}
20
21impl Drop for PtyHandle {
22    fn drop(&mut self) {
23        // Send shutdown signal when handle is dropped
24        let _ = self.control_tx.send(ControlMsg::Shutdown);
25    }
26}
27
28impl PtyHandle {
29    /// Send input to the terminal
30    pub async fn send_input(&self, data: Bytes) -> Result<(), String> {
31        self.input_tx
32            .send(data)
33            .map_err(|_| format!("Terminal {} input channel closed", self.id))
34    }
35
36    /// Resize the terminal
37    pub async fn resize(&self, rows: u16, cols: u16) -> Result<(), String> {
38        // Validate dimensions
39        if rows == 0 || cols == 0 {
40            return Err(format!("Invalid terminal dimensions: {rows}x{cols}"));
41        }
42        if rows > 1000 || cols > 1000 {
43            return Err(format!("Terminal dimensions too large: {rows}x{cols}"));
44        }
45
46        let size = PtySize {
47            rows,
48            cols,
49            pixel_width: 0,
50            pixel_height: 0,
51        };
52
53        self.control_tx
54            .send(ControlMsg::Resize(size))
55            .map_err(|_| format!("Terminal {} control channel closed", self.id))
56    }
57
58    /// Shutdown the terminal
59    pub async fn shutdown(&self) -> Result<(), String> {
60        self.control_tx
61            .send(ControlMsg::Shutdown)
62            .map_err(|_| format!("Terminal {} control channel closed", self.id))
63    }
64
65    /// Get process information
66    pub async fn get_process_info(&self) -> Result<ProcessInfo, String> {
67        let (tx, rx) = tokio::sync::oneshot::channel();
68        self.process_info_tx
69            .send(ProcessInfoRequest(tx))
70            .map_err(|_| "Process info channel closed".to_string())?;
71        rx.await
72            .map_err(|_| "Failed to get process info".to_string())
73    }
74}
75
76enum ControlMsg {
77    Resize(PtySize),
78    Shutdown,
79}
80
81struct ProcessInfoRequest(tokio::sync::oneshot::Sender<ProcessInfo>);
82
83#[derive(Debug, Clone, serde::Serialize)]
84pub struct ProcessInfo {
85    pub pid: u32,
86    pub name: String,
87    pub command: String,
88    pub status: ProcessStatus,
89}
90
91#[derive(Debug, Clone, PartialEq, serde::Serialize)]
92pub enum ProcessStatus {
93    Running,
94    Exited(i32),
95    Crashed,
96}
97
98pub struct PtyManager {
99    handles: Arc<Mutex<HashMap<String, broadcast::Sender<Bytes>>>>,
100}
101
102impl Default for PtyManager {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108impl PtyManager {
109    pub fn new() -> Self {
110        Self {
111            handles: Arc::new(Mutex::new(HashMap::new())),
112        }
113    }
114
115    pub async fn create_pty(
116        &self,
117        terminal_id: String,
118        shell: Option<String>,
119        rows: u16,
120        cols: u16,
121    ) -> Result<PtyHandle, Box<dyn std::error::Error>> {
122        // Validate dimensions
123        if rows == 0 || cols == 0 {
124            return Err(format!("Invalid terminal dimensions: {rows}x{cols}").into());
125        }
126        if rows > 1000 || cols > 1000 {
127            return Err(format!("Terminal dimensions too large: {rows}x{cols}").into());
128        }
129        let size = PtySize {
130            rows,
131            cols,
132            pixel_width: 0,
133            pixel_height: 0,
134        };
135
136        // Create channels
137        let (output_tx, _output_rx) = broadcast::channel(1024);
138        let (input_tx, input_rx) = mpsc::unbounded_channel();
139        let (control_tx, control_rx) = mpsc::unbounded_channel();
140        let (process_info_tx, process_info_rx) = mpsc::unbounded_channel();
141
142        // Store output sender
143        self.handles
144            .lock()
145            .await
146            .insert(terminal_id.clone(), output_tx.clone());
147
148        // Spawn PTY in blocking thread
149        let terminal_id_clone = terminal_id.clone();
150        let handles_clone = self.handles.clone();
151        let output_tx_clone = output_tx.clone();
152
153        tokio::task::spawn_blocking(move || {
154            run_pty_blocking(
155                terminal_id_clone,
156                shell,
157                size,
158                output_tx_clone,
159                input_rx,
160                control_rx,
161                process_info_rx,
162                handles_clone,
163            )
164        });
165
166        Ok(PtyHandle {
167            id: terminal_id,
168            output_tx,
169            input_tx,
170            control_tx,
171            process_info_tx,
172        })
173    }
174
175    pub async fn close_pty(&self, terminal_id: &str) -> Result<(), Box<dyn std::error::Error>> {
176        self.handles.lock().await.remove(terminal_id);
177        Ok(())
178    }
179}
180
181#[allow(clippy::too_many_arguments)]
182fn run_pty_blocking(
183    terminal_id: String,
184    shell: Option<String>,
185    size: PtySize,
186    output_tx: broadcast::Sender<Bytes>,
187    mut input_rx: mpsc::UnboundedReceiver<Bytes>,
188    mut control_rx: mpsc::UnboundedReceiver<ControlMsg>,
189    mut process_info_rx: mpsc::UnboundedReceiver<ProcessInfoRequest>,
190    handles: Arc<Mutex<HashMap<String, broadcast::Sender<Bytes>>>>,
191) {
192    // Create PTY system
193    let pty_system = native_pty_system();
194
195    // Create PTY pair
196    let pty_pair = match pty_system.openpty(size) {
197        Ok(pair) => pair,
198        Err(e) => {
199            eprintln!("Failed to create PTY: {e}");
200            return;
201        }
202    };
203
204    // Determine shell
205    let mut shell_path = shell.unwrap_or_else(|| {
206        if cfg!(windows) {
207            "powershell.exe".to_string()
208        } else {
209            std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string())
210        }
211    });
212
213    // Validate shell path exists
214    if !std::path::Path::new(&shell_path).exists() {
215        eprintln!("Shell not found: {shell_path}, falling back to default");
216        // Fall back to a known shell
217        shell_path = if cfg!(windows) {
218            "cmd.exe".to_string()
219        } else {
220            "/bin/sh".to_string()
221        };
222    }
223
224    // Build command
225    let mut cmd = CommandBuilder::new(&shell_path);
226    cmd.cwd(std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("/")));
227
228    // Store shell info for process info
229    let shell_name = std::path::Path::new(&shell_path)
230        .file_name()
231        .unwrap_or_default()
232        .to_string_lossy()
233        .to_string();
234
235    // Spawn shell
236    let mut child = match pty_pair.slave.spawn_command(cmd) {
237        Ok(child) => child,
238        Err(e) => {
239            eprintln!("Failed to spawn shell: {e}");
240            return;
241        }
242    };
243
244    // Get reader and writer
245    let mut reader = match pty_pair.master.try_clone_reader() {
246        Ok(r) => r,
247        Err(e) => {
248            eprintln!("Failed to clone reader: {e}");
249            return;
250        }
251    };
252
253    let mut writer = match pty_pair.master.take_writer() {
254        Ok(w) => w,
255        Err(e) => {
256            eprintln!("Failed to take writer: {e}");
257            return;
258        }
259    };
260
261    // Note: We handle blocking with WouldBlock error instead of set_non_blocking
262
263    let mut buf = vec![0u8; 4096];
264
265    // Main event loop
266    loop {
267        // Check for input
268        match input_rx.try_recv() {
269            Ok(data) => {
270                if writer.write_all(&data).is_err() {
271                    break;
272                }
273                let _ = writer.flush();
274            }
275            Err(mpsc::error::TryRecvError::Empty) => {}
276            Err(mpsc::error::TryRecvError::Disconnected) => break,
277        }
278
279        // Check for control messages
280        match control_rx.try_recv() {
281            Ok(ControlMsg::Resize(size)) => {
282                let _ = pty_pair.master.resize(size);
283            }
284            Ok(ControlMsg::Shutdown) => break,
285            Err(mpsc::error::TryRecvError::Empty) => {}
286            Err(mpsc::error::TryRecvError::Disconnected) => break,
287        }
288
289        // Check for process info requests
290        match process_info_rx.try_recv() {
291            Ok(ProcessInfoRequest(tx)) => {
292                let pid = child.process_id().unwrap_or(0);
293                let info = ProcessInfo {
294                    pid,
295                    name: shell_name.clone(),
296                    command: shell_path.clone(),
297                    status: ProcessStatus::Running,
298                };
299                let _ = tx.send(info);
300            }
301            Err(mpsc::error::TryRecvError::Empty) => {}
302            Err(mpsc::error::TryRecvError::Disconnected) => break,
303        }
304
305        // Read output
306        match reader.read(&mut buf) {
307            Ok(0) => break, // EOF
308            Ok(n) => {
309                let data = Bytes::copy_from_slice(&buf[..n]);
310                if output_tx.send(data).is_err() {
311                    // No receivers, but continue running
312                }
313            }
314            Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
315                // No data available, sleep briefly
316                std::thread::sleep(std::time::Duration::from_millis(10));
317            }
318            Err(_) => break,
319        }
320
321        // Check if child is still running
322        match child.try_wait() {
323            Ok(Some(_)) => break, // Process exited
324            Ok(None) => {}        // Still running
325            Err(_) => break,
326        }
327    }
328
329    // Cleanup - ensure child process is terminated
330    if let Err(e) = child.kill() {
331        eprintln!("Failed to kill child process for terminal {terminal_id}: {e}");
332    }
333    // Wait for child to exit to avoid zombies
334    let _ = child.wait();
335
336    // Remove from handles
337    let rt = tokio::runtime::Handle::try_current();
338    if let Ok(handle) = rt {
339        handle.spawn(async move {
340            handles.lock().await.remove(&terminal_id);
341        });
342    }
343}