1use std::sync::Arc;
15use std::time::Duration;
16
17use crate::error::{GrpcCode, SailError};
18use crate::exec::ExecOptions;
19use crate::sailbox::object::Sailbox;
20
21#[derive(Debug, Clone, Default)]
23pub struct ShellOptions {
24 pub shell: Option<String>,
27 pub term: Option<String>,
29 pub cwd: Option<String>,
31 pub timeout: Option<Duration>,
33}
34
35#[doc(hidden)]
37pub fn stdio_is_tty() -> bool {
38 use std::io::IsTerminal;
39 std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
40}
41
42fn tty_required() -> SailError {
43 SailError::Execution {
44 code: GrpcCode::FailedPrecondition,
45 detail: "shell requires an interactive terminal (stdin and stdout must be TTYs)"
46 .to_string(),
47 }
48}
49
50impl Sailbox {
51 pub async fn shell(
59 &self,
60 command: Option<&str>,
61 options: ShellOptions,
62 ) -> Result<i32, SailError> {
63 if !stdio_is_tty() {
64 return Err(tty_required());
65 }
66 let command = match command {
67 Some(command) => command.to_string(),
68 None => login_shell_command(options.shell.as_deref()),
69 };
70 let (cols, rows) = terminal_size();
71 let proc = self
72 .client()
73 .exec_shell(
74 self.sailbox_id(),
75 &command,
76 ExecOptions {
77 timeout: options.timeout,
78 pty: true,
79 term: options
80 .term
81 .or_else(|| std::env::var("TERM").ok())
82 .unwrap_or_default(),
83 cols,
84 rows,
85 cwd: options.cwd,
86 ..Default::default()
87 },
88 )
89 .await?;
90 let proc = Arc::new(proc);
91 tokio::task::spawn_blocking(move || run_interactive(proc))
92 .await
93 .map_err(|err| SailError::Internal {
94 message: format!("shell bridge task failed: {err}"),
95 })?
96 }
97}
98
99fn login_shell_command(shell: Option<&str>) -> String {
104 match shell {
105 Some(shell) => format!("exec {} -l", crate::exec::sh_quote(shell)),
106 None => "exec ${SHELL:-/bin/bash} -l".to_string(),
107 }
108}
109
110#[cfg(unix)]
112#[doc(hidden)]
113pub fn terminal_size() -> (u32, u32) {
114 let mut size = libc::winsize {
115 ws_row: 0,
116 ws_col: 0,
117 ws_xpixel: 0,
118 ws_ypixel: 0,
119 };
120 let ok = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &raw mut size) } == 0;
121 if ok && size.ws_col > 0 && size.ws_row > 0 {
122 (u32::from(size.ws_col), u32::from(size.ws_row))
123 } else {
124 (80, 24)
125 }
126}
127
128#[cfg(not(unix))]
130#[doc(hidden)]
131pub fn terminal_size() -> (u32, u32) {
132 (80, 24)
133}
134
135#[cfg(not(unix))]
137#[doc(hidden)]
138pub fn run_interactive(_proc: Arc<crate::exec::ExecProcess>) -> Result<i32, SailError> {
139 Err(SailError::Execution {
140 code: GrpcCode::Unimplemented,
141 detail: "interactive PTY sessions are not supported on this platform".to_string(),
142 })
143}
144
145#[cfg(unix)]
146#[doc(hidden)]
147pub use unix::run_interactive;
148
149#[cfg(unix)]
150mod unix {
151 use std::io::Write;
152 use std::sync::atomic::{AtomicBool, Ordering};
153 use std::sync::Arc;
154 use std::thread;
155 use std::time::Duration;
156
157 use super::terminal_size;
158 use crate::error::SailError;
159 use crate::exec::{ExecProcess, OutputStream, ReadStep};
160
161 fn block_on<F: std::future::Future>(future: F) -> F::Output {
167 match tokio::runtime::Handle::try_current() {
168 Ok(handle) => handle.block_on(future),
169 Err(_) => crate::runtime::block_on(future),
170 }
171 }
172
173 static RESIZE_PENDING: AtomicBool = AtomicBool::new(false);
175
176 extern "C" fn on_sigwinch(_signum: libc::c_int) {
177 RESIZE_PENDING.store(true, Ordering::Relaxed);
178 }
179
180 pub fn run_interactive(proc: Arc<ExecProcess>) -> Result<i32, SailError> {
184 let saved = enter_raw_mode()?;
185 let prev_winch = install_sigwinch();
186 let prev_flags = set_stdin_nonblocking();
187
188 let (cols, rows) = terminal_size();
190 block_on(proc.resize(cols, rows));
191
192 let stop = Arc::new(AtomicBool::new(false));
193 let output = spawn_output_pump(Arc::clone(&proc), Arc::clone(&stop));
194
195 drive_input(&proc, &stop);
196
197 let _ = output.join();
199 restore_stdin_flags(prev_flags);
200 restore_sigwinch(prev_winch);
201 restore_terminal(&saved);
202
203 let exit = block_on(proc.wait())?;
204 Ok(exit.exit_code)
205 }
206
207 fn spawn_output_pump(proc: Arc<ExecProcess>, stop: Arc<AtomicBool>) -> thread::JoinHandle<()> {
209 thread::spawn(move || {
210 let mut reader = proc.reader(OutputStream::Stdout);
211 let mut stdout = std::io::stdout();
212 loop {
213 match reader.next(Duration::from_millis(100)) {
214 ReadStep::Chunk(text) => {
215 let _ = stdout.write_all(text.as_bytes());
216 let _ = stdout.flush();
217 }
218 ReadStep::Eof => break,
219 ReadStep::Pending => {}
220 }
221 }
222 stop.store(true, Ordering::Relaxed);
223 })
224 }
225
226 fn drive_input(proc: &Arc<ExecProcess>, stop: &AtomicBool) {
229 let mut buf = [0u8; 4096];
230 let mut stdin_open = true;
231 while !stop.load(Ordering::Relaxed) {
232 if RESIZE_PENDING.swap(false, Ordering::Relaxed) {
233 let (cols, rows) = terminal_size();
234 block_on(proc.resize(cols, rows));
235 }
236 if !stdin_open {
237 thread::sleep(Duration::from_millis(20));
238 continue;
239 }
240 let n = unsafe {
241 libc::read(
242 libc::STDIN_FILENO,
243 buf.as_mut_ptr().cast::<libc::c_void>(),
244 buf.len(),
245 )
246 };
247 match n.cmp(&0) {
248 std::cmp::Ordering::Greater => {
249 if block_on(proc.write_stdin(&buf[..n as usize])).is_err() {
250 break; }
252 }
253 std::cmp::Ordering::Equal => {
254 let _ = block_on(proc.close_stdin());
257 stdin_open = false;
258 }
259 std::cmp::Ordering::Less => {
260 let err = std::io::Error::last_os_error();
266 if matches!(
267 err.kind(),
268 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
269 ) {
270 thread::sleep(Duration::from_millis(10));
271 } else {
272 break;
273 }
274 }
275 }
276 }
277 }
278
279 fn enter_raw_mode() -> Result<libc::termios, SailError> {
283 unsafe {
284 let mut saved: libc::termios = std::mem::zeroed();
285 if libc::tcgetattr(libc::STDIN_FILENO, &raw mut saved) != 0 {
286 return Err(SailError::Internal {
287 message: format!(
288 "could not enter raw terminal mode: {}",
289 std::io::Error::last_os_error()
290 ),
291 });
292 }
293 let mut raw = saved;
294 libc::cfmakeraw(&raw mut raw);
295 if libc::tcsetattr(libc::STDIN_FILENO, libc::TCSADRAIN, &raw const raw) != 0 {
296 return Err(SailError::Internal {
297 message: format!(
298 "could not enter raw terminal mode: {}",
299 std::io::Error::last_os_error()
300 ),
301 });
302 }
303 Ok(saved)
304 }
305 }
306
307 fn restore_terminal(saved: &libc::termios) {
308 unsafe {
309 let _ = libc::tcsetattr(
310 libc::STDIN_FILENO,
311 libc::TCSADRAIN,
312 std::ptr::from_ref(saved),
313 );
314 }
315 }
316
317 type SigHandler = libc::sighandler_t;
318
319 fn install_sigwinch() -> SigHandler {
320 let handler = on_sigwinch as extern "C" fn(libc::c_int) as usize;
324 unsafe { libc::signal(libc::SIGWINCH, handler) }
325 }
326
327 fn restore_sigwinch(prev: SigHandler) {
328 unsafe {
329 libc::signal(libc::SIGWINCH, prev);
330 }
331 }
332
333 fn set_stdin_nonblocking() -> libc::c_int {
336 unsafe {
337 let flags = libc::fcntl(libc::STDIN_FILENO, libc::F_GETFL);
338 if flags >= 0 {
339 libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags | libc::O_NONBLOCK);
340 }
341 flags
342 }
343 }
344
345 fn restore_stdin_flags(flags: libc::c_int) {
346 if flags >= 0 {
347 unsafe {
348 libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags);
349 }
350 }
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[test]
359 fn login_shell_quotes_an_explicit_path() {
360 assert_eq!(
362 login_shell_command(Some("/opt/my tools/zsh")),
363 "exec '/opt/my tools/zsh' -l"
364 );
365 assert_eq!(
367 login_shell_command(None),
368 "exec ${SHELL:-/bin/bash} -l"
369 );
370 }
371
372 #[tokio::test]
373 async fn shell_requires_a_tty() {
374 let client = crate::Client::builder("sk_test")
377 .api_url("http://127.0.0.1:1")
378 .sailbox_api_url("http://127.0.0.1:1")
379 .build()
380 .expect("build");
381 let err = client
382 .sailbox("sb_test")
383 .shell(None, ShellOptions::default())
384 .await
385 .expect_err("no tty in tests");
386 assert!(err.to_string().contains("interactive terminal"), "{err}");
387 }
388}