Skip to main content

term_session_server/
session.rs

1use portable_pty::{CommandBuilder, PtySize};
2use term_session_muxio_service_definitions::ChannelName;
3use term_session_muxio_service_definitions::PathWire;
4use term_wm_pty_engine::{Pty, PtyResult, PtyStatus};
5
6pub struct Session {
7    pub id: u64,
8    pub pty: Pty,
9    pub title: Option<String>,
10    pub exited: bool,
11    pub exit_code: Option<i32>,
12    pub cols: u16,
13    pub rows: u16,
14}
15
16fn default_shell_command() -> CommandBuilder {
17    #[cfg(not(windows))]
18    let shell = std::env::var("SHELL").unwrap_or_else(|_| "bash".to_string());
19    #[cfg(windows)]
20    let shell = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string());
21    CommandBuilder::new(shell)
22}
23
24/// Resolve the working directory a newly spawned session should start in.
25/// Prefers the caller's launch directory (losslessly decoded wire bytes);
26/// falls back to this process's cwd (the daemon's) for legacy clients that
27/// send `None` or an empty payload.
28fn resolve_cwd(cwd: Option<&PathWire>) -> Option<std::path::PathBuf> {
29    match cwd {
30        Some(c) if !c.is_empty() => Some(c.decode()),
31        _ => std::env::current_dir().ok(),
32    }
33}
34
35impl Session {
36    pub fn spawn(
37        id: u64,
38        cmd: Option<Vec<String>>,
39        cols: u16,
40        rows: u16,
41        channel: Option<&ChannelName>,
42        cwd: Option<&PathWire>,
43    ) -> PtyResult<Self> {
44        let size = PtySize {
45            rows,
46            cols,
47            pixel_width: 0,
48            pixel_height: 0,
49        };
50        // Prefer the caller's launch directory; fall back to this process's
51        // cwd (the daemon's) for legacy clients that send no cwd.
52        let resolved_cwd = resolve_cwd(cwd);
53        let mut builder = if let Some(cmd_parts) = &cmd {
54            let mut b = CommandBuilder::new(&cmd_parts[0]);
55            for arg in &cmd_parts[1..] {
56                b.arg(arg);
57            }
58            b
59        } else {
60            default_shell_command()
61        };
62        if let Some(ch) = channel {
63            builder.env("TERM_WM_CHANNEL", ch.to_string());
64        }
65        if let Some(c) = resolved_cwd {
66            builder.cwd(c);
67        }
68        let pty = Pty::spawn(builder, size)?;
69        Ok(Self {
70            id,
71            pty,
72            title: None,
73            exited: false,
74            exit_code: None,
75            cols,
76            rows,
77        })
78    }
79
80    pub fn read_output(&mut self) -> Vec<u8> {
81        // Clear dirty flag and wake the PTY reader thread from I/O burst budget parking
82        self.pty.screen();
83        // Sync title from the background engine (replaces manual OSC extraction)
84        if let Some(title) = self.pty.take_pending_title() {
85            self.title = Some(title);
86        }
87        self.pty.drain_pending()
88    }
89
90    /// Sync screen state without draining pending output.
91    /// Clears the dirty flag (waking the reader thread from I/O burst budget parking)
92    /// and syncs the title, but leaves accumulated bytes in the pending buffer so
93    /// they can be sent to a future subscriber.
94    pub fn sync_screen(&mut self) {
95        self.pty.screen();
96        if let Some(title) = self.pty.take_pending_title() {
97            self.title = Some(title);
98        }
99    }
100
101    pub fn check_exited(&mut self) -> bool {
102        if !self.exited && self.pty.has_exited() {
103            self.exited = true;
104            self.exit_code = self.pty.exit_status().map(|s| s.exit_code() as i32);
105            true
106        } else {
107            false
108        }
109    }
110
111    pub fn take_exit_code(&mut self) -> Option<i32> {
112        self.exit_code.take()
113    }
114
115    pub fn generate_snapshot(&mut self) -> Vec<u8> {
116        self.pty.generate_snapshot()
117    }
118
119    pub fn set_status_callback(&mut self, cb: Option<Box<dyn Fn(PtyStatus) + Send + Sync>>) {
120        self.pty.set_status_callback(cb);
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::{Session, resolve_cwd};
127    use std::path::{Path, PathBuf};
128    use std::time::{Duration, Instant};
129
130    use term_session_muxio_service_definitions::path_wire;
131    use term_session_muxio_service_definitions::path_wire::PathWire;
132
133    const TEST_COLS: u16 = 80;
134    const TEST_ROWS: u16 = 24;
135    const REPORT_TIMEOUT_SECS: u64 = 10;
136
137    #[test]
138    fn resolve_cwd_uses_provided_dir() {
139        let dir = std::env::temp_dir().join("resolve-cwd-probe");
140        let probe = path_wire::encode_path(&dir);
141        assert_eq!(resolve_cwd(Some(&probe)), Some(dir));
142    }
143
144    #[test]
145    fn resolve_cwd_falls_back_to_process_dir_when_none() {
146        assert_eq!(resolve_cwd(None), std::env::current_dir().ok());
147    }
148
149    #[test]
150    fn resolve_cwd_falls_back_to_process_dir_when_empty() {
151        assert_eq!(
152            resolve_cwd(Some(&PathWire::default())),
153            std::env::current_dir().ok()
154        );
155    }
156
157    /// Try to create a directory whose name contains bytes that are not valid
158    /// UTF-8. Only possible on Unix, and some filesystems refuse it: macOS
159    /// requires valid UTF-8 filenames, so this returns `None` there and the
160    /// tests skip (losslessness is still covered by the pure `path_wire`
161    /// round-trip test, which needs no filesystem). Returns `Some` on Linux
162    /// etc., proving the cwd round-trip is byte-for-byte, not merely
163    /// UTF-8-equivalent.
164    #[cfg(unix)]
165    fn try_non_utf8_dir(base: &Path) -> Option<PathBuf> {
166        use std::os::unix::ffi::OsStrExt;
167        let name = std::ffi::OsStr::from_bytes(b"cwd-\xff\xfe-non-utf8");
168        let dir = base.join(name);
169        std::fs::create_dir_all(&dir).ok().map(|()| dir)
170    }
171
172    #[cfg(unix)]
173    #[test]
174    fn resolve_cwd_round_trips_non_utf8_dir() {
175        let base = tempfile::tempdir().expect("tempdir");
176        let Some(dir) = try_non_utf8_dir(base.path()) else {
177            eprintln!("skipping: filesystem rejects non-UTF-8 directory names");
178            return;
179        };
180        let probe = path_wire::encode_path(&dir);
181        assert_eq!(resolve_cwd(Some(&probe)), Some(dir));
182    }
183
184    /// Poll `report` until the mock `pwd` child has written its cwd, with a
185    /// generous timeout so a broken spawn fails the assertion instead of
186    /// hanging the test. Meanwhile `read_output()` pumps the PTY so the
187    /// child's DSR startup handshake completes (a Windows console child
188    /// stalls until the host answers `\x1b[6n`) — otherwise the mock never
189    /// runs and no report is written. Returns the raw report bytes so
190    /// losslessness is asserted byte-for-byte.
191    fn read_report(session: &mut Session, report: &Path) -> Vec<u8> {
192        let deadline = Instant::now() + Duration::from_secs(REPORT_TIMEOUT_SECS);
193        loop {
194            if let Ok(content) = std::fs::read(report) {
195                return content;
196            }
197            assert!(
198                Instant::now() < deadline,
199                "mock pwd never wrote the report at {report:?}"
200            );
201            session.read_output();
202            std::thread::sleep(Duration::from_millis(50));
203        }
204    }
205
206    /// Spawn a session running `mock pwd <report>` with the given wire-encoded
207    /// cwd and return the wire bytes the child reports.
208    ///
209    /// The mock is a console app spawned through a PTY, which on Windows
210    /// stalls at startup until the host answers its DSR cursor-position query
211    /// (`\x1b[6n` → `\x1b[row;colR`). The wait loop therefore pumps the PTY via
212    /// `read_output()` → `screen()`, mirroring the real daemon's poll/sync loop.
213    fn spawn_pwd_report(cwd: Option<&PathWire>) -> PathWire {
214        let dir = tempfile::tempdir().expect("report tempdir");
215        let report = dir.path().join("pwd.txt");
216        let mock = term_session_mock::get_mock_bin();
217        let cmd = vec![
218            mock.to_string_lossy().into_owned(),
219            "pwd".to_string(),
220            report.to_string_lossy().into_owned(),
221        ];
222        let mut session =
223            Session::spawn(1, Some(cmd), TEST_COLS, TEST_ROWS, None, cwd).expect("spawn session");
224        let bytes = read_report(&mut session, &report);
225        session.pty.kill_child().ok();
226        PathWire::from(bytes)
227    }
228
229    fn canonical_process_cwd() -> PathBuf {
230        std::fs::canonicalize(std::env::current_dir().expect("process cwd"))
231            .expect("canonicalize process cwd")
232    }
233
234    #[test]
235    fn spawn_starts_in_specified_cwd() {
236        let client_dir = tempfile::tempdir().expect("client tempdir");
237        let expected = std::fs::canonicalize(client_dir.path()).expect("canonicalize client dir");
238        let reported = spawn_pwd_report(Some(&path_wire::encode_path(client_dir.path())));
239        let reported = std::fs::canonicalize(reported.decode()).expect("canonicalize reported");
240        assert_eq!(reported, expected);
241    }
242
243    #[test]
244    fn spawn_falls_back_to_process_cwd_when_cwd_none() {
245        let reported = spawn_pwd_report(None);
246        let reported = std::fs::canonicalize(reported.decode()).expect("canonicalize reported");
247        assert_eq!(reported, canonical_process_cwd());
248    }
249
250    #[test]
251    fn spawn_falls_back_to_process_cwd_when_cwd_empty() {
252        let reported = spawn_pwd_report(Some(&PathWire::default()));
253        let reported = std::fs::canonicalize(reported.decode()).expect("canonicalize reported");
254        assert_eq!(reported, canonical_process_cwd());
255    }
256
257    /// End-to-end losslessness proof: a non-UTF-8 cwd survives the full
258    /// `Session::spawn` → child cwd → report pipeline byte-for-byte (skipped on
259    /// filesystems that reject non-UTF-8 names, e.g. macOS).
260    #[cfg(unix)]
261    #[test]
262    fn spawn_round_trips_non_utf8_cwd() {
263        let base = tempfile::tempdir().expect("tempdir");
264        let Some(dir) = try_non_utf8_dir(base.path()) else {
265            eprintln!("skipping: filesystem rejects non-UTF-8 directory names");
266            return;
267        };
268        let reported = spawn_pwd_report(Some(&path_wire::encode_path(&dir)));
269        let expected = std::fs::canonicalize(&dir).expect("canonicalize non-utf8 dir");
270        assert_eq!(path_wire::decode_path(&reported), expected);
271    }
272}