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    /// Drain the session's final output after the child has exited, waiting
91    /// (bounded by `grace`) for the PTY reader thread to finish EOF processing
92    /// so trailing bytes are not truncated. Used when retaining output for a
93    /// late subscriber across session teardown.
94    pub fn read_final_output(&mut self, grace: std::time::Duration) -> Vec<u8> {
95        let out = self.pty.drain_final_output(grace);
96        if let Some(title) = self.pty.take_pending_title() {
97            self.title = Some(title);
98        }
99        out
100    }
101
102    /// Sync screen state without draining pending output.
103    /// Clears the dirty flag (waking the reader thread from I/O burst budget parking)
104    /// and syncs the title, but leaves accumulated bytes in the pending buffer so
105    /// they can be sent to a future subscriber.
106    pub fn sync_screen(&mut self) {
107        self.pty.screen();
108        if let Some(title) = self.pty.take_pending_title() {
109            self.title = Some(title);
110        }
111    }
112
113    pub fn check_exited(&mut self) -> bool {
114        if !self.exited && self.pty.has_exited() {
115            self.exited = true;
116            self.exit_code = self.pty.exit_status().map(|s| s.exit_code() as i32);
117            true
118        } else {
119            false
120        }
121    }
122
123    pub fn take_exit_code(&mut self) -> Option<i32> {
124        self.exit_code.take()
125    }
126
127    pub fn generate_snapshot(&mut self) -> Vec<u8> {
128        self.pty.generate_snapshot()
129    }
130
131    pub fn set_status_callback(&mut self, cb: Option<Box<dyn Fn(PtyStatus) + Send + Sync>>) {
132        self.pty.set_status_callback(cb);
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::{Session, resolve_cwd};
139    use std::path::{Path, PathBuf};
140    use std::time::{Duration, Instant};
141
142    use term_session_muxio_service_definitions::path_wire;
143    use term_session_muxio_service_definitions::path_wire::PathWire;
144
145    const TEST_COLS: u16 = 80;
146    const TEST_ROWS: u16 = 24;
147    const REPORT_TIMEOUT_SECS: u64 = 10;
148
149    #[test]
150    fn resolve_cwd_uses_provided_dir() {
151        let dir = std::env::temp_dir().join("resolve-cwd-probe");
152        let probe = path_wire::encode_path(&dir);
153        assert_eq!(resolve_cwd(Some(&probe)), Some(dir));
154    }
155
156    #[test]
157    fn resolve_cwd_falls_back_to_process_dir_when_none() {
158        assert_eq!(resolve_cwd(None), std::env::current_dir().ok());
159    }
160
161    #[test]
162    fn resolve_cwd_falls_back_to_process_dir_when_empty() {
163        assert_eq!(
164            resolve_cwd(Some(&PathWire::default())),
165            std::env::current_dir().ok()
166        );
167    }
168
169    /// Try to create a directory whose name contains bytes that are not valid
170    /// UTF-8. Only possible on Unix, and some filesystems refuse it: macOS
171    /// requires valid UTF-8 filenames, so this returns `None` there and the
172    /// tests skip (losslessness is still covered by the pure `path_wire`
173    /// round-trip test, which needs no filesystem). Returns `Some` on Linux
174    /// etc., proving the cwd round-trip is byte-for-byte, not merely
175    /// UTF-8-equivalent.
176    #[cfg(unix)]
177    fn try_non_utf8_dir(base: &Path) -> Option<PathBuf> {
178        use std::os::unix::ffi::OsStrExt;
179        let name = std::ffi::OsStr::from_bytes(b"cwd-\xff\xfe-non-utf8");
180        let dir = base.join(name);
181        std::fs::create_dir_all(&dir).ok().map(|()| dir)
182    }
183
184    #[cfg(unix)]
185    #[test]
186    fn resolve_cwd_round_trips_non_utf8_dir() {
187        let base = tempfile::tempdir().expect("tempdir");
188        let Some(dir) = try_non_utf8_dir(base.path()) else {
189            eprintln!("skipping: filesystem rejects non-UTF-8 directory names");
190            return;
191        };
192        let probe = path_wire::encode_path(&dir);
193        assert_eq!(resolve_cwd(Some(&probe)), Some(dir));
194    }
195
196    /// Poll `report` until the mock `pwd` child has written its cwd, with a
197    /// generous timeout so a broken spawn fails the assertion instead of
198    /// hanging the test. Meanwhile `read_output()` pumps the PTY so the
199    /// child's DSR startup handshake completes (a Windows console child
200    /// stalls until the host answers `\x1b[6n`) — otherwise the mock never
201    /// runs and no report is written. Returns the raw report bytes so
202    /// losslessness is asserted byte-for-byte.
203    fn read_report(session: &mut Session, report: &Path) -> Vec<u8> {
204        let deadline = Instant::now() + Duration::from_secs(REPORT_TIMEOUT_SECS);
205        loop {
206            if let Ok(content) = std::fs::read(report) {
207                return content;
208            }
209            assert!(
210                Instant::now() < deadline,
211                "mock pwd never wrote the report at {report:?}"
212            );
213            session.read_output();
214            std::thread::sleep(Duration::from_millis(50));
215        }
216    }
217
218    /// Spawn a session running `mock pwd <report>` with the given wire-encoded
219    /// cwd and return the wire bytes the child reports.
220    ///
221    /// The mock is a console app spawned through a PTY, which on Windows
222    /// stalls at startup until the host answers its DSR cursor-position query
223    /// (`\x1b[6n` → `\x1b[row;colR`). The wait loop therefore pumps the PTY via
224    /// `read_output()` → `screen()`, mirroring the real daemon's poll/sync loop.
225    fn spawn_pwd_report(cwd: Option<&PathWire>) -> PathWire {
226        let dir = tempfile::tempdir().expect("report tempdir");
227        let report = dir.path().join("pwd.txt");
228        let mock = term_session_mock::get_mock_bin();
229        let cmd = vec![
230            mock.to_string_lossy().into_owned(),
231            "pwd".to_string(),
232            report.to_string_lossy().into_owned(),
233        ];
234        let mut session =
235            Session::spawn(1, Some(cmd), TEST_COLS, TEST_ROWS, None, cwd).expect("spawn session");
236        let bytes = read_report(&mut session, &report);
237        session.pty.kill_child().ok();
238        PathWire::from(bytes)
239    }
240
241    fn canonical_process_cwd() -> PathBuf {
242        std::fs::canonicalize(std::env::current_dir().expect("process cwd"))
243            .expect("canonicalize process cwd")
244    }
245
246    #[test]
247    fn spawn_starts_in_specified_cwd() {
248        let client_dir = tempfile::tempdir().expect("client tempdir");
249        let expected = std::fs::canonicalize(client_dir.path()).expect("canonicalize client dir");
250        let reported = spawn_pwd_report(Some(&path_wire::encode_path(client_dir.path())));
251        let reported = std::fs::canonicalize(reported.decode()).expect("canonicalize reported");
252        assert_eq!(reported, expected);
253    }
254
255    #[test]
256    fn spawn_falls_back_to_process_cwd_when_cwd_none() {
257        let reported = spawn_pwd_report(None);
258        let reported = std::fs::canonicalize(reported.decode()).expect("canonicalize reported");
259        assert_eq!(reported, canonical_process_cwd());
260    }
261
262    #[test]
263    fn spawn_falls_back_to_process_cwd_when_cwd_empty() {
264        let reported = spawn_pwd_report(Some(&PathWire::default()));
265        let reported = std::fs::canonicalize(reported.decode()).expect("canonicalize reported");
266        assert_eq!(reported, canonical_process_cwd());
267    }
268
269    /// End-to-end losslessness proof: a non-UTF-8 cwd survives the full
270    /// `Session::spawn` → child cwd → report pipeline byte-for-byte (skipped on
271    /// filesystems that reject non-UTF-8 names, e.g. macOS).
272    #[cfg(unix)]
273    #[test]
274    fn spawn_round_trips_non_utf8_cwd() {
275        let base = tempfile::tempdir().expect("tempdir");
276        let Some(dir) = try_non_utf8_dir(base.path()) else {
277            eprintln!("skipping: filesystem rejects non-UTF-8 directory names");
278            return;
279        };
280        let reported = spawn_pwd_report(Some(&path_wire::encode_path(&dir)));
281        let expected = std::fs::canonicalize(&dir).expect("canonicalize non-utf8 dir");
282        assert_eq!(path_wire::decode_path(&reported), expected);
283    }
284}