term_session_server/
session.rs1use 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
24fn 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 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 self.pty.screen();
83 if let Some(title) = self.pty.take_pending_title() {
85 self.title = Some(title);
86 }
87 self.pty.drain_pending()
88 }
89
90 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 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 #[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 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 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 #[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}