vibetunnel/tty-fwd/src/tty_spawn.rs
2025-06-19 14:32:14 +02:00

1013 lines
35 KiB
Rust

use std::env;
use std::ffi::{CString, OsStr, OsString};
use std::fs::File;
use std::io;
use std::os::fd::{AsFd, BorrowedFd, IntoRawFd, OwnedFd};
use std::os::unix::prelude::{AsRawFd, OpenOptionsExt, OsStrExt};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use crate::protocol::{
AsciinemaEvent, AsciinemaEventType, NotificationEvent, NotificationWriter, SessionInfo,
StreamWriter,
};
use anyhow::Error;
use jiff::Timestamp;
use nix::errno::Errno;
#[cfg(any(
target_os = "macos",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
))]
use nix::libc::login_tty;
use nix::libc::{O_NONBLOCK, TIOCGWINSZ, TIOCSWINSZ, VEOF};
// Define TIOCSCTTY for platforms where it's not exposed by libc
#[cfg(target_os = "linux")]
const TIOCSCTTY: u64 = 0x540E;
use nix::pty::{openpty, Winsize};
use nix::sys::select::{select, FdSet};
use nix::sys::signal::{killpg, Signal};
use nix::sys::stat::Mode;
use nix::sys::termios::{cfmakeraw, tcgetattr, tcsetattr, LocalFlags, SetArg, Termios};
use nix::sys::time::TimeVal;
use nix::sys::wait::{waitpid, WaitStatus};
use nix::unistd::{
close, dup2, execvp, fork, mkfifo, read, setsid, tcgetpgrp, write, ForkResult, Pid,
};
use signal_hook::consts::SIGWINCH;
use tempfile::NamedTempFile;
pub const DEFAULT_TERM: &str = "xterm-256color";
/// Cross-platform implementation of `login_tty`
/// On systems with `login_tty`, use it directly. Otherwise, implement manually.
#[cfg(any(
target_os = "macos",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
))]
unsafe fn login_tty_compat(fd: i32) -> Result<(), Error> {
if login_tty(fd) == 0 {
Ok(())
} else {
Err(Error::msg("login_tty failed"))
}
}
#[cfg(not(any(
target_os = "macos",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd"
)))]
unsafe fn login_tty_compat(fd: i32) -> Result<(), Error> {
// Manual implementation of login_tty for Linux and other systems
// Create a new session
if libc::setsid() == -1 {
return Err(Error::msg("setsid failed"));
}
// Make the tty our controlling terminal
#[cfg(target_os = "linux")]
{
if libc::ioctl(fd, TIOCSCTTY as libc::c_ulong, 0) == -1 {
// Try without forcing
if libc::ioctl(fd, TIOCSCTTY as libc::c_ulong, 1) == -1 {
return Err(Error::msg("ioctl TIOCSCTTY failed"));
}
}
}
#[cfg(not(target_os = "linux"))]
{
// Use the libc constant directly on non-Linux platforms
if libc::ioctl(fd, libc::TIOCSCTTY as libc::c_ulong, 0) == -1 {
// Try without forcing
if libc::ioctl(fd, libc::TIOCSCTTY as libc::c_ulong, 1) == -1 {
return Err(Error::msg("ioctl TIOCSCTTY failed"));
}
}
}
// Duplicate the tty to stdin/stdout/stderr
if libc::dup2(fd, 0) == -1 {
return Err(Error::msg("dup2 stdin failed"));
}
if libc::dup2(fd, 1) == -1 {
return Err(Error::msg("dup2 stdout failed"));
}
if libc::dup2(fd, 2) == -1 {
return Err(Error::msg("dup2 stderr failed"));
}
// Close the original fd if it's not one of the standard descriptors
if fd > 2 {
libc::close(fd);
}
Ok(())
}
/// Creates environment variables for `AsciinemaHeader`
fn create_env_vars(term: &str) -> std::collections::HashMap<String, String> {
let mut env_vars = std::collections::HashMap::new();
env_vars.insert("TERM".to_string(), term.to_string());
// Include other important terminal-related environment variables if they exist
for var in ["SHELL", "LANG", "LC_ALL", "PATH", "USER", "HOME"] {
if let Ok(value) = std::env::var(var) {
env_vars.insert(var.to_string(), value);
}
}
env_vars
}
/// Lets you spawn processes with a TTY connected.
pub struct TtySpawn {
options: Option<SpawnOptions>,
}
impl TtySpawn {
/// Alternative way to construct a [`TtySpawn`].
///
/// Takes an iterator of command and arguments. If the iterator is empty this
/// panicks.
///
/// # Panicks
///
/// If the iterator is empty, this panics.
pub fn new_cmdline<S: AsRef<OsStr>, I: Iterator<Item = S>>(mut cmdline: I) -> Self {
let mut command = vec![cmdline
.next()
.expect("empty cmdline")
.as_ref()
.to_os_string()];
command.extend(cmdline.map(|arg| arg.as_ref().to_os_string()));
Self {
options: Some(SpawnOptions {
command,
stdin_file: None,
stdout_file: None,
control_file: None,
notification_writer: None,
session_json_path: None,
session_name: None,
detached: false,
term: DEFAULT_TERM.to_string(),
}),
}
}
/// Sets a path as input file for stdin.
pub fn stdin_path<P: AsRef<Path>>(&mut self, path: P) -> Result<&mut Self, Error> {
let path = path.as_ref();
mkfifo_atomic(path)?;
// for the justification for write(true) see the explanation on
// stdin_file - we need to open for both read and write to prevent
// polling primitives from reporting ready when no data is available.
let file = File::options()
.read(true)
.write(true)
.custom_flags(O_NONBLOCK)
.open(path)?;
self.options_mut().stdin_file = Some(file);
Ok(self)
}
/// Sets a path as control file for resize and other control commands.
pub fn control_path<P: AsRef<Path>>(&mut self, path: P) -> Result<&mut Self, Error> {
let path = path.as_ref();
mkfifo_atomic(path)?;
let file = File::options()
.read(true)
.write(true)
.custom_flags(O_NONBLOCK)
.open(path)?;
self.options_mut().control_file = Some(file);
Ok(self)
}
/// Sets a path as output file for stdout.
///
/// If the `truncate` flag is set to `true` the file will be truncated
/// first, otherwise it will be appended to.
pub fn stdout_path<P: AsRef<Path>>(
&mut self,
path: P,
truncate: bool,
) -> Result<&mut Self, Error> {
let file = if truncate {
File::options()
.create(true)
.truncate(true)
.write(true)
.open(path)?
} else {
File::options().append(true).create(true).open(path)?
};
self.options_mut().stdout_file = Some(file);
Ok(self)
}
/// Sets the session JSON path for status updates.
pub fn session_json_path<P: AsRef<Path>>(&mut self, path: P) -> &mut Self {
self.options_mut().session_json_path = Some(path.as_ref().to_path_buf());
self
}
/// Sets the session name.
pub fn session_name<S: Into<String>>(&mut self, name: S) -> &mut Self {
self.options_mut().session_name = Some(name.into());
self
}
/// Sets the process to run in detached mode (don't connect to current terminal).
pub const fn detached(&mut self, detached: bool) -> &mut Self {
self.options_mut().detached = detached;
self
}
/// Sets a path as output file for notifications.
pub fn notification_path<P: AsRef<Path>>(&mut self, path: P) -> Result<&mut Self, Error> {
let file = File::options().create(true).append(true).open(path)?;
let notification_writer = NotificationWriter::new(file);
self.options_mut().notification_writer = Some(notification_writer);
Ok(self)
}
/// Sets the TERM environment variable for the spawned process.
pub fn term<S: AsRef<str>>(&mut self, term: S) -> &mut Self {
self.options_mut().term = term.as_ref().to_string();
self
}
/// Spawns the application in the TTY.
pub fn spawn(&mut self) -> Result<i32, Error> {
spawn(self.options.take().expect("builder only works once"))
}
const fn options_mut(&mut self) -> &mut SpawnOptions {
self.options.as_mut().expect("builder only works once")
}
}
struct SpawnOptions {
command: Vec<OsString>,
stdin_file: Option<File>,
stdout_file: Option<File>,
control_file: Option<File>,
notification_writer: Option<NotificationWriter>,
session_json_path: Option<PathBuf>,
session_name: Option<String>,
detached: bool,
term: String,
}
/// Creates a new session JSON file with the provided information
pub fn create_session_info(
session_json_path: &Path,
cmdline: Vec<String>,
name: String,
cwd: String,
term: String,
cols: Option<u16>,
rows: Option<u16>,
) -> Result<(), Error> {
let session_info = SessionInfo {
cmdline,
name,
cwd,
pid: None,
status: "starting".to_string(),
exit_code: None,
started_at: Some(Timestamp::now()),
term,
spawn_type: "socket".to_string(),
cols,
rows,
};
let session_info_str = serde_json::to_string(&session_info)?;
// Write to temporary file first, then move to final location
let temp_file =
NamedTempFile::new_in(session_json_path.parent().unwrap_or_else(|| Path::new(".")))?;
std::fs::write(temp_file.path(), session_info_str)?;
temp_file.persist(session_json_path)?;
Ok(())
}
/// Updates the session status in the JSON file
fn update_session_status(
session_json_path: &Path,
pid: Option<u32>,
status: &str,
exit_code: Option<i32>,
) -> Result<(), Error> {
if let Ok(content) = std::fs::read_to_string(session_json_path) {
if let Ok(mut session_info) = serde_json::from_str::<SessionInfo>(&content) {
if let Some(pid) = pid {
session_info.pid = Some(pid);
}
session_info.status = status.to_string();
if let Some(code) = exit_code {
session_info.exit_code = Some(code);
}
let updated_content = serde_json::to_string(&session_info)?;
// Write to temporary file first, then move to final location
let temp_file = NamedTempFile::new_in(
session_json_path.parent().unwrap_or_else(|| Path::new(".")),
)?;
std::fs::write(temp_file.path(), updated_content)?;
temp_file.persist(session_json_path)?;
}
}
Ok(())
}
/// Spawns a process in a PTY in a manor similar to `script`
/// but with separate stdout/stderr.
///
/// It leaves stdin/stdout/stderr connected but also writes events into the
/// optional `out` log file. Additionally it can retrieve instructions from
/// the given control socket.
fn spawn(mut opts: SpawnOptions) -> Result<i32, Error> {
// if we can't retrieve the terminal atts we're not directly connected
// to a pty in which case we won't do any of the terminal related
// operations. In detached mode, we don't connect to the current terminal.
let term_attrs = if opts.detached {
None
} else {
tcgetattr(io::stdin()).ok()
};
let winsize = if opts.detached {
Some(Winsize {
ws_row: 24,
ws_col: 80,
ws_xpixel: 0,
ws_ypixel: 0,
})
} else {
term_attrs
.as_ref()
.and_then(|_| get_winsize(io::stdin().as_fd()))
};
// Create session info at the beginning if we have a session JSON path
if let Some(ref session_json_path) = opts.session_json_path {
// Get executable name for session name
let executable_name = opts.command[0]
.to_string_lossy()
.split('/')
.next_back()
.unwrap_or("unknown")
.to_string();
// Get current working directory
let current_dir = env::current_dir().map_or_else(
|_| "unknown".to_string(),
|p| p.to_string_lossy().to_string(),
);
let cmdline: Vec<String> = opts
.command
.iter()
.map(|s| s.to_string_lossy().to_string())
.collect();
let session_name = opts.session_name.clone().unwrap_or(executable_name);
create_session_info(
session_json_path,
cmdline.clone(),
session_name.clone(),
current_dir.clone(),
opts.term.clone(),
winsize.as_ref().map(|w| w.ws_col),
winsize.as_ref().map(|w| w.ws_row),
)?;
// Send session started notification
if let Some(ref mut notification_writer) = opts.notification_writer {
let notification = NotificationEvent {
timestamp: Timestamp::now(),
event: "session_started".to_string(),
data: serde_json::json!({
"cmdline": cmdline,
"name": session_name,
"cwd": current_dir
}),
};
let _ = notification_writer.write_notification(notification);
}
}
// Create the outer pty for stdout
let pty = openpty(&winsize, &term_attrs)?;
// We always use raw mode since script_mode and no_raw are always false.
// This switches the terminal to raw mode and restores it on Drop.
// Unfortunately due to all our shenanigans here we have no real guarantee
// that `Drop` is called so there will be cases where the term is left in
// raw state and requires a reset :(
let _restore_term = term_attrs.as_ref().map(|term_attrs| {
let mut raw_attrs = term_attrs.clone();
cfmakeraw(&mut raw_attrs);
raw_attrs.local_flags.remove(LocalFlags::ECHO);
tcsetattr(io::stdin(), SetArg::TCSAFLUSH, &raw_attrs).ok();
RestoreTerm(term_attrs.clone())
});
// set some flags after pty has been created. There are cases where we
// want to remove the ECHO flag so we don't see ^D and similar things in
// the output. Likewise in script mode we want to remove OPOST which will
// otherwise convert LF to CRLF.
// Since script_mode and no_echo are always false, we don't need to
// modify any terminal attributes for the pty master.
// Fork and establish the communication loop in the parent. This unfortunately
// has to merge stdout/stderr since the pseudo terminal only has one stream for
// both.
let detached = opts.detached;
if detached {
// Use double fork to properly daemonize the process
match unsafe { fork()? } {
ForkResult::Parent { child: first_child } => {
// Wait for the first child to exit immediately
let _ = waitpid(first_child, None)?;
drop(pty.slave);
// Start a monitoring thread that doesn't block the parent
// We'll monitor the PTY master for activity and session files
let master_fd = pty.master;
let session_json_path = opts.session_json_path.clone();
let notification_writer = opts.notification_writer;
let stdin_file = opts.stdin_file;
let control_file = opts.control_file;
// Create StreamWriter for detached session if we have an output file
let stream_writer = if let Some(stdout_file) = opts.stdout_file.take() {
StreamWriter::with_params(
stdout_file,
winsize.as_ref().map_or(80, |x| u32::from(x.ws_col)),
winsize.as_ref().map_or(24, |x| u32::from(x.ws_row)),
Some(
opts.command
.iter()
.map(|s| s.to_string_lossy().to_string())
.collect::<Vec<_>>()
.join(" "),
),
opts.session_name,
Some(create_env_vars(&opts.term)),
)
.ok()
} else {
None
};
std::thread::spawn(move || {
// Monitor the session by watching the PTY and session files
let _ = monitor_detached_session(
master_fd,
session_json_path.as_deref(),
notification_writer,
stream_writer,
stdin_file,
control_file,
);
});
return Ok(0);
}
ForkResult::Child => {
// First child - fork again and exit immediately
match unsafe { fork()? } {
ForkResult::Parent { .. } => {
// First child exits immediately to orphan the grandchild
std::process::exit(0);
}
ForkResult::Child => {
// Grandchild - this becomes the daemon
// Continue to the child process setup below
}
}
}
}
} else if let ForkResult::Parent { child } = unsafe { fork()? } {
drop(pty.slave);
let stderr_pty = None; // Always None since script_mode is always false
// Update session status to running with PID
if let Some(ref session_json_path) = opts.session_json_path {
let _ = update_session_status(
session_json_path,
Some(child.as_raw() as u32),
"running",
None,
);
}
// Create StreamWriter if we have an output file
let mut stream_writer = if let Some(stdout_file) = opts.stdout_file.take() {
StreamWriter::with_params(
stdout_file,
winsize.as_ref().map_or(80, |x| u32::from(x.ws_col)),
winsize.as_ref().map_or(24, |x| u32::from(x.ws_row)),
Some(
opts.command
.iter()
.map(|s| s.to_string_lossy().to_string())
.collect::<Vec<_>>()
.join(" "),
),
opts.session_name,
Some(create_env_vars(&opts.term)),
)
.ok()
} else {
None
};
let exit_code = communication_loop(
pty.master,
child,
term_attrs.is_some() && !opts.detached,
stream_writer.as_mut(),
opts.stdin_file.as_mut(),
stderr_pty,
true, // flush is always enabled
opts.notification_writer.as_mut(),
opts.session_json_path.as_deref(),
)?;
// Send exit event to stream before updating session status
if let Some(ref mut stream_writer) = stream_writer {
let session_id = opts
.session_json_path
.as_ref()
.and_then(|p| p.file_stem())
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let exit_event = serde_json::json!(["exit", exit_code, session_id]);
let _ = stream_writer.write_raw_json(&exit_event);
}
// Update session status to exited with exit code
if let Some(ref session_json_path) = opts.session_json_path {
let _ = update_session_status(session_json_path, None, "exited", Some(exit_code));
}
// Send session exited notification
if let Some(ref mut notification_writer) = opts.notification_writer {
let notification = NotificationEvent {
timestamp: Timestamp::now(),
event: "session_exited".to_string(),
data: serde_json::json!({
"exit_code": exit_code
}),
};
let _ = notification_writer.write_notification(notification);
}
return Ok(exit_code);
}
// Since no_pager and script_mode are always false, we don't set PAGER.
// If we reach this point we're the child and we want to turn into the
// target executable after having set up the tty with `login_tty` which
// rebinds stdin/stdout/stderr to the pty.
let args = opts
.command
.iter()
.filter_map(|x| CString::new(x.as_bytes()).ok())
.collect::<Vec<_>>();
drop(pty.master);
if detached {
// Set TERM environment variable for the child process
env::set_var("TERM", &opts.term);
// In detached mode, manually set up file descriptors without login_tty
// This prevents the child from connecting to the current terminal
// Create a new session to detach from controlling terminal
let _ = setsid();
// Update session status with the actual daemon PID
if let Some(ref session_json_path) = opts.session_json_path {
let daemon_pid = std::process::id();
let _ = update_session_status(session_json_path, Some(daemon_pid), "running", None);
}
// Redirect stdin, stdout, stderr to the pty slave
use std::os::fd::{FromRawFd, OwnedFd};
let slave_fd = pty.slave.as_raw_fd();
// Create OwnedFd for slave and standard file descriptors
let slave_owned_fd = unsafe { OwnedFd::from_raw_fd(slave_fd) };
let mut stdin_fd = unsafe { OwnedFd::from_raw_fd(0) };
let mut stdout_fd = unsafe { OwnedFd::from_raw_fd(1) };
let mut stderr_fd = unsafe { OwnedFd::from_raw_fd(2) };
dup2(&slave_owned_fd, &mut stdin_fd).expect("Failed to dup2 stdin");
dup2(&slave_owned_fd, &mut stdout_fd).expect("Failed to dup2 stdout");
dup2(&slave_owned_fd, &mut stderr_fd).expect("Failed to dup2 stderr");
// Forget the OwnedFd instances to prevent them from being closed
std::mem::forget(stdin_fd);
std::mem::forget(stdout_fd);
std::mem::forget(stderr_fd);
std::mem::forget(slave_owned_fd);
// Close the original slave fd if it's not one of the standard fds
if slave_fd > 2 {
close(slave_fd).ok();
}
} else {
unsafe {
login_tty_compat(pty.slave.into_raw_fd())?;
// No stderr redirection since script_mode is always false
}
}
// Since this returns Infallible rather than ! due to limitations, we need
// this dummy match.
match execvp(&args[0], &args)? {}
}
#[allow(clippy::too_many_arguments)]
fn communication_loop(
master: OwnedFd,
child: Pid,
is_tty: bool,
mut stream_writer: Option<&mut StreamWriter>,
in_file: Option<&mut File>,
stderr: Option<OwnedFd>,
flush: bool,
_notification_writer: Option<&mut NotificationWriter>,
_session_json_path: Option<&Path>,
) -> Result<i32, Error> {
let mut buf = [0; 4096];
let mut read_stdin = is_tty;
let mut done = false;
let stdin = io::stdin();
let got_winch = Arc::new(AtomicBool::new(false));
if is_tty {
signal_hook::flag::register(SIGWINCH, Arc::clone(&got_winch)).ok();
}
while !done {
if got_winch.load(Ordering::Relaxed) {
forward_winsize(
master.as_fd(),
stderr.as_ref().map(|x| x.as_fd()),
&mut stream_writer,
)?;
got_winch.store(false, Ordering::Relaxed);
}
let mut read_fds = FdSet::new();
let mut timeout = TimeVal::new(0, 100_000); // 100ms timeout
read_fds.insert(master.as_fd());
if !read_stdin && is_tty {
read_stdin = true;
}
if read_stdin {
read_fds.insert(stdin.as_fd());
}
if let Some(ref f) = in_file {
read_fds.insert(f.as_fd());
}
if let Some(ref fd) = stderr {
read_fds.insert(fd.as_fd());
}
match select(None, Some(&mut read_fds), None, None, Some(&mut timeout)) {
Ok(0) => {
// Timeout occurred - just continue
continue;
}
Err(Errno::EINTR | Errno::EAGAIN) => continue,
Ok(_) => {}
Err(err) => return Err(err.into()),
}
if read_fds.contains(stdin.as_fd()) {
match read(&stdin, &mut buf) {
Ok(0) => {
send_eof_sequence(master.as_fd());
read_stdin = false;
}
Ok(n) => {
write_all(master.as_fd(), &buf[..n])?;
}
Err(Errno::EINTR | Errno::EAGAIN) => {}
// on linux a closed tty raises EIO
Err(Errno::EIO) => {
done = true;
}
Err(err) => return Err(err.into()),
}
}
if let Some(ref f) = in_file {
if read_fds.contains(f.as_fd()) {
// use read() here so that we can handle EAGAIN/EINTR
// without this we might receive resource temporary unavailable
// see https://github.com/mitsuhiko/teetty/issues/3
match read(f, &mut buf) {
Ok(0) | Err(Errno::EAGAIN | Errno::EINTR) => {}
Err(err) => return Err(err.into()),
Ok(n) => {
write_all(master.as_fd(), &buf[..n])?;
}
}
}
}
if let Some(ref fd) = stderr {
if read_fds.contains(fd.as_fd()) {
match read(fd, &mut buf) {
Ok(0) | Err(_) => {}
Ok(n) => {
forward_and_log(io::stderr().as_fd(), &mut None, &buf[..n], flush)?;
}
}
}
}
if read_fds.contains(master.as_fd()) {
match read(&master, &mut buf) {
// on linux a closed tty raises EIO
Ok(0) | Err(Errno::EIO) => {
done = true;
}
Ok(n) => {
forward_and_log(io::stdout().as_fd(), &mut stream_writer, &buf[..n], flush)?;
}
Err(Errno::EAGAIN | Errno::EINTR) => {}
Err(err) => return Err(err.into()),
}
}
}
Ok(match waitpid(child, None)? {
WaitStatus::Exited(_, status) => status,
WaitStatus::Signaled(_, signal, _) => 128 + signal as i32,
_ => 1,
})
}
fn forward_and_log(
fd: BorrowedFd,
stream_writer: &mut Option<&mut StreamWriter>,
buf: &[u8],
_flush: bool,
) -> Result<(), Error> {
if let Some(writer) = stream_writer {
writer.write_output(buf)?;
}
write_all(fd, buf)?;
Ok(())
}
/// Forwards the winsize and emits SIGWINCH
fn forward_winsize(
master: BorrowedFd,
stderr_master: Option<BorrowedFd>,
stream_writer: &mut Option<&mut StreamWriter>,
) -> Result<(), Error> {
if let Some(winsize) = get_winsize(io::stdin().as_fd()) {
set_winsize(master, winsize).ok();
if let Some(second_master) = stderr_master {
set_winsize(second_master, winsize).ok();
}
if let Ok(pgrp) = tcgetpgrp(master) {
killpg(pgrp, Signal::SIGWINCH).ok();
}
// Log resize event to stream writer
if let Some(writer) = stream_writer {
let time = writer.elapsed_time();
let event = AsciinemaEvent {
time,
event_type: AsciinemaEventType::Resize,
data: format!("{col}x{row}", col = winsize.ws_col, row = winsize.ws_row),
};
writer.write_event(event)?;
}
}
Ok(())
}
/// If possible, returns the terminal size of the given fd.
fn get_winsize(fd: BorrowedFd) -> Option<Winsize> {
nix::ioctl_read_bad!(_get_window_size, TIOCGWINSZ, Winsize);
let mut size: Winsize = unsafe { std::mem::zeroed() };
unsafe { _get_window_size(fd.as_raw_fd(), &mut size).ok()? };
Some(size)
}
/// Sets the winsize
fn set_winsize(fd: BorrowedFd, winsize: Winsize) -> Result<(), Error> {
nix::ioctl_write_ptr_bad!(_set_window_size, TIOCSWINSZ, Winsize);
unsafe { _set_window_size(fd.as_raw_fd(), &winsize) }?;
Ok(())
}
/// Sends an EOF signal to the terminal if it's in canonical mode.
fn send_eof_sequence(fd: BorrowedFd) {
if let Ok(attrs) = tcgetattr(fd) {
if attrs.local_flags.contains(LocalFlags::ICANON) {
write(fd, &[attrs.control_chars[VEOF]]).ok();
}
}
}
/// Calls write in a loop until it's done.
fn write_all(fd: BorrowedFd, mut buf: &[u8]) -> Result<(), Error> {
while !buf.is_empty() {
// we generally assume that EINTR/EAGAIN can't happen on write()
let n = write(fd, buf)?;
buf = &buf[n..];
}
Ok(())
}
/// Creates a FIFO at the path if the file does not exist yet.
fn mkfifo_atomic(path: &Path) -> Result<(), Error> {
match mkfifo(path, Mode::S_IRUSR | Mode::S_IWUSR) {
Ok(()) | Err(Errno::EEXIST) => Ok(()),
Err(err) => Err(err.into()),
}
}
struct RestoreTerm(Termios);
impl Drop for RestoreTerm {
fn drop(&mut self) {
tcsetattr(io::stdin(), SetArg::TCSAFLUSH, &self.0).ok();
}
}
/// Monitors a detached session by running a communication loop
fn monitor_detached_session(
master: OwnedFd,
session_json_path: Option<&Path>,
mut notification_writer: Option<NotificationWriter>,
mut stream_writer: Option<StreamWriter>,
stdin_file: Option<File>,
control_file: Option<File>,
) -> Result<(), Error> {
let mut buf = [0; 4096];
let mut done = false;
while !done {
let mut read_fds = FdSet::new();
let mut timeout = TimeVal::new(0, 100_000); // 100ms timeout
read_fds.insert(master.as_fd());
if let Some(ref f) = stdin_file {
read_fds.insert(f.as_fd());
}
if let Some(ref f) = control_file {
read_fds.insert(f.as_fd());
}
match select(None, Some(&mut read_fds), None, None, Some(&mut timeout)) {
Ok(0) => {
// Timeout occurred - just continue
continue;
}
Err(Errno::EINTR | Errno::EAGAIN) => continue,
Ok(_) => {}
Err(err) => return Err(err.into()),
}
if let Some(ref f) = control_file {
if read_fds.contains(f.as_fd()) {
match read(f, &mut buf) {
Ok(0) | Err(Errno::EAGAIN | Errno::EINTR) => {}
Err(err) => return Err(err.into()),
Ok(n) => {
// Parse control command
if let Ok(cmd_str) = std::str::from_utf8(&buf[..n]) {
for line in cmd_str.lines() {
if let Ok(cmd) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(cmd_type) = cmd.get("cmd").and_then(|v| v.as_str()) {
if cmd_type == "resize" {
if let (Some(cols), Some(rows)) = (
cmd.get("cols").and_then(|v| v.as_u64()),
cmd.get("rows").and_then(|v| v.as_u64()),
) {
let winsize = Winsize {
ws_row: rows as u16,
ws_col: cols as u16,
ws_xpixel: 0,
ws_ypixel: 0,
};
if let Err(e) = set_winsize(master.as_fd(), winsize) {
eprintln!("Failed to resize terminal: {}", e);
} else {
// Log resize event
if let Some(writer) = &mut stream_writer {
let time = writer.elapsed_time();
let data = format!("{}x{}", cols, rows);
let event = AsciinemaEvent {
time,
event_type: AsciinemaEventType::Resize,
data,
};
let _ = writer.write_event(event);
}
}
}
}
}
}
}
}
}
}
}
}
if let Some(ref f) = stdin_file {
if read_fds.contains(f.as_fd()) {
match read(f, &mut buf) {
Ok(0) | Err(Errno::EAGAIN | Errno::EINTR) => {}
Err(err) => return Err(err.into()),
Ok(n) => {
write_all(master.as_fd(), &buf[..n])?;
}
}
}
}
if read_fds.contains(master.as_fd()) {
match read(&master, &mut buf) {
// on linux a closed tty raises EIO
Ok(0) | Err(Errno::EIO) => {
done = true;
}
Ok(n) => {
// Only log to stream writer, don't write to stdout since we're detached
if let Some(writer) = &mut stream_writer {
let time = writer.elapsed_time();
let data = String::from_utf8_lossy(&buf[..n]).to_string();
let event = AsciinemaEvent {
time,
event_type: AsciinemaEventType::Output,
data,
};
writer.write_event(event)?;
}
}
Err(Errno::EAGAIN | Errno::EINTR) => {}
Err(err) => return Err(err.into()),
}
}
}
// Send exit event to stream before updating session status
if let Some(ref mut stream_writer) = stream_writer {
let session_id = session_json_path
.and_then(|p| p.file_stem())
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let exit_event = serde_json::json!(["exit", 0, session_id]);
let _ = stream_writer.write_raw_json(&exit_event);
}
// Update session status to exited
if let Some(session_json_path) = session_json_path {
let _ = update_session_status(session_json_path, None, "exited", Some(0));
}
// Send session exited notification
if let Some(ref mut notification_writer) = notification_writer {
let notification = NotificationEvent {
timestamp: Timestamp::now(),
event: "session_exited".to_string(),
data: serde_json::json!({
"exit_code": 0
}),
};
let _ = notification_writer.write_notification(notification);
}
Ok(())
}