Relocate tty metadata and protocols and clean it up

fish-shell attempts to set up certain terminal protocols (bracketed paste,
CSI-U) while it is in control of the tty, and disable these when passing
off the tty to other processes. These terminal protocols are enabled or
disabled by emitting certain control sequences to the tty.

Today fish-shell does this in a somewhat haphazard way, tracking whether
the protocols are enabled or disabled. Functions like `Parser::exec_node`
then just toggle these, causing data to be written to the terminal in
unexpected places. In particular this is very bad for concurrent execution:
we don't want random threads talking to the tty.

Fortunately we have a controlled place where we can muck with the tty:
`TtyTransfer` which controls handoff of ownership to child processes (via
`tcsetpgrp`). Let's centralize logic around enabling and disabling terminal
protocols there. Put it in a new module and rename it to `TtyHandoff` which is a
little nicer.

This commit moves code around and does some cleanup; it doesn't actually
pull the trigger on centralizing the logic though. Next commit will do that.
This commit is contained in:
Peter Ammon
2025-06-19 17:48:36 -07:00
parent 65a4cb5245
commit f0e007c439
8 changed files with 595 additions and 353 deletions

View File

@@ -5,7 +5,7 @@
use crate::reader::reader_write_title;
use crate::tokenizer::tok_command;
use crate::wutil::perror;
use crate::{env::EnvMode, proc::TtyTransfer};
use crate::{env::EnvMode, tty_handoff::TtyHandoff};
use libc::{STDIN_FILENO, TCSADRAIN};
use super::prelude::*;
@@ -155,16 +155,16 @@ pub fn fg(parser: &Parser, streams: &mut IoStreams, argv: &mut [&wstr]) -> Built
}
}
}
let mut transfer = TtyTransfer::new();
transfer.to_job_group(job.group.as_ref().unwrap());
let mut handoff = TtyHandoff::new();
handoff.to_job_group(job.group.as_ref().unwrap());
let resumed = job.resume();
if resumed {
job.continue_job(parser);
}
if job.is_stopped() {
transfer.save_tty_modes();
handoff.save_tty_modes();
}
transfer.reclaim();
handoff.reclaim();
if resumed {
Ok(SUCCESS)
} else {

View File

@@ -20,8 +20,8 @@
env::{env_init, EnvStack, Environment},
future_feature_flags,
input_common::{
match_key_event_to_key, terminal_protocol_hacks, terminal_protocols_enable_ifn, CharEvent,
InputEventQueue, InputEventQueuer, KeyEvent, QueryResponseEvent, TerminalQuery,
match_key_event_to_key, terminal_protocols_enable_ifn, CharEvent, InputEventQueue,
InputEventQueuer, KeyEvent, QueryResponseEvent, TerminalQuery,
},
key::{char_to_symbol, Key},
nix::isatty,
@@ -33,6 +33,7 @@
terminal::{Capability, KITTY_KEYBOARD_SUPPORTED},
threads,
topic_monitor::topic_monitor_init,
tty_handoff::initialize_tty_metadata,
wchar::prelude::*,
wgetopt::{wopt, ArgType, WGetopter, WOption},
};
@@ -151,7 +152,7 @@ fn setup_and_process_keys(
// We need to set the shell-modes for ICRNL,
// in fish-proper this is done once a command is run.
unsafe { libc::tcsetattr(0, TCSANOW, &*shell_modes()) };
terminal_protocol_hacks();
initialize_tty_metadata();
let blocking_query: OnceCell<RefCell<Option<TerminalQuery>>> = OnceCell::new();
initial_query(&blocking_query, streams.out, None);

View File

@@ -38,12 +38,12 @@
use crate::proc::{
hup_jobs, is_interactive_session, jobs_requiring_warning_on_exit, no_exec,
print_exit_warning_for_jobs, InternalProc, Job, JobGroupRef, ProcStatus, Process, ProcessType,
TtyTransfer,
};
use crate::reader::{reader_run_count, safe_restore_term_mode};
use crate::redirection::{dup2_list_resolve_chain, Dup2List};
use crate::threads::{iothread_perform_cant_wait, is_forked_child};
use crate::trace::trace_if_enabled_with_args;
use crate::tty_handoff::TtyHandoff;
use crate::wchar::prelude::*;
use crate::wchar_ext::ToWString;
use crate::wutil::{fish_wcstol, perror};
@@ -110,7 +110,7 @@ pub fn exec_job(parser: &Parser, job: &Job, block_io: IoChain) -> bool {
let deferred_process = get_deferred_process(job);
// We may want to transfer tty ownership to the pgroup leader.
let mut transfer = TtyTransfer::new();
let mut handoff = TtyHandoff::new();
// This loop loops over every process_t in the job, starting it as appropriate. This turns out
// to be rather complex, since a process_t can be one of many rather different things.
@@ -175,7 +175,7 @@ pub fn exec_job(parser: &Parser, job: &Job, block_io: IoChain) -> bool {
// Transfer tty?
if p.leads_pgrp && job.group().wants_terminal() {
transfer.to_job_group(job.group.as_ref().unwrap());
handoff.to_job_group(job.group.as_ref().unwrap());
}
}
drop(pipe_next_read);
@@ -236,9 +236,9 @@ pub fn exec_job(parser: &Parser, job: &Job, block_io: IoChain) -> bool {
}
if job.is_stopped() {
transfer.save_tty_modes();
handoff.save_tty_modes();
}
transfer.reclaim();
handoff.reclaim();
true
}

View File

@@ -1,28 +1,21 @@
use crate::common::{
fish_reserved_codepoint, is_windows_subsystem_for_linux, read_blocked, shell_modes,
str2wcstring, ScopeGuard, WSL,
str2wcstring, WSL,
};
use crate::env::{EnvStack, Environment};
use crate::fd_readable_set::{FdReadableSet, Timeout};
use crate::flog::{FloggableDebug, FloggableDisplay, FLOG};
use crate::fork_exec::flog_safe::FLOG_SAFE;
use crate::global_safety::RelaxedAtomicBool;
use crate::key::{
self, alt, canonicalize_control_char, canonicalize_keyed_control_char, char_to_symbol, ctrl,
function_key, shift, Key, Modifiers, ViewportPosition,
};
use crate::reader::{reader_current_data, reader_test_and_clear_interrupted};
use crate::terminal::TerminalCommand::{
ApplicationKeypadModeDisable, ApplicationKeypadModeEnable, DecrstBracketedPaste,
DecrstFocusReporting, DecsetBracketedPaste, DecsetFocusReporting,
KittyKeyboardProgressiveEnhancementsDisable, KittyKeyboardProgressiveEnhancementsEnable,
ModifyOtherKeysDisable, ModifyOtherKeysEnable,
};
use crate::reader::reader_current_data;
use crate::reader::reader_test_and_clear_interrupted;
use crate::terminal::{
Capability, Output, Outputter, KITTY_KEYBOARD_SUPPORTED, SCROLL_FORWARD_SUPPORTED,
SCROLL_FORWARD_TERMINFO_CODE,
Capability, KITTY_KEYBOARD_SUPPORTED, SCROLL_FORWARD_SUPPORTED, SCROLL_FORWARD_TERMINFO_CODE,
};
use crate::threads::{iothread_port, is_main_thread};
use crate::threads::iothread_port;
use crate::tty_handoff::set_tty_protocols_active;
use crate::universal_notifier::default_notifier;
use crate::wchar::{encode_byte_to_char, prelude::*};
use crate::wutil::encoding::{mbrtowc, mbstate_t, zero_mbstate};
@@ -31,9 +24,8 @@
use std::collections::VecDeque;
use std::mem::MaybeUninit;
use std::os::fd::RawFd;
use std::os::unix::ffi::OsStrExt;
use std::ptr;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
// The range of key codes for inputrc-style keyboard functions.
@@ -645,120 +637,6 @@ pub fn update_wait_on_sequence_key_ms(vars: &EnvStack) {
}
}
static TERMINAL_PROTOCOLS: AtomicBool = AtomicBool::new(false);
static BRACKETED_PASTE: AtomicBool = AtomicBool::new(false);
static IS_TMUX: RelaxedAtomicBool = RelaxedAtomicBool::new(false);
pub(crate) static IN_MIDNIGHT_COMMANDER: RelaxedAtomicBool = RelaxedAtomicBool::new(false);
pub(crate) static IN_DVTM: RelaxedAtomicBool = RelaxedAtomicBool::new(false);
static ITERM_NO_KITTY_KEYBOARD: RelaxedAtomicBool = RelaxedAtomicBool::new(false);
pub fn terminal_protocol_hacks() {
use std::env::var_os;
IN_MIDNIGHT_COMMANDER.store(var_os("MC_TMPDIR").is_some());
IN_DVTM
.store(var_os("TERM").is_some_and(|term| term.as_os_str().as_bytes() == b"dvtm-256color"));
IS_TMUX.store(var_os("TMUX").is_some());
ITERM_NO_KITTY_KEYBOARD.store(
var_os("LC_TERMINAL").is_some_and(|term| term.as_os_str().as_bytes() == b"iTerm2")
&& var_os("LC_TERMINAL_VERSION").is_some_and(|version| {
let Some(version) = parse_version(&str2wcstring(version.as_os_str().as_bytes()))
else {
return false;
};
version < (3, 5, 12)
}),
);
}
fn parse_version(version: &wstr) -> Option<(i64, i64, i64)> {
let mut numbers = version.split('.');
let major = fish_wcstol(numbers.next()?).ok()?;
let minor = fish_wcstol(numbers.next()?).ok()?;
let patch = numbers.next()?;
let patch = &patch[..patch
.chars()
.position(|c| !c.is_ascii_digit())
.unwrap_or(patch.len())];
let patch = fish_wcstol(patch).ok()?;
Some((major, minor, patch))
}
#[test]
fn test_parse_version() {
assert_eq!(parse_version(L!("3.5.2")), Some((3, 5, 2)));
assert_eq!(parse_version(L!("3.5.3beta")), Some((3, 5, 3)));
}
pub fn terminal_protocols_enable_ifn() {
let did_write = RelaxedAtomicBool::new(false);
let _save_screen_state = ScopeGuard::new((), |()| {
if did_write.load() {
reader_current_data().map(|data| data.save_screen_state());
}
});
let mut out = Outputter::stdoutput().borrow_mut();
if !BRACKETED_PASTE.load(Ordering::Relaxed) {
BRACKETED_PASTE.store(true, Ordering::Release);
out.write_command(DecsetBracketedPaste);
if IS_TMUX.load() {
out.write_command(DecsetFocusReporting);
}
did_write.store(true);
}
let kitty_keyboard_supported = KITTY_KEYBOARD_SUPPORTED.load(Ordering::Relaxed);
if kitty_keyboard_supported == Capability::Unknown as _ {
return;
}
if TERMINAL_PROTOCOLS.load(Ordering::Relaxed) {
return;
}
TERMINAL_PROTOCOLS.store(true, Ordering::Release);
FLOG!(term_protocols, "Enabling extended keys");
if kitty_keyboard_supported == Capability::NotSupported as _ || ITERM_NO_KITTY_KEYBOARD.load() {
out.write_command(ModifyOtherKeysEnable); // XTerm's modifyOtherKeys
out.write_command(ApplicationKeypadModeEnable); // set application keypad mode, so the keypad keys send unique codes
} else {
out.write_command(KittyKeyboardProgressiveEnhancementsEnable);
}
did_write.store(true);
}
pub(crate) fn terminal_protocols_disable_ifn() {
let did_write = RelaxedAtomicBool::new(false);
let _save_screen_state = is_main_thread().then(|| {
ScopeGuard::new((), |()| {
if did_write.load() {
reader_current_data().map(|data| data.save_screen_state());
}
})
});
let mut out = Outputter::stdoutput().borrow_mut();
if BRACKETED_PASTE.load(Ordering::Acquire) {
out.write_command(DecrstBracketedPaste);
if IS_TMUX.load() {
out.write_command(DecrstFocusReporting);
}
BRACKETED_PASTE.store(false, Ordering::Release);
did_write.store(true);
}
if !TERMINAL_PROTOCOLS.load(Ordering::Acquire) {
return;
}
FLOG_SAFE!(term_protocols, "Disabling extended keys");
let kitty_keyboard_supported = KITTY_KEYBOARD_SUPPORTED.load(Ordering::Acquire);
assert_ne!(kitty_keyboard_supported, Capability::Unknown as _);
if kitty_keyboard_supported == Capability::NotSupported as _ || ITERM_NO_KITTY_KEYBOARD.load() {
out.write_command(ModifyOtherKeysDisable);
out.write_command(ApplicationKeypadModeDisable);
} else {
out.write_command(KittyKeyboardProgressiveEnhancementsDisable);
}
TERMINAL_PROTOCOLS.store(false, Ordering::Release);
did_write.store(true);
}
fn parse_mask(mask: u32) -> (Modifiers, bool) {
let modifiers = Modifiers {
ctrl: (mask & 4) != 0,
@@ -770,6 +648,22 @@ fn parse_mask(mask: u32) -> (Modifiers, bool) {
(modifiers, caps_lock)
}
// Trampolines to enable or disable terminal protocols.
pub fn terminal_protocols_enable_ifn() {
if set_tty_protocols_active(true) {
// Our tty has been modified, so save the current timestamps so that
// the reader doesn't believe it has to repaint the prompt.
reader_current_data().map(|data| data.save_screen_state());
}
}
pub fn terminal_protocols_disable_ifn() {
if set_tty_protocols_active(false) {
// Our tty has been modified, so save the current timestamps so that
// the reader doesn't believe it has to repaint the prompt.
reader_current_data().map(|data| data.save_screen_state());
}
}
// A data type used by the input machinery.
pub struct InputData {
// The file descriptor from which we read input, often stdin.

View File

@@ -96,6 +96,7 @@
pub mod tokenizer;
pub mod topic_monitor;
pub mod trace;
pub mod tty_handoff;
pub mod universal_notifier;
pub mod util;
pub mod wait_handle;

View File

@@ -22,12 +22,11 @@
use crate::wait_handle::{InternalJobId, WaitHandle, WaitHandleRef, WaitHandleStore};
use crate::wchar::prelude::*;
use crate::wchar_ext::ToWString;
use crate::wutil::{perror, wbasename, wperror};
use crate::wutil::{wbasename, wperror};
use libc::{
EBADF, EINVAL, ENOTTY, EPERM, EXIT_SUCCESS, SIGABRT, SIGBUS, SIGCONT, SIGFPE, SIGHUP, SIGILL,
SIGINT, SIGKILL, SIGPIPE, SIGQUIT, SIGSEGV, SIGSYS, SIGTTOU, SIG_DFL, SIG_IGN, STDIN_FILENO,
WCONTINUED, WEXITSTATUS, WIFCONTINUED, WIFEXITED, WIFSIGNALED, WIFSTOPPED, WNOHANG, WTERMSIG,
WUNTRACED, _SC_CLK_TCK,
EXIT_SUCCESS, SIGABRT, SIGBUS, SIGCONT, SIGFPE, SIGHUP, SIGILL, SIGINT, SIGKILL, SIGPIPE,
SIGQUIT, SIGSEGV, SIGSYS, SIGTTOU, SIG_DFL, SIG_IGN, WCONTINUED, WEXITSTATUS, WIFCONTINUED,
WIFEXITED, WIFSIGNALED, WIFSTOPPED, WNOHANG, WTERMSIG, WUNTRACED, _SC_CLK_TCK,
};
use once_cell::sync::Lazy;
#[cfg(not(target_has_atomic = "64"))]
@@ -35,7 +34,6 @@
use std::cell::{Cell, Ref, RefCell, RefMut};
use std::fs;
use std::io::{Read, Write};
use std::mem::MaybeUninit;
use std::num::NonZeroU32;
use std::os::fd::RawFd;
use std::rc::Rc;
@@ -270,202 +268,6 @@ pub fn get_id(&self) -> u64 {
}
}
// Allows transferring the tty to a job group, while it runs.
#[derive(Default)]
pub struct TtyTransfer {
// The job group which owns the tty, or empty if none.
owner: Option<JobGroupRef>,
}
impl TtyTransfer {
pub fn new() -> Self {
Default::default()
}
/// Transfer to the given job group, if it wants to own the terminal.
#[allow(clippy::wrong_self_convention)]
pub fn to_job_group(&mut self, jg: &JobGroupRef) {
assert!(self.owner.is_none(), "Terminal already transferred");
if TtyTransfer::try_transfer(jg) {
self.owner = Some(jg.clone());
}
}
/// Reclaim the tty if we transferred it.
pub fn reclaim(&mut self) {
if self.owner.is_some() {
FLOG!(proc_pgroup, "fish reclaiming terminal");
if unsafe { libc::tcsetpgrp(STDIN_FILENO, libc::getpgrp()) } == -1 {
FLOG!(warning, "Could not return shell to foreground");
perror("tcsetpgrp");
}
self.owner = None;
}
}
/// Save the current tty modes into the owning job group, if we are transferred.
pub fn save_tty_modes(&mut self) {
if let Some(ref mut owner) = self.owner {
let mut tmodes = MaybeUninit::uninit();
if unsafe { libc::tcgetattr(STDIN_FILENO, tmodes.as_mut_ptr()) } == 0 {
owner.tmodes.replace(Some(unsafe { tmodes.assume_init() }));
} else if errno::errno().0 != ENOTTY {
perror("tcgetattr");
}
}
}
fn try_transfer(jg: &JobGroup) -> bool {
if !jg.wants_terminal() {
// The job doesn't want the terminal.
return false;
}
// Get the pgid; we must have one if we want the terminal.
let pgid = jg.get_pgid().unwrap();
// It should never be fish's pgroup.
let fish_pgrp = crate::nix::getpgrp();
assert!(
pgid.as_pid_t() != fish_pgrp,
"Job should not have fish's pgroup"
);
// Ok, we want to transfer to the child.
// Note it is important to be very careful about calling tcsetpgrp()!
// fish ignores SIGTTOU which means that it has the power to reassign the tty even if it doesn't
// own it. This means that other processes may get SIGTTOU and become zombies.
// Check who own the tty now. There's four cases of interest:
// 1. There is no tty at all (tcgetpgrp() returns -1). For example running from a pure script.
// Of course do not transfer it in that case.
// 2. The tty is owned by the process. This comes about often, as the process will call
// tcsetpgrp() on itself between fork and exec. This is the essential race inherent in
// tcsetpgrp(). In this case we want to reclaim the tty, but do not need to transfer it
// ourselves since the child won the race.
// 3. The tty is owned by a different process. This may come about if fish is running in the
// background with job control enabled. Do not transfer it.
// 4. The tty is owned by fish. In that case we want to transfer the pgid.
let current_owner = unsafe { libc::tcgetpgrp(STDIN_FILENO) };
if current_owner < 0 {
// Case 1.
return false;
} else if current_owner == pgid.get() {
// Case 2.
return true;
} else if current_owner != pgid.get() && current_owner != fish_pgrp {
// Case 3.
return false;
}
// Case 4 - we do want to transfer it.
// The tcsetpgrp(2) man page says that EPERM is thrown if "pgrp has a supported value, but
// is not the process group ID of a process in the same session as the calling process."
// Since we _guarantee_ that this isn't the case (the child calls setpgid before it calls
// SIGSTOP, and the child was created in the same session as us), it seems that EPERM is
// being thrown because of an caching issue - the call to tcsetpgrp isn't seeing the
// newly-created process group just yet. On this developer's test machine (WSL running Linux
// 4.4.0), EPERM does indeed disappear on retry. The important thing is that we can
// guarantee the process isn't going to exit while we wait (which would cause us to possibly
// block indefinitely).
while unsafe { libc::tcsetpgrp(STDIN_FILENO, pgid.as_pid_t()) } != 0 {
FLOGF!(proc_termowner, "tcsetpgrp failed: %d", errno::errno().0);
// Before anything else, make sure that it's even necessary to call tcsetpgrp.
// Since it usually _is_ necessary, we only check in case it fails so as to avoid the
// unnecessary syscall and associated context switch, which profiling has shown to have
// a significant cost when running process groups in quick succession.
let getpgrp_res = unsafe { libc::tcgetpgrp(STDIN_FILENO) };
if getpgrp_res < 0 {
match errno::errno().0 {
ENOTTY | EBADF => {
// stdin is not a tty. This may come about if job control is enabled but we are
// not a tty - see #6573.
return false;
}
_ => {
perror("tcgetpgrp");
return false;
}
}
}
if getpgrp_res == pgid.get() {
FLOGF!(
proc_termowner,
"Process group %d already has control of terminal",
pgid
);
return true;
}
let pgroup_terminated;
if errno::errno().0 == EINVAL {
// OS X returns EINVAL if the process group no longer lives. Probably other OSes,
// too. Unlike EPERM below, EINVAL can only happen if the process group has
// terminated.
pgroup_terminated = true;
} else if errno::errno().0 == EPERM {
// Retry so long as this isn't because the process group is dead.
let mut result: libc::c_int = 0;
let wait_result = unsafe { libc::waitpid(-pgid.as_pid_t(), &mut result, WNOHANG) };
if wait_result == -1 {
// Note that -1 is technically an "error" for waitpid in the sense that an
// invalid argument was specified because no such process group exists any
// longer. This is the observed behavior on Linux 4.4.0. a "success" result
// would mean processes from the group still exist but is still running in some
// state or the other.
pgroup_terminated = true;
} else {
// Debug the original tcsetpgrp error (not the waitpid errno) to the log, and
// then retry until not EPERM or the process group has exited.
FLOGF!(
proc_termowner,
"terminal_give_to_job(): EPERM with pgid %d.",
pgid
);
continue;
}
} else if errno::errno().0 == ENOTTY {
// stdin is not a TTY. In general we expect this to be caught via the tcgetpgrp
// call's EBADF handler above.
return false;
} else {
FLOGF!(
warning,
"Could not send job %d ('%ls') with pgid %d to foreground",
jg.job_id.to_wstring(),
jg.command,
pgid
);
perror("tcsetpgrp");
return false;
}
if pgroup_terminated {
// All processes in the process group has exited.
// Since we delay reaping any processes in a process group until all members of that
// job/group have been started, the only way this can happen is if the very last
// process in the group terminated and didn't need to access the terminal, otherwise
// it would have hung waiting for terminal IO (SIGTTIN). We can safely ignore this.
FLOGF!(
proc_termowner,
"tcsetpgrp called but process group %d has terminated.\n",
pgid
);
return false;
}
break;
}
true
}
}
/// The destructor will assert if reclaim() has not been called.
impl Drop for TtyTransfer {
fn drop(&mut self) {
assert!(self.owner.is_none(), "Forgot to reclaim() the tty");
}
}
/// A type-safe equivalent to [`libc::pid_t`].
#[repr(transparent)]
#[derive(Clone, Copy, Debug, PartialOrd, Ord, PartialEq, Eq, Hash)]

View File

@@ -87,11 +87,8 @@
use crate::input_common::ImplicitEvent;
use crate::input_common::QueryResponseEvent;
use crate::input_common::TerminalQuery;
use crate::input_common::IN_DVTM;
use crate::input_common::IN_MIDNIGHT_COMMANDER;
use crate::input_common::{
terminal_protocol_hacks, terminal_protocols_enable_ifn, CharEvent, CharInputStyle, InputData,
ReadlineCmd,
terminal_protocols_enable_ifn, CharEvent, CharInputStyle, InputData, ReadlineCmd,
};
use crate::io::IoChain;
use crate::key::ViewportPosition;
@@ -150,6 +147,7 @@
tok_command, MoveWordStateMachine, MoveWordStyle, TokenType, Tokenizer, TOK_ACCEPT_UNFINISHED,
TOK_SHOW_COMMENTS,
};
use crate::tty_handoff::{initialize_tty_metadata, tty_metadata};
use crate::wchar::prelude::*;
use crate::wcstringutil::string_prefixes_string_maybe_case_insensitive;
use crate::wcstringutil::{
@@ -264,10 +262,8 @@ pub(crate) fn initial_query(
vars: Option<&dyn Environment>,
) {
blocking_query.get_or_init(|| {
let query = if is_dumb()
|| IN_MIDNIGHT_COMMANDER.load()
|| IN_DVTM.load()
|| !isatty(STDOUT_FILENO)
let md = tty_metadata();
let query = if is_dumb() || md.in_midnight_commander || md.in_dvtm || !isatty(STDOUT_FILENO)
{
None
} else {
@@ -4495,7 +4491,7 @@ fn reader_interactive_init(parser: &Parser) {
.vars()
.set_one(L!("_"), EnvMode::GLOBAL, L!("fish").to_owned());
terminal_protocol_hacks();
initialize_tty_metadata();
}
/// Destroy data for interactive use.

548
src/tty_handoff.rs Normal file
View File

@@ -0,0 +1,548 @@
//! Utility for transferring the tty to a child process in a scoped way,
//! and reclaiming it after.
use crate::common;
use crate::flog::{FLOG, FLOGF};
use crate::global_safety::RelaxedAtomicBool;
use crate::job_group::JobGroup;
use crate::proc::JobGroupRef;
use crate::terminal::TerminalCommand::{
self, ApplicationKeypadModeDisable, ApplicationKeypadModeEnable, DecrstBracketedPaste,
DecrstFocusReporting, DecsetBracketedPaste, DecsetFocusReporting,
KittyKeyboardProgressiveEnhancementsDisable, KittyKeyboardProgressiveEnhancementsEnable,
ModifyOtherKeysDisable, ModifyOtherKeysEnable,
};
use crate::terminal::{Capability, Output, Outputter, KITTY_KEYBOARD_SUPPORTED};
use crate::threads::assert_is_main_thread;
use crate::wchar_ext::ToWString;
use crate::wutil::perror;
use libc::{EINVAL, ENOTTY, EPERM, STDIN_FILENO, WNOHANG};
use std::mem::MaybeUninit;
use std::os::fd::BorrowedFd;
use std::sync::atomic::{AtomicPtr, Ordering};
// Facts about our environment, which inform how we handle the tty.
#[derive(Debug, Copy, Clone)]
pub struct TtyMetadata {
// Whether we are running under Midnight Commander.
pub in_midnight_commander: bool,
// Whether we are running under dvtm.
pub in_dvtm: bool,
// Whether we are running under tmux.
pub in_tmux: bool,
// If set, we are running before iTerm2 3.5.12, which does not support CSI-U.
pub pre_kitty_iterm2: bool,
}
impl TtyMetadata {
// Create a new TtyMetadata instance with the current environment.
fn detect() -> Self {
use std::env::{var, var_os};
let in_midnight_commander = var_os("MC_TMPDIR").is_some();
let in_dvtm = var("TERM").as_deref() == Ok("dvtm-256color");
let in_tmux = var_os("TMUX").is_some();
// Detect iTerm2 before 3.5.12.
let pre_kitty_iterm2 = match get_iterm2_version() {
None => true,
Some(v) => v < (3, 5, 12),
};
Self {
in_midnight_commander,
in_dvtm,
in_tmux,
pre_kitty_iterm2,
}
}
}
// Helper to determine which keyboard protocols to enable.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
enum ProtocolKind {
CSI_U, // Kitty keyboard support with CSI-U
Other, // Other protocols (e.g., modifyOtherKeys)
None, // No protocols
}
// Commands to emit to enable or disable TTY protocols. Each of these contains
// the full serialized command sequence as bytes. It's structured in this awkward
// way so that we can use it from a signal handler - no need to allocate or deallocate
// as Kitty support is discovered through tty queries.
struct ProtocolBytes {
csi_u: Box<[u8]>,
other: Box<[u8]>,
none: Box<[u8]>,
}
// The combined set of TTY protocols.
// This is created once at startup and then leaked, so it may be used
// from the SIGTERM handler.
struct TtyProtocolsSet {
// TTY metadata.
md: TtyMetadata,
// Variants to enable or disable tty protocols.
enablers: ProtocolBytes,
disablers: ProtocolBytes,
}
impl TtyProtocolsSet {
// Get commands to enable or disable TTY protocols, based on the metadata
// and the KITTY_KEYBOARD_SUPPORTED global variable.
// THIS IS USED FROM A SIGNAL HANDLER.
pub fn safe_get_commands(&self, enable: bool) -> &[u8] {
let protocol = self.md.safe_get_supported_protocol();
let cmds = if enable {
&self.enablers
} else {
&self.disablers
};
match protocol {
ProtocolKind::CSI_U => &cmds.csi_u,
ProtocolKind::Other => &cmds.other,
ProtocolKind::None => &cmds.none,
}
}
}
// Serialize a sequence of terminal commands into a byte array.
fn serialize_commands<const N: usize>(cmds: [TerminalCommand<'_>; N]) -> Box<[u8]> {
let mut out = Outputter::new_buffering();
for cmd in cmds {
out.write_command(cmd);
}
out.contents().into()
}
impl TtyMetadata {
// Determine which keyboard protocol to use based on the metadata
// and the KITTY_KEYBOARD_SUPPORTED global variable.
// This is used from a signal handler.
fn safe_get_supported_protocol(&self) -> ProtocolKind {
if self.pre_kitty_iterm2 {
return ProtocolKind::Other;
}
let cap = KITTY_KEYBOARD_SUPPORTED.load(Ordering::Relaxed);
match cap {
x if x == Capability::Supported as _ => ProtocolKind::CSI_U,
x if x == Capability::NotSupported as _ => ProtocolKind::Other,
_ => ProtocolKind::None,
}
}
// Return the protocols set to enable or disable TTY protocols.
fn get_protocols(self) -> TtyProtocolsSet {
let enablers = ProtocolBytes {
csi_u: serialize_commands([
DecsetBracketedPaste, // Enable bracketed paste
DecsetFocusReporting, // Enable focus reporting under tmux
KittyKeyboardProgressiveEnhancementsEnable, // Kitty keyboard progressive enhancements
]),
other: serialize_commands([
DecsetBracketedPaste,
DecsetFocusReporting,
ModifyOtherKeysEnable, // XTerm's modifyOtherKeys
ApplicationKeypadModeEnable, // set application keypad mode, so the keypad keys send unique codes
]),
none: serialize_commands([DecsetBracketedPaste, DecsetFocusReporting]),
};
let disablers = ProtocolBytes {
csi_u: serialize_commands([
DecrstBracketedPaste, // Disable bracketed paste
DecrstFocusReporting, // Disable focus reporting under tmux
KittyKeyboardProgressiveEnhancementsDisable, // Kitty keyboard progressive enhancements
]),
other: serialize_commands([
DecrstBracketedPaste,
DecrstFocusReporting,
ModifyOtherKeysDisable,
ApplicationKeypadModeDisable,
]),
none: serialize_commands([DecrstBracketedPaste, DecrstFocusReporting]),
};
TtyProtocolsSet {
md: self,
enablers,
disablers,
}
}
}
// The global tty protocols. This is set once at startup and not changed thereafter.
// This is an AtomicPtr and not a OnceLock, etc. so that it can be used from a signal handler.
static TTY_PROTOCOLS: AtomicPtr<TtyProtocolsSet> = AtomicPtr::new(std::ptr::null_mut());
// Get the TTY protocols, without initializing it.
fn tty_protocols() -> Option<&'static TtyProtocolsSet> {
// Safety: TTY_PROTOCOLS is never modified after initialization.
unsafe { TTY_PROTOCOLS.load(Ordering::Acquire).as_ref() }
}
// Get the TTY protocols, initializing it if necessary.
// This also initializes the terminal enable and disable serialized commands.
// Note in practice this is only used from the main thread - races are very unlikely.
fn get_or_init_tty_protocols() -> &'static TtyProtocolsSet {
use std::sync::atomic::Ordering::{Acquire, Release};
// Standard lazy-init pattern from rust-atomics-and-locks.
let mut p = TTY_PROTOCOLS.load(Acquire);
if p.is_null() {
// Try to swap in a new TTY protocols set.
p = Box::into_raw(Box::new(TtyMetadata::detect().get_protocols()));
if let Err(e) = TTY_PROTOCOLS.compare_exchange(std::ptr::null_mut(), p, Release, Acquire) {
// Safety: p comes from Box::into_raw right above,
// and wasn't shared with any other thread.
drop(unsafe { Box::from_raw(p) });
p = e;
}
}
// Safety: p is not null and points to a properly initialized value.
unsafe { &*p }
}
// Get the TTY metadata, initializing it if necessary.
pub fn tty_metadata() -> TtyMetadata {
get_or_init_tty_protocols().md
}
// Cover to merely initialize the TTY metadata, for clarity at call sites.
pub fn initialize_tty_metadata() {
tty_metadata();
}
// A marker of the current state of the tty protocols.
static TTY_PROTOCOLS_ACTIVE: RelaxedAtomicBool = RelaxedAtomicBool::new(false);
// Enable or disable TTY protocols by writing the appropriate commands to the tty.
// Return true if we emitted any bytes to the tty.
// Note this does NOT intialize the TTY protocls if not already initialized.
pub fn set_tty_protocols_active(enable: bool) -> bool {
assert_is_main_thread();
// Have protocols at all? We require someone else to have initialized them.
let Some(protocols) = tty_protocols() else {
return false;
};
// Already set?
// Note we don't need atomic swaps as this is only called on the main thread.
if TTY_PROTOCOLS_ACTIVE.load() == enable {
return false;
}
TTY_PROTOCOLS_ACTIVE.store(enable);
// Write the commands to the tty, ignoring errors.
let commands = protocols.safe_get_commands(enable);
let _ = common::write_loop(&libc::STDOUT_FILENO, commands);
// Flog any terminal protocol changes of interest.
let mode = if enable { "Enabling" } else { "Disabling" };
match protocols.md.safe_get_supported_protocol() {
ProtocolKind::CSI_U => FLOG!(term_protocols, mode, "CSI-U extended keys"),
ProtocolKind::Other => FLOG!(term_protocols, mode, "other extended keys"),
ProtocolKind::None => (),
};
true
}
// Helper to check if TTY protocols are active.
pub fn get_tty_protocols_active() -> bool {
TTY_PROTOCOLS_ACTIVE.load()
}
// Called from a signal handler to deactivate TTY protocols before exiting.
// Only async-signal-safe code can be run here.
pub fn safe_deactivate_tty_protocols() {
// Safety: TTY_PROTOCOLS is never modified after initialization.
let protocols = unsafe { TTY_PROTOCOLS.load(Ordering::Acquire).as_ref() };
let Some(protocols) = protocols else {
// No protocols set, nothing to do.
return;
};
if !TTY_PROTOCOLS_ACTIVE.load() {
return;
}
TTY_PROTOCOLS_ACTIVE.store(false);
let commands = protocols.safe_get_commands(false);
// Safety: just writing data to stdout.
let stdout_fd = unsafe { BorrowedFd::borrow_raw(libc::STDOUT_FILENO) };
let _ = nix::unistd::write(stdout_fd, commands);
}
// Allows transferring the tty to a job group, while it runs, in a scoped fashion.
// This has several responsibilities:
// - Invoking tcsetpgrp() to transfer the tty to the job group.
// Note this is complex because it is inherently "racey."
// - Saving tty modes if a job stops. That is, if a job is running and
// then it stops in the background, we want to record the tty modes
// it has in the job, so that we can restore them when the job is resumed.
// - Managing enabling and disabling terminal protocols (bracketed paste, etc).
// Note it only ever makes sense to run this on the main thread.
pub struct TtyHandoff {
// The job group which owns the tty, or empty if none.
owner: Option<JobGroupRef>,
// Whether terminal protocols were initially enabled.
// reclaim() restores the state to this.
tty_protocols_initial: bool,
// The state of terminal protocols that we set.
// Note we track this separately from TTY_PROTOCOLS_ACTIVE. We undo the changes
// we make.
tty_protocols_applied: bool,
// Whether reclaim was called, restoring the tty to its pre-scoped value.
reclaimed: bool,
}
impl TtyHandoff {
pub fn new() -> Self {
let protocols_active = get_tty_protocols_active();
TtyHandoff {
owner: None,
tty_protocols_initial: protocols_active,
tty_protocols_applied: protocols_active,
reclaimed: false,
}
}
/// Mark terminal modes as enabled.
/// Return true if something was written to the tty.
pub fn enable_tty_protocols(&mut self) -> bool {
if self.tty_protocols_applied {
return false; // Already enabled.
}
self.tty_protocols_applied = true;
set_tty_protocols_active(true)
}
/// Mark terminal modes as disabled.
/// Return true if something was written to the tty.
pub fn disable_tty_protocols(&mut self) -> bool {
if !self.tty_protocols_applied {
return false; // Already disabled.
};
self.tty_protocols_applied = false;
set_tty_protocols_active(false)
}
/// Transfer to the given job group, if it wants to own the terminal.
#[allow(clippy::wrong_self_convention)]
pub fn to_job_group(&mut self, jg: &JobGroupRef) {
assert!(self.owner.is_none(), "Terminal already transferred");
if Self::try_transfer(jg) {
self.owner = Some(jg.clone());
}
}
/// Reclaim the tty if we transferred it.
/// Returns true if data was written to the tty, as part of
/// re-enabling terminal protocols.
pub fn reclaim(mut self) -> bool {
self.reclaim_impl()
}
/// Release the tty, meaning no longer restore anything in Drop - similar to `mem::forget`.
pub fn release(mut self) {
self.reclaimed = true;
}
/// Implementation of reclaim, factored out for use in Drop.
fn reclaim_impl(&mut self) -> bool {
assert!(!self.reclaimed, "Terminal already reclaimed");
self.reclaimed = true;
if self.owner.is_some() {
FLOG!(proc_pgroup, "fish reclaiming terminal");
if unsafe { libc::tcsetpgrp(STDIN_FILENO, libc::getpgrp()) } == -1 {
FLOG!(warning, "Could not return shell to foreground");
perror("tcsetpgrp");
}
self.owner = None;
}
// Restore the terminal protocols. Note this does nothing if they were unchanged.
if self.tty_protocols_initial {
self.enable_tty_protocols()
} else {
self.disable_tty_protocols()
}
}
/// Save the current tty modes into the owning job group, if we are transferred.
pub fn save_tty_modes(&mut self) {
if let Some(ref mut owner) = self.owner {
let mut tmodes = MaybeUninit::uninit();
if unsafe { libc::tcgetattr(STDIN_FILENO, tmodes.as_mut_ptr()) } == 0 {
owner.tmodes.replace(Some(unsafe { tmodes.assume_init() }));
} else if errno::errno().0 != ENOTTY {
perror("tcgetattr");
}
}
}
fn try_transfer(jg: &JobGroup) -> bool {
if !jg.wants_terminal() {
// The job doesn't want the terminal.
return false;
}
// Get the pgid; we must have one if we want the terminal.
let pgid = jg.get_pgid().unwrap();
// It should never be fish's pgroup.
let fish_pgrp = crate::nix::getpgrp();
assert!(
pgid.as_pid_t() != fish_pgrp,
"Job should not have fish's pgroup"
);
// Ok, we want to transfer to the child.
// Note it is important to be very careful about calling tcsetpgrp()!
// fish ignores SIGTTOU which means that it has the power to reassign the tty even if it doesn't
// own it. This means that other processes may get SIGTTOU and become zombies.
// Check who own the tty now. There's four cases of interest:
// 1. There is no tty at all (tcgetpgrp() returns -1). For example running from a pure script.
// Of course do not transfer it in that case.
// 2. The tty is owned by the process. This comes about often, as the process will call
// tcsetpgrp() on itself between fork and exec. This is the essential race inherent in
// tcsetpgrp(). In this case we want to reclaim the tty, but do not need to transfer it
// ourselves since the child won the race.
// 3. The tty is owned by a different process. This may come about if fish is running in the
// background with job control enabled. Do not transfer it.
// 4. The tty is owned by fish. In that case we want to transfer the pgid.
let current_owner = unsafe { libc::tcgetpgrp(STDIN_FILENO) };
if current_owner < 0 {
// Case 1.
return false;
} else if current_owner == pgid.get() {
// Case 2.
return true;
} else if current_owner != pgid.get() && current_owner != fish_pgrp {
// Case 3.
return false;
}
// Case 4 - we do want to transfer it.
// The tcsetpgrp(2) man page says that EPERM is thrown if "pgrp has a supported value, but
// is not the process group ID of a process in the same session as the calling process."
// Since we _guarantee_ that this isn't the case (the child calls setpgid before it calls
// SIGSTOP, and the child was created in the same session as us), it seems that EPERM is
// being thrown because of an caching issue - the call to tcsetpgrp isn't seeing the
// newly-created process group just yet. On this developer's test machine (WSL running Linux
// 4.4.0), EPERM does indeed disappear on retry. The important thing is that we can
// guarantee the process isn't going to exit while we wait (which would cause us to possibly
// block indefinitely).
while unsafe { libc::tcsetpgrp(STDIN_FILENO, pgid.as_pid_t()) } != 0 {
FLOGF!(proc_termowner, "tcsetpgrp failed: %d", errno::errno().0);
// Before anything else, make sure that it's even necessary to call tcsetpgrp.
// Since it usually _is_ necessary, we only check in case it fails so as to avoid the
// unnecessary syscall and associated context switch, which profiling has shown to have
// a significant cost when running process groups in quick succession.
let getpgrp_res = unsafe { libc::tcgetpgrp(STDIN_FILENO) };
if getpgrp_res < 0 {
match errno::errno().0 {
ENOTTY => {
// stdin is not a tty. This may come about if job control is enabled but we are
// not a tty - see #6573.
return false;
}
_ => {
perror("tcgetpgrp");
return false;
}
}
}
if getpgrp_res == pgid.get() {
FLOGF!(
proc_termowner,
"Process group %d already has control of terminal",
pgid
);
return true;
}
let pgroup_terminated;
if errno::errno().0 == EINVAL {
// OS X returns EINVAL if the process group no longer lives. Probably other OSes,
// too. Unlike EPERM below, EINVAL can only happen if the process group has
// terminated.
pgroup_terminated = true;
} else if errno::errno().0 == EPERM {
// Retry so long as this isn't because the process group is dead.
let mut result: libc::c_int = 0;
let wait_result = unsafe { libc::waitpid(-pgid.as_pid_t(), &mut result, WNOHANG) };
if wait_result == -1 {
// Note that -1 is technically an "error" for waitpid in the sense that an
// invalid argument was specified because no such process group exists any
// longer. This is the observed behavior on Linux 4.4.0. a "success" result
// would mean processes from the group still exist but is still running in some
// state or the other.
pgroup_terminated = true;
} else {
// Debug the original tcsetpgrp error (not the waitpid errno) to the log, and
// then retry until not EPERM or the process group has exited.
FLOGF!(
proc_termowner,
"terminal_give_to_job(): EPERM with pgid %d.",
pgid
);
continue;
}
} else if errno::errno().0 == ENOTTY {
// stdin is not a TTY. In general we expect this to be caught via the tcgetpgrp
// call's EBADF handler above.
return false;
} else {
FLOGF!(
warning,
"Could not send job %d ('%ls') with pgid %d to foreground",
jg.job_id.to_wstring(),
jg.command,
pgid
);
perror("tcsetpgrp");
return false;
}
if pgroup_terminated {
// All processes in the process group has exited.
// Since we delay reaping any processes in a process group until all members of that
// job/group have been started, the only way this can happen is if the very last
// process in the group terminated and didn't need to access the terminal, otherwise
// it would have hung waiting for terminal IO (SIGTTIN). We can safely ignore this.
FLOGF!(
proc_termowner,
"tcsetpgrp called but process group %d has terminated.\n",
pgid
);
return false;
}
break;
}
true
}
}
/// The destructor will assert if reclaim() has not been called.
impl Drop for TtyHandoff {
fn drop(&mut self) {
if !self.reclaimed {
self.reclaim_impl();
}
}
}
// If we are running under iTerm2, get the version as a tuple of (major, minor, patch).
fn get_iterm2_version() -> Option<(u32, u32, u32)> {
use std::env::var;
let term = var("LC_TERMINAL").ok()?;
if term != "iTerm2" {
return None;
}
let version = var("LC_TERMINAL_VERSION").ok()?;
let mut parts = version.split('.');
Some((
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
parts.next()?.parse().ok()?,
))
}