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 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 #[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 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 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 #[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}