From 2fadcc7b4ad6e8f85d265000e7493fd81e0c44bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcus=20Sch=C3=A4fer?= Date: Mon, 17 Aug 2026 13:07:48 +0200 Subject: [PATCH] Fix terminal emulation in firecracker sessions Make sure to place the caller terminal in raw mode such that tab completion can work. Additionally make sure to setup window size and terminal type correctly --- .../guestvm-tools/sci/src/defaults.rs | 3 + .../guestvm-tools/sci/src/main.rs | 124 +++++++++++++++- firecracker-pilot/src/defaults.rs | 1 + firecracker-pilot/src/firecracker.rs | 138 +++++++++++++++++- 4 files changed, 260 insertions(+), 6 deletions(-) diff --git a/firecracker-pilot/guestvm-tools/sci/src/defaults.rs b/firecracker-pilot/guestvm-tools/sci/src/defaults.rs index c7868a4..2bd9754 100644 --- a/firecracker-pilot/guestvm-tools/sci/src/defaults.rs +++ b/firecracker-pilot/guestvm-tools/sci/src/defaults.rs @@ -34,6 +34,9 @@ pub const PROBE_MODULE: &str = "/sbin/modprobe"; pub const SYSTEMD_NETWORK_RESOLV_CONF: &str = "/run/systemd/resolve/resolv.conf"; pub const VM_QUIT: &str = "sci_quit"; pub const VHOST_TRANSPORT: &str = "vmw_vsock_virtio_transport"; +pub const TERM_TYPE: &str = "xterm"; +pub const TERM_LINES: u16 = 24; +pub const TERM_COLUMNS: u16 = 80; pub const VM_PORT: u32 = 52; pub const GUEST_CID: u32 = 3; pub const RETRIES: u32 = diff --git a/firecracker-pilot/guestvm-tools/sci/src/main.rs b/firecracker-pilot/guestvm-tools/sci/src/main.rs index 959bca8..9203be4 100644 --- a/firecracker-pilot/guestvm-tools/sci/src/main.rs +++ b/firecracker-pilot/guestvm-tools/sci/src/main.rs @@ -68,6 +68,9 @@ fn main() { env::set_var("PS1", "\\[\\]\\u@\\h: >\n"); + // provide a terminal type for the command call + setup_terminal_environment(); + // print user space env for (key, value) in env::vars() { debug(&format!("{key}: {value}")); @@ -430,6 +433,106 @@ fn redirect_command(command: &str, stream: vsock::VsockStream) { } } +fn setup_terminal_environment() { + /*! + Provide a terminal type in the environment + + The environment of sci is created from the kernel commandline + and therefore normally does not provide a TERM setting. Shells + like bash switch off their line editor (readline) if the + terminal type is unset or set to 'dumb'. Without the line + editor there is no tab completion and no history handling for + the caller. The terminal type of the caller can be handed over + through the sci_term=... boot parameter and defaults to + defaults::TERM_TYPE + !*/ + let term = env::var("TERM").unwrap_or_default(); + if term.is_empty() { + let mut term_type = env::var("sci_term").unwrap_or_default(); + if term_type.is_empty() { + term_type = defaults::TERM_TYPE.to_string() + } + debug(&format!("Setting terminal type to: {term_type}")); + env::set_var("TERM", term_type); + } +} + +fn set_interactive_terminal_flags(fd: i32) { + /*! + Setup the given terminal for interactive use + + Keep the standard line discipline of the terminal switched on + such that the line editor of an interactive command, e.g the + tab completion of a shell, stays in control of the input + handling and echoes back what it has read. The terminal of the + caller is switched to raw mode by the pilot, thus every single + key stroke, including TAB, arrives here unmodified + !*/ + match Termios::from_fd(fd) { + Ok(mut termios) => { + termios.c_lflag |= ECHO | ECHOE | ECHOK | ICANON | ISIG | IEXTEN; + termios.c_iflag |= ICRNL; + termios.c_oflag |= OPOST | ONLCR; + match tcsetattr(fd, TCSANOW, &termios) { + Ok(_) => {} + Err(error) => { + debug(&format!("tcsetattr failed with: {error}")); + } + } + }, + Err(error) => { + debug(&format!( + "Term I/O failed with: {error}" + )); + } + } + set_terminal_size(fd) +} + +fn set_terminal_size(fd: i32) { + /*! + Set the window size of the given terminal + + A newly allocated pseudo terminal comes with no window size + assigned. The line editor needs the size of the caller's + terminal to be able to redraw the input line and to arrange + the list of tab completion matches in columns. The size of the + caller's terminal can be handed over through the sci_lines=... + and sci_columns=... boot parameters and defaults to + defaults::TERM_LINES x defaults::TERM_COLUMNS + !*/ + let window_size = libc::winsize { + ws_row: get_terminal_size_value( + "sci_lines", defaults::TERM_LINES + ), + ws_col: get_terminal_size_value( + "sci_columns", defaults::TERM_COLUMNS + ), + ws_xpixel: 0, + ws_ypixel: 0 + }; + let result = unsafe { + libc::ioctl( + fd, libc::TIOCSWINSZ as _, &window_size as *const libc::winsize + ) + }; + if result == -1 { + debug(&format!( + "Failed to set terminal size: {}", + std::io::Error::last_os_error() + )); + } +} + +fn get_terminal_size_value(name: &str, default_value: u16) -> u16 { + // Read a terminal geometry value from the given environment + // variable and fall back to the given default value + match env::var(name).unwrap_or_default().parse::() { + Ok(value) if value > 0 => value, + _ => default_value + } +} + fn set_output_terminal_flags(fd: i32) { // Disable echo and canonical mode on stdout match Termios::from_fd(fd) { @@ -480,7 +583,7 @@ fn redirect_command_to_raw_channels( set_output_terminal_flags(stdout_fd); // main send/recv loop - let mut buffer = [0_u8; 100]; + let mut buffer = [0_u8; 1]; loop { // prepare file descriptors to be watched for by select() let raw_fdset = std::mem::MaybeUninit::::uninit(); @@ -549,6 +652,15 @@ fn redirect_command_to_raw_channels( debug("EOF detected on stream"); break; } + // On raw channels there is no terminal which + // could echo back the input. As the caller's + // terminal is in raw mode and no longer echoes + // locally, send the input back to make typing + // visible + if stream.write_all(&buffer[0..sz_r]).is_err() { + debug("write failure on stream"); + break; + } if stdin.write_all(&buffer[0..sz_r]).is_err() { debug("write failure on stdin"); break; @@ -576,10 +688,14 @@ fn redirect_command_to_pty( let stdout_fd = master.as_raw_fd(); let stream_fd = stream.as_raw_fd(); - set_output_terminal_flags(stdout_fd); + // Keep the line discipline of the pseudo terminal active. + // The command in the terminal, e.g a shell, takes care for + // reading and echoing the input which is the precondition + // for features like tab completion to work + set_interactive_terminal_flags(stdout_fd); // main send/recv loop - let mut buffer = [0_u8; 100]; + let mut buffer = [0_u8; 1]; loop { // prepare file descriptors to be watched for by select() let raw_fdset = std::mem::MaybeUninit::::uninit(); @@ -606,7 +722,7 @@ fn redirect_command_to_pty( // try to handle what happened on the file descriptors if unsafe { libc::FD_ISSET(stdout_fd, &fdset) } { // something new happened on master, - // try to receive some bytes an send them through the stream + // try to receive some bytes and send them through the stream if let Ok(sz_r) = master.read(&mut buffer) { if sz_r == 0 { debug("EOF detected on stdout"); diff --git a/firecracker-pilot/src/defaults.rs b/firecracker-pilot/src/defaults.rs index 791165c..1ca24c1 100644 --- a/firecracker-pilot/src/defaults.rs +++ b/firecracker-pilot/src/defaults.rs @@ -44,6 +44,7 @@ pub const FIRECRACKER_VSOCK_PREFIX: &str = "sci_cmd_"; pub const FIRECRACKER_VSOCK_PORT_START: u32 = 49200; pub const GC_THRESHOLD: usize = 20; +pub const TERM_NAME_MAX_LEN: usize = 32; pub const VM_CID: u32 = 3; pub const VM_PORT: u32 = 52; diff --git a/firecracker-pilot/src/firecracker.rs b/firecracker-pilot/src/firecracker.rs index 3a6f5d5..505abd7 100644 --- a/firecracker-pilot/src/firecracker.rs +++ b/firecracker-pilot/src/firecracker.rs @@ -604,6 +604,60 @@ pub fn execute_command_at_instance( Ok(()) } +pub fn get_terminal_boot_args() -> Vec { + /*! + Provide the terminal setup of the caller as kernel boot params + + The terminal type is required in the guest to switch on the + line editor of interactive commands, e.g the tab completion of + a shell. The window size is required to let that line editor + redraw the input line and to arrange the tab completion matches + in columns + !*/ + let mut terminal_boot_args: Vec = Vec::new(); + if let Ok(term) = env::var("TERM") { + // Only pass along a terminal name which cannot be used to + // smuggle further parameters into the kernel commandline + let is_terminal_name = ! term.is_empty() + && term.len() <= defaults::TERM_NAME_MAX_LEN + && term.chars().all( + |char| char.is_ascii_alphanumeric() || "-_.+".contains(char) + ); + if is_terminal_name { + terminal_boot_args.push(format!("sci_term={term}")) + } else if Lookup::is_debug() { + debug!("Unsupported TERM name, not passed to the instance"); + } + } + if let Some((lines, columns)) = get_terminal_size() { + terminal_boot_args.push(format!("sci_lines={lines}")); + terminal_boot_args.push(format!("sci_columns={columns}")); + } + terminal_boot_args +} + +pub fn get_terminal_size() -> Option<(u16, u16)> { + /*! + Provide the window size of the caller's terminal + !*/ + let mut window_size = std::mem::MaybeUninit::::uninit(); + let result = unsafe { + libc::ioctl( + io::stdin().as_raw_fd(), + libc::TIOCGWINSZ as _, + window_size.as_mut_ptr() + ) + }; + if result == -1 { + return None + } + let window_size = unsafe { window_size.assume_init() }; + if window_size.ws_row == 0 || window_size.ws_col == 0 { + return None + } + Some((window_size.ws_row, window_size.ws_col)) +} + pub fn create_firecracker_config( program_name: &String, config_file: &NamedTempFile @@ -642,6 +696,11 @@ pub fn create_firecracker_config( if engine_section.overlay_size.is_some() { boot_args.push("overlay_root=/dev/vdb".to_string()); } + // hand over the setup of the caller's terminal. sci uses this + // information to setup the pseudo terminal of the command such + // that interactive commands, e.g a shell, can provide features + // like tab completion + boot_args.append(&mut get_terminal_boot_args()); for boot_option in engine_section.boot_args { if (resume || force_vsock) @@ -1069,6 +1128,69 @@ pub fn umount_vm(sub_dir: &str, user: User) -> Result<(), CommandError> { x.into_iter().collect() } +pub struct TerminalMode { + fd: i32, + saved: Option +} + +impl TerminalMode { + pub fn raw(fd: i32) -> Self { + /*! + Switch the given terminal into raw mode + + In raw mode the terminal driver no longer buffers the input + until the line is complete and no longer echoes what was + typed. Every single key stroke is passed on unmodified and + the echo is left to the command on the other end of the + connection. Output post processing stays switched on such + that output which only provides a line feed is still + displayed correctly. The original terminal setup is + restored when the returned object is dropped. + + If the given file descriptor is not a terminal, e.g if the + input is piped, nothing is changed + !*/ + if unsafe { libc::isatty(fd) } != 1 { + return TerminalMode { fd, saved: None } + } + let mut terminal = std::mem::MaybeUninit::::uninit(); + if unsafe { libc::tcgetattr(fd, terminal.as_mut_ptr()) } != 0 { + if Lookup::is_debug() { + debug!( + "tcgetattr failed with: {}", io::Error::last_os_error() + ); + } + return TerminalMode { fd, saved: None } + } + let saved = unsafe { terminal.assume_init() }; + let mut raw = saved; + raw.c_lflag &= !( + libc::ECHO | libc::ECHONL | libc::ICANON | + libc::ISIG | libc::IEXTEN + ); + raw.c_iflag &= !(libc::ICRNL | libc::IXON); + raw.c_cc[libc::VMIN] = 1; + raw.c_cc[libc::VTIME] = 0; + if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) } != 0 { + if Lookup::is_debug() { + debug!( + "tcsetattr failed with: {}", io::Error::last_os_error() + ); + } + return TerminalMode { fd, saved: None } + } + TerminalMode { fd, saved: Some(saved) } + } +} + +impl Drop for TerminalMode { + fn drop(&mut self) { + if let Some(saved) = self.saved { + unsafe { libc::tcsetattr(self.fd, libc::TCSANOW, &saved) }; + } + } +} + pub fn stream_listener(socket_path: &str) -> thread::JoinHandle<()> { let mut socket = String::new(); socket.push_str(socket_path); @@ -1101,8 +1223,18 @@ pub fn stream_io(mut stream: UnixStream) { let stream_fd = stream.as_raw_fd(); let stdin_fd = stdin.as_raw_fd(); let stdout_fd = stdout.as_raw_fd(); + + // Switch the caller's terminal into raw mode for the time of + // the session. Only in raw mode a single key stroke, e.g the + // TAB key, is passed on to the guest instead of being buffered + // until the line is complete. This is the precondition for the + // line editor of an interactive command in the guest, e.g a + // shell, to provide tab completion. The original terminal + // setup is restored when this function returns + let _terminal_mode = TerminalMode::raw(stdin_fd); + // main send/recv loop - let mut buffer = [0_u8; 100]; + let mut buffer = [0_u8; 1]; loop { // prepare file descriptors to be watched for by select() let raw_fdset = std::mem::MaybeUninit::::uninit(); @@ -1148,6 +1280,7 @@ pub fn stream_io(mut stream: UnixStream) { } break; } + let _ = stdout.flush(); } else { if Lookup::is_debug() { debug!("read failure on stdin"); @@ -1157,7 +1290,7 @@ pub fn stream_io(mut stream: UnixStream) { } if unsafe { libc::FD_ISSET(stream_fd, &fdset) } { // something new happened on the stream - // try to receive some bytes an send them to stdout + // try to receive some bytes and send them to stdout if let Ok(sz_r) = stream.read(&mut buffer) { if sz_r == 0 { if Lookup::is_debug() { @@ -1171,6 +1304,7 @@ pub fn stream_io(mut stream: UnixStream) { } break; } + let _ = stdout.flush(); } else { if Lookup::is_debug() { debug!("read failure on stream");