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. Returns the raw report bytes so losslessness is
187    /// asserted byte-for-byte.
188    fn read_report(report: &Path) -> Vec<u8> {
189        let deadline = Instant::now() + Duration::from_secs(REPORT_TIMEOUT_SECS);
190        loop {
191            if let Ok(content) = std::fs::read(report) {
192                return content;
193            }
194            assert!(
195                Instant::now() < deadline,
196                "mock pwd never wrote the report at {report:?}"
197            );
198            std::thread::sleep(Duration::from_millis(50));
199        }
200    }
201
202    /// Spawn a session running `mock pwd <report>` with the given wire-encoded
203    /// cwd and return the wire bytes the child reports.
204    fn spawn_pwd_report(cwd: Option<&PathWire>) -> PathWire {
205        let dir = tempfile::tempdir().expect("report tempdir");
206        let report = dir.path().join("pwd.txt");
207        let mock = term_session_mock::get_mock_bin();
208        let cmd = vec![
209            mock.to_string_lossy().into_owned(),
210            "pwd".to_string(),
211            report.to_string_lossy().into_owned(),
212        ];
213        let _session =
214            Session::spawn(1, Some(cmd), TEST_COLS, TEST_ROWS, None, cwd).expect("spawn session");
215        PathWire::from(read_report(&report))
216    }
217
218    fn canonical_process_cwd() -> PathBuf {
219        std::fs::canonicalize(std::env::current_dir().expect("process cwd"))
220            .expect("canonicalize process cwd")
221    }
222
223    #[test]
224    fn spawn_starts_in_specified_cwd() {
225        let client_dir = tempfile::tempdir().expect("client tempdir");
226        let expected = std::fs::canonicalize(client_dir.path()).expect("canonicalize client dir");
227        let reported = spawn_pwd_report(Some(&path_wire::encode_path(client_dir.path())));
228        assert_eq!(path_wire::decode_path(&reported), expected);
229    }
230
231    #[test]
232    fn spawn_falls_back_to_process_cwd_when_cwd_none() {
233        let reported = spawn_pwd_report(None);
234        assert_eq!(path_wire::decode_path(&reported), canonical_process_cwd());
235    }
236
237    #[test]
238    fn spawn_falls_back_to_process_cwd_when_cwd_empty() {
239        let reported = spawn_pwd_report(Some(&PathWire::default()));
240        assert_eq!(path_wire::decode_path(&reported), canonical_process_cwd());
241    }
242
243    /// End-to-end losslessness proof: a non-UTF-8 cwd survives the full
244    /// `Session::spawn` → child cwd → report pipeline byte-for-byte (skipped on
245    /// filesystems that reject non-UTF-8 names, e.g. macOS).
246    #[cfg(unix)]
247    #[test]
248    fn spawn_round_trips_non_utf8_cwd() {
249        let base = tempfile::tempdir().expect("tempdir");
250        let Some(dir) = try_non_utf8_dir(base.path()) else {
251            eprintln!("skipping: filesystem rejects non-UTF-8 directory names");
252            return;
253        };
254        let reported = spawn_pwd_report(Some(&path_wire::encode_path(&dir)));
255        let expected = std::fs::canonicalize(&dir).expect("canonicalize non-utf8 dir");
256        assert_eq!(path_wire::decode_path(&reported), expected);
257    }
258}