use std::sync::Arc;
use std::time::Duration;
use crate::error::{GrpcCode, SailError};
use crate::exec::ExecOptions;
use crate::sailbox::object::Sailbox;
#[derive(Debug, Clone, Default)]
pub struct ShellOptions {
pub shell: Option<String>,
pub term: Option<String>,
pub cwd: Option<String>,
pub timeout: Option<Duration>,
}
#[doc(hidden)]
pub fn stdio_is_tty() -> bool {
use std::io::IsTerminal;
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
}
fn tty_required() -> SailError {
SailError::Execution {
code: GrpcCode::FailedPrecondition,
detail: "shell requires an interactive terminal (stdin and stdout must be TTYs)"
.to_string(),
}
}
impl Sailbox {
pub async fn shell(
&self,
command: Option<&str>,
options: ShellOptions,
) -> Result<i32, SailError> {
if !stdio_is_tty() {
return Err(tty_required());
}
let command = match command {
Some(command) => command.to_string(),
None => login_shell_command(options.shell.as_deref()),
};
let (cols, rows) = terminal_size();
let proc = self
.client()
.exec_shell(
self.sailbox_id(),
&command,
ExecOptions {
timeout: options.timeout,
pty: true,
term: options
.term
.or_else(|| std::env::var("TERM").ok())
.unwrap_or_default(),
cols,
rows,
cwd: options.cwd,
..Default::default()
},
)
.await?;
let proc = Arc::new(proc);
tokio::task::spawn_blocking(move || run_interactive(proc))
.await
.map_err(|err| SailError::Internal {
message: format!("shell bridge task failed: {err}"),
})?
}
}
fn login_shell_command(shell: Option<&str>) -> String {
match shell {
Some(shell) => format!("exec {} -l", crate::exec::sh_quote(shell)),
None => "exec ${SHELL:-/bin/bash} -l".to_string(),
}
}
#[cfg(unix)]
#[doc(hidden)]
pub fn terminal_size() -> (u32, u32) {
let mut size = libc::winsize {
ws_row: 0,
ws_col: 0,
ws_xpixel: 0,
ws_ypixel: 0,
};
let ok = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &raw mut size) } == 0;
if ok && size.ws_col > 0 && size.ws_row > 0 {
(u32::from(size.ws_col), u32::from(size.ws_row))
} else {
(80, 24)
}
}
#[cfg(not(unix))]
#[doc(hidden)]
pub fn terminal_size() -> (u32, u32) {
(80, 24)
}
#[cfg(not(unix))]
#[doc(hidden)]
pub fn run_interactive(_proc: Arc<crate::exec::ExecProcess>) -> Result<i32, SailError> {
Err(SailError::Execution {
code: GrpcCode::Unimplemented,
detail: "interactive PTY sessions are not supported on this platform".to_string(),
})
}
#[cfg(unix)]
#[doc(hidden)]
pub use unix::run_interactive;
#[cfg(unix)]
mod unix {
use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use super::terminal_size;
use crate::error::SailError;
use crate::exec::{ExecProcess, OutputStream, ReadStep};
fn block_on<F: std::future::Future>(future: F) -> F::Output {
match tokio::runtime::Handle::try_current() {
Ok(handle) => handle.block_on(future),
Err(_) => crate::runtime::block_on(future),
}
}
static RESIZE_PENDING: AtomicBool = AtomicBool::new(false);
extern "C" fn on_sigwinch(_signum: libc::c_int) {
RESIZE_PENDING.store(true, Ordering::Relaxed);
}
pub fn run_interactive(proc: Arc<ExecProcess>) -> Result<i32, SailError> {
let saved = enter_raw_mode()?;
let prev_winch = install_sigwinch();
let prev_flags = set_stdin_nonblocking();
let (cols, rows) = terminal_size();
block_on(proc.resize(cols, rows));
let stop = Arc::new(AtomicBool::new(false));
let output = spawn_output_pump(Arc::clone(&proc), Arc::clone(&stop));
drive_input(&proc, &stop);
let _ = output.join();
restore_stdin_flags(prev_flags);
restore_sigwinch(prev_winch);
restore_terminal(&saved);
let exit = block_on(proc.wait())?;
Ok(exit.exit_code)
}
fn spawn_output_pump(proc: Arc<ExecProcess>, stop: Arc<AtomicBool>) -> thread::JoinHandle<()> {
thread::spawn(move || {
let mut reader = proc.reader(OutputStream::Stdout);
let mut stdout = std::io::stdout();
loop {
match reader.next(Duration::from_millis(100)) {
ReadStep::Chunk(text) => {
let _ = stdout.write_all(text.as_bytes());
let _ = stdout.flush();
}
ReadStep::Eof => break,
ReadStep::Pending => {}
}
}
stop.store(true, Ordering::Relaxed);
})
}
fn drive_input(proc: &Arc<ExecProcess>, stop: &AtomicBool) {
let mut buf = [0u8; 4096];
let mut stdin_open = true;
while !stop.load(Ordering::Relaxed) {
if RESIZE_PENDING.swap(false, Ordering::Relaxed) {
let (cols, rows) = terminal_size();
block_on(proc.resize(cols, rows));
}
if !stdin_open {
thread::sleep(Duration::from_millis(20));
continue;
}
let n = unsafe {
libc::read(
libc::STDIN_FILENO,
buf.as_mut_ptr().cast::<libc::c_void>(),
buf.len(),
)
};
match n.cmp(&0) {
std::cmp::Ordering::Greater => {
if block_on(proc.write_stdin(&buf[..n as usize])).is_err() {
break; }
}
std::cmp::Ordering::Equal => {
let _ = block_on(proc.close_stdin());
stdin_open = false;
}
std::cmp::Ordering::Less => {
let err = std::io::Error::last_os_error();
if matches!(
err.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted
) {
thread::sleep(Duration::from_millis(10));
} else {
break;
}
}
}
}
}
fn enter_raw_mode() -> Result<libc::termios, SailError> {
unsafe {
let mut saved: libc::termios = std::mem::zeroed();
if libc::tcgetattr(libc::STDIN_FILENO, &raw mut saved) != 0 {
return Err(SailError::Internal {
message: format!(
"could not enter raw terminal mode: {}",
std::io::Error::last_os_error()
),
});
}
let mut raw = saved;
libc::cfmakeraw(&raw mut raw);
if libc::tcsetattr(libc::STDIN_FILENO, libc::TCSADRAIN, &raw const raw) != 0 {
return Err(SailError::Internal {
message: format!(
"could not enter raw terminal mode: {}",
std::io::Error::last_os_error()
),
});
}
Ok(saved)
}
}
fn restore_terminal(saved: &libc::termios) {
unsafe {
let _ = libc::tcsetattr(
libc::STDIN_FILENO,
libc::TCSADRAIN,
std::ptr::from_ref(saved),
);
}
}
type SigHandler = libc::sighandler_t;
fn install_sigwinch() -> SigHandler {
let handler = on_sigwinch as extern "C" fn(libc::c_int) as usize;
unsafe { libc::signal(libc::SIGWINCH, handler) }
}
fn restore_sigwinch(prev: SigHandler) {
unsafe {
libc::signal(libc::SIGWINCH, prev);
}
}
fn set_stdin_nonblocking() -> libc::c_int {
unsafe {
let flags = libc::fcntl(libc::STDIN_FILENO, libc::F_GETFL);
if flags >= 0 {
libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags | libc::O_NONBLOCK);
}
flags
}
}
fn restore_stdin_flags(flags: libc::c_int) {
if flags >= 0 {
unsafe {
libc::fcntl(libc::STDIN_FILENO, libc::F_SETFL, flags);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn login_shell_quotes_an_explicit_path() {
assert_eq!(
login_shell_command(Some("/opt/my tools/zsh")),
"exec '/opt/my tools/zsh' -l"
);
assert_eq!(
login_shell_command( None),
"exec ${SHELL:-/bin/bash} -l"
);
}
#[tokio::test]
async fn shell_requires_a_tty() {
let client = crate::Client::builder("sk_test")
.api_url("http://127.0.0.1:1")
.sailbox_api_url("http://127.0.0.1:1")
.build()
.expect("build");
let err = client
.sailbox("sb_test")
.shell( None, ShellOptions::default())
.await
.expect_err("no tty in tests");
assert!(err.to_string().contains("interactive terminal"), "{err}");
}
}