Skip to main content

oxios_kernel/pty/
session.rs

1//! Per-session PTY state (RFC-038).
2//!
3//! A `PtySession` holds the master PTY handle and bookkeeping. The session
4//! outlives the WebSocket — when the WS closes, the session becomes
5//! `Detached` and may be re-attached (until `max_lifetime_secs` elapses).
6use parking_lot::Mutex;
7use portable_pty::{MasterPty, PtySize as PortablePtySize, native_pty_system};
8use serde::{Deserialize, Serialize};
9use std::sync::Arc;
10use std::time::Instant;
11use tokio::sync::mpsc;
12
13use crate::config::PtyConfig;
14
15use super::error::PtyError;
16
17/// Session id (ULID, time-sortable). Type alias for readability.
18pub type PtySessionId = String;
19
20/// PTY size used at spawn / resize time.
21#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
22pub struct PtySize {
23    pub cols: u16,
24    pub rows: u16,
25    pub pixel_width: u16,
26    pub pixel_height: u16,
27}
28
29impl PtySize {
30    /// Convert to portable-pty's representation.
31    pub fn to_portable(self) -> PortablePtySize {
32        PortablePtySize {
33            rows: self.rows,
34            cols: self.cols,
35            pixel_width: self.pixel_width,
36            pixel_height: self.pixel_height,
37        }
38    }
39
40    /// Default 80×24.
41    pub fn default_80x24() -> Self {
42        Self {
43            cols: 80,
44            rows: 24,
45            pixel_width: 0,
46            pixel_height: 0,
47        }
48    }
49}
50
51/// State of a session.
52pub enum PtySessionState {
53    /// Open and bound to a WebSocket client.
54    Attached {
55        /// Sender for PTY bytes flowing to the WS client.
56        ws_tx: mpsc::Sender<Vec<u8>>,
57    },
58    /// No client attached; orphan, awaiting re-attach or GC.
59    Detached {
60        /// Instant when this state was entered.
61        orphan_since: Instant,
62    },
63    /// Exit code recorded; awaiting GC.
64    Closed {
65        /// Exit code recorded (None if killed by signal).
66        exit_code: Option<i32>,
67        /// Signal that terminated the process (None if exited normally).
68        signal: Option<i32>,
69        /// Instant when this state was entered.
70        at: Instant,
71    },
72}
73
74/// Per-session info exposed to the Web UI (`GET /api/terminal/sessions`).
75#[derive(Debug, Clone, Serialize)]
76pub struct PtySessionInfo {
77    pub id: PtySessionId,
78    pub shell: String,
79    pub created_at_unix_ms: u64,
80    pub last_input_at_unix_ms: u64,
81    pub state: String,
82    pub cols: u16,
83    pub rows: u16,
84}
85
86/// A live PTY session.
87pub struct PtySession {
88    pub id: PtySessionId,
89    pub principal: String,
90    pub shell: String,
91    pub created_at: Instant,
92    pub size: PtySize,
93    /// Last input time as Unix-millis; used for idle GC.
94    pub last_input_ms: parking_lot::Mutex<u64>,
95    /// Live master handle. Drop = kill the child.
96    pub master: Mutex<Option<Box<dyn MasterPty + Send>>>,
97    pub state: Mutex<PtySessionState>,
98}
99
100impl PtySession {
101    /// Construct a new session, spawning the shell via `portable-pty`.
102    pub fn spawn(
103        id: PtySessionId,
104        principal: String,
105        shell: String,
106        size: PtySize,
107        config: &PtyConfig,
108    ) -> Result<Arc<Self>, PtyError> {
109        let pty_system = native_pty_system();
110        let pair = pty_system
111            .openpty(PortablePtySize {
112                rows: size.rows,
113                cols: size.cols,
114                pixel_width: size.pixel_width,
115                pixel_height: size.pixel_height,
116            })
117            .map_err(|e| PtyError::Spawn(e.to_string()))?;
118
119        let mut cmd = portable_pty::CommandBuilder::new(&shell);
120        if let Some(cwd) = &config.working_directory {
121            cmd.cwd(cwd);
122        }
123        cmd.env("TERM", "xterm-256color");
124        cmd.env("COLORTERM", "truecolor");
125        cmd.env("OXIOS_PTY_SESSION", &id);
126        // Strip daemon secrets, then inherit the rest.
127        for (k, v) in std::env::vars() {
128            if config
129                .env_strip_prefixes
130                .iter()
131                .any(|p| k.starts_with(p.as_str()))
132            {
133                continue;
134            }
135            cmd.env(&k, &v);
136        }
137        for (k, v) in &config.extra_env {
138            cmd.env(k, v);
139        }
140
141        let mut child = pair
142            .slave
143            .spawn_command(cmd)
144            .map_err(|e| PtyError::Spawn(e.to_string()))?;
145        drop(pair.slave);
146
147        let now_unix_ms = unix_millis_now();
148        let session = Arc::new(Self {
149            id,
150            principal,
151            shell,
152            created_at: Instant::now(),
153            size,
154            last_input_ms: parking_lot::Mutex::new(now_unix_ms),
155            master: Mutex::new(Some(pair.master)),
156            state: Mutex::new(PtySessionState::Detached {
157                orphan_since: Instant::now(),
158            }),
159        });
160
161        // Wait task: transitions to Closed when child exits.
162        let session_for_wait = Arc::clone(&session);
163        tokio::spawn(async move {
164            let exit = tokio::task::spawn_blocking(move || child.wait()).await;
165            let (code, signal) = match exit {
166                Ok(Ok(status)) => (Some(status.exit_code() as i32), None),
167                Ok(Err(e)) => {
168                    tracing::warn!(error = %e, session = %session_for_wait.id, "pty child wait error");
169                    (None, None)
170                }
171                Err(e) => {
172                    tracing::warn!(error = %e, session = %session_for_wait.id, "pty wait join error");
173                    (None, None)
174                }
175            };
176            let mut st = session_for_wait.state.lock();
177            *st = PtySessionState::Closed {
178                exit_code: code,
179                signal,
180                at: Instant::now(),
181            };
182        });
183
184        Ok(session)
185    }
186
187    /// Touch `last_input_ms` to now.
188    pub fn touch_input(&self) {
189        let mut g = self.last_input_ms.lock();
190        *g = unix_millis_now();
191    }
192
193    /// Compute idle duration in milliseconds.
194    pub fn idle_ms(&self) -> u64 {
195        unix_millis_now().saturating_sub(*self.last_input_ms.lock())
196    }
197
198    /// Lifetime elapsed in milliseconds.
199    pub fn lifetime_ms(&self) -> u64 {
200        self.created_at.elapsed().as_millis() as u64
201    }
202
203    /// Snapshot for the UI listing endpoint.
204    pub fn info(&self) -> PtySessionInfo {
205        let state_label = match &*self.state.lock() {
206            PtySessionState::Attached { .. } => "attached",
207            PtySessionState::Detached { .. } => "detached",
208            PtySessionState::Closed { .. } => "closed",
209        };
210        PtySessionInfo {
211            id: self.id.clone(),
212            shell: self.shell.clone(),
213            created_at_unix_ms: unix_millis_from(self.created_at),
214            last_input_at_unix_ms: *self.last_input_ms.lock(),
215            state: state_label.to_string(),
216            cols: self.size.cols,
217            rows: self.size.rows,
218        }
219    }
220}
221
222/// Drop the session → kills the child process (PTY closes).
223impl Drop for PtySession {
224    fn drop(&mut self) {
225        let _ = self.master.lock().take();
226    }
227}
228
229fn unix_millis_now() -> u64 {
230    use std::time::{SystemTime, UNIX_EPOCH};
231    SystemTime::now()
232        .duration_since(UNIX_EPOCH)
233        .map(|d| d.as_millis() as u64)
234        .unwrap_or(0)
235}
236
237fn unix_millis_from(t: Instant) -> u64 {
238    let now = Instant::now();
239    let delta = now.saturating_duration_since(t);
240    unix_millis_now().saturating_sub(delta.as_millis() as u64)
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn size_default_80x24() {
249        let s = PtySize::default_80x24();
250        assert_eq!(s.cols, 80);
251        assert_eq!(s.rows, 24);
252    }
253
254    #[test]
255    fn size_to_portable() {
256        let s = PtySize {
257            cols: 120,
258            rows: 40,
259            pixel_width: 0,
260            pixel_height: 0,
261        };
262        let p = s.to_portable();
263        assert_eq!(p.cols, 120);
264        assert_eq!(p.rows, 40);
265    }
266}