diff -wpruN --no-dereference a~/src/builtins/fg.rs a/src/builtins/fg.rs
--- a~/src/builtins/fg.rs	2026-05-07 16:02:14.000000000 +0000
+++ a/src/builtins/fg.rs	2026-05-20 13:38:23.291989325 +0000
@@ -8,9 +8,7 @@ use crate::tokenizer::tok_command;
 use crate::{env::EnvMode, tty_handoff::TtyHandoff};
 use crate::{err_fmt, err_str};
 use fish_util::perror;
-use libc::STDIN_FILENO;
-use nix::sys::termios::{self, tcsetattr};
-use std::os::fd::BorrowedFd;
+use libc::{STDIN_FILENO, TCSADRAIN};
 
 use super::prelude::*;
 
@@ -140,14 +138,9 @@ pub fn fg(parser: &Parser, streams: &mut
         }
         let tmodes = job_group.tmodes.borrow();
         if job_group.wants_terminal() && tmodes.is_some() {
-            let tmodes = tmodes.as_ref().unwrap();
-            if tcsetattr(
-                unsafe { BorrowedFd::borrow_raw(STDIN_FILENO) },
-                termios::SetArg::TCSADRAIN,
-                tmodes,
-            )
-            .is_err()
-            {
+            let termios = tmodes.as_ref().unwrap();
+            let res = unsafe { libc::tcsetattr(STDIN_FILENO, TCSADRAIN, termios) };
+            if res < 0 {
                 perror("tcsetattr");
             }
         }
diff -wpruN --no-dereference a~/src/builtins/fish_key_reader.rs a/src/builtins/fish_key_reader.rs
--- a~/src/builtins/fish_key_reader.rs	2026-05-07 16:02:14.000000000 +0000
+++ a/src/builtins/fish_key_reader.rs	2026-05-20 13:38:23.292319209 +0000
@@ -49,7 +49,7 @@ fn should_exit(
 
     for evt in [VINTR, VEOF] {
         let modes = shell_modes();
-        let cc = Key::from_single_byte(modes.control_chars[evt]);
+        let cc = Key::from_single_byte(modes.c_cc[evt]);
 
         if match_key_event_to_key(&key_evt, &cc).is_some() {
             if recent_keys
@@ -63,7 +63,7 @@ fn should_exit(
             }
             streams.err.appendln(&wgettext_fmt!(
                 "Press ctrl-%c again to exit",
-                char::from(modes.control_chars[evt] + 0x60)
+                char::from(modes.c_cc[evt] + 0x60)
             ));
             return false;
         }
@@ -164,8 +164,8 @@ fn setup_and_process_keys(
         let modes = shell_modes();
         streams.err.appendln(&wgettext_fmt!(
             "or press ctrl-%c or ctrl-%c twice in a row.",
-            char::from(modes.control_chars[VINTR] + 0x60),
-            char::from(modes.control_chars[VEOF] + 0x60)
+            char::from(modes.c_cc[VINTR] + 0x60),
+            char::from(modes.c_cc[VEOF] + 0x60)
         ));
         streams.err.appendln(L!("\n"));
     }
diff -wpruN --no-dereference a~/src/common.rs a/src/common.rs
--- a~/src/common.rs	2026-05-07 16:02:14.000000000 +0000
+++ a/src/common.rs	2026-05-20 13:38:23.292733249 +0000
@@ -9,7 +9,6 @@ use crate::{
 };
 use fish_fallback::fish_wcwidth;
 use fish_widestring::subslice_position;
-use nix::sys::termios::Termios;
 use std::{
     env,
     sync::{MutexGuard, OnceLock, atomic::Ordering},
@@ -21,7 +20,7 @@ pub const BUILD_DIR: &str = env!("FISH_R
 
 pub const PACKAGE_NAME: &str = env!("CARGO_PKG_NAME");
 
-pub fn shell_modes() -> MutexGuard<'static, Termios> {
+pub fn shell_modes() -> MutexGuard<'static, libc::termios> {
     crate::reader::SHELL_MODES.lock().unwrap()
 }
 
diff -wpruN --no-dereference a~/src/input_common.rs a/src/input_common.rs
--- a~/src/input_common.rs	2026-05-07 16:02:14.000000000 +0000
+++ a/src/input_common.rs	2026-05-20 13:38:23.293562243 +0000
@@ -877,7 +877,7 @@ pub trait InputEventQueuer {
                                 self.push_back(evt);
                             }
                         });
-                        let vintr = shell_modes().control_chars[libc::VINTR];
+                        let vintr = shell_modes().c_cc[libc::VINTR];
                         if vintr != 0
                             && key.is_some_and(|key| {
                                 match_key_event_to_key(&key, &Key::from_single_byte(vintr))
@@ -1618,7 +1618,7 @@ pub trait InputEventQueuer {
     fn select_interrupted(&mut self) {}
 
     fn enqueue_interrupt_key(&mut self) {
-        let vintr = shell_modes().control_chars[libc::VINTR];
+        let vintr = shell_modes().c_cc[libc::VINTR];
         if vintr != 0 {
             let interrupt_evt = CharEvent::from_key(KeyEvent::from_single_byte(vintr));
             if stop_query(self.blocking_query()) {
diff -wpruN --no-dereference a~/src/job_group.rs a/src/job_group.rs
--- a~/src/job_group.rs	2026-05-07 16:02:14.000000000 +0000
+++ a/src/job_group.rs	2026-05-20 13:38:23.293920516 +0000
@@ -2,7 +2,6 @@ use crate::global_safety::RelaxedAtomicB
 use crate::prelude::*;
 use crate::proc::{JobGroupRef, Pid};
 use crate::signal::Signal;
-use nix::sys::termios::Termios;
 use std::cell::RefCell;
 use std::num::NonZeroU32;
 use std::sync::atomic::{AtomicI32, Ordering};
@@ -61,7 +60,7 @@ impl<'a> fish_printf::ToArg<'a> for Mayb
 pub struct JobGroup {
     /// If set, the saved terminal modes of this job. This needs to be saved so that we can restore
     /// the terminal to the same state when resuming a stopped job.
-    pub tmodes: RefCell<Option<Termios>>,
+    pub tmodes: RefCell<Option<libc::termios>>,
     /// Whether job control is enabled in this `JobGroup` or not.
     ///
     /// If this is set, then the first process in the root job must be external, as it will become
diff -wpruN --no-dereference a~/src/reader/reader.rs a/src/reader/reader.rs
--- a~/src/reader/reader.rs	2026-05-07 16:02:14.000000000 +0000
+++ a/src/reader/reader.rs	2026-05-20 13:38:35.221217180 +0000
@@ -125,15 +125,15 @@ use fish_wcstringutil::{
 };
 use fish_widestring::{ELLIPSIS_CHAR, UTF8_BOM_WCHAR, bytes2wcstring};
 use libc::{
-    _POSIX_VDISABLE, EIO, EISDIR, ENOTTY, ESRCH, O_NONBLOCK, O_RDONLY, SIGINT, STDERR_FILENO,
-    STDIN_FILENO, STDOUT_FILENO, VMIN, VQUIT, VSUSP, VTIME, c_char,
+    _POSIX_VDISABLE, ECHO, EINTR, EIO, EISDIR, ENOTTY, ESRCH, FLUSHO, ICANON, ICRNL, IEXTEN,
+    INLCR, IXOFF, IXON, O_NONBLOCK, O_RDONLY, ONLCR, OPOST, SIGINT, STDERR_FILENO, STDIN_FILENO,
+    STDOUT_FILENO, TCSANOW, VMIN, VQUIT, VSUSP, VTIME, c_char,
 };
 use nix::{
     fcntl::OFlag,
     sys::{
         signal::{Signal, killpg},
         stat::Mode,
-        termios::{self, SetArg, Termios, tcgetattr, tcsetattr},
     },
     unistd::{getpgrp, getpid, setpgid},
 };
@@ -142,6 +142,7 @@ use std::{
     cell::UnsafeCell,
     cmp,
     io::BufReader,
+    mem::MaybeUninit,
     num::NonZeroUsize,
     ops::{ControlFlow, Range},
     os::fd::{AsRawFd as _, BorrowedFd, FromRawFd as _, OwnedFd, RawFd},
@@ -166,19 +167,15 @@ enum ExitState {
 
 static EXIT_STATE: AtomicU8 = AtomicU8::new(ExitState::None as u8);
 
-fn zeroed_termios() -> Termios {
-    let termios: libc::termios = unsafe { std::mem::zeroed() };
-    termios.into()
-}
-
-pub static SHELL_MODES: LazyLock<Mutex<Termios>> = LazyLock::new(|| Mutex::new(zeroed_termios()));
+pub static SHELL_MODES: LazyLock<Mutex<libc::termios>> =
+    LazyLock::new(|| Mutex::new(unsafe { std::mem::zeroed() }));
 
 /// The valid terminal modes on startup.
 static TERMINAL_MODE_ON_STARTUP: OnceLock<libc::termios> = OnceLock::new();
 
 /// Mode we use to execute programs.
-static TTY_MODES_FOR_EXTERNAL_CMDS: LazyLock<Mutex<Termios>> =
-    LazyLock::new(|| Mutex::new(zeroed_termios()));
+static TTY_MODES_FOR_EXTERNAL_CMDS: LazyLock<Mutex<libc::termios>> =
+    LazyLock::new(|| Mutex::new(unsafe { std::mem::zeroed() }));
 
 static RUN_COUNT: AtomicU64 = AtomicU64::new(0);
 
@@ -222,11 +219,13 @@ fn redirect_tty_after_sighup() {
     };
     let fd = devnull.as_raw_fd();
     for stdfd in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] {
-        if matches!(
-            tcgetattr(unsafe { BorrowedFd::borrow_raw(stdfd) }),
-            Err(nix::Error::EIO | nix::Error::ENOTTY)
-        ) {
-            unsafe { libc::dup2(fd, stdfd) };
+        let mut t = std::mem::MaybeUninit::uninit();
+        unsafe {
+            if libc::tcgetattr(stdfd, t.as_mut_ptr()) != 0
+                && matches!(errno::errno().0, EIO | ENOTTY)
+            {
+                libc::dup2(fd, stdfd);
+            }
         }
     }
 }
@@ -985,24 +984,19 @@ fn read_ni(parser: &Parser, fd: RawFd, i
     }
 }
 
-const FLOW_CONTROL_FLAGS: termios::InputFlags = {
-    use termios::InputFlags;
-    InputFlags::IXON.union(InputFlags::IXOFF)
-};
+const FLOW_CONTROL_FLAGS: libc::tcflag_t = IXON | IXOFF;
 
 /// Initialize the reader.
 pub fn reader_init(will_restore_foreground_pgroup: bool) {
     assert_is_main_thread();
-    let terminal_mode_on_startup = match tcgetattr(unsafe { BorrowedFd::borrow_raw(STDIN_FILENO) })
-    {
-        Ok(modes) => {
             // Save the initial terminal mode.
+    // Note this field is read by a signal handler, so do it atomically, with a leaked mode.
+    let mut terminal_mode_on_startup = unsafe { std::mem::zeroed::<libc::termios>() };
+    let ret = unsafe { libc::tcgetattr(libc::STDIN_FILENO, &mut terminal_mode_on_startup) };
             // TODO: rationalize behavior if initial tcgetattr() fails.
-            TERMINAL_MODE_ON_STARTUP.get_or_init(|| libc::termios::from(modes.clone()));
-            modes
+    if ret == 0 {
+        TERMINAL_MODE_ON_STARTUP.get_or_init(|| terminal_mode_on_startup);
         }
-        Err(_) => zeroed_termios(),
-    };
 
     AT_EXIT
         .set(Box::new(move || {
@@ -1016,12 +1010,12 @@ pub fn reader_init(will_restore_foregrou
     term_fix_external_modes(&mut external_modes);
 
     // Disable flow control by default.
-    external_modes.input_flags &= !FLOW_CONTROL_FLAGS;
+    external_modes.c_iflag &= !FLOW_CONTROL_FLAGS;
 
     // Set the mode used for the terminal, initialized to the current mode.
     {
         let mut shell_modes = shell_modes();
-        *shell_modes = external_modes.clone();
+        *shell_modes = external_modes;
         term_fix_shell_modes(&mut shell_modes);
     }
 
@@ -1050,7 +1044,7 @@ pub fn restore_term_mode() {
         return;
     }
     if let Some(modes) = get_terminal_mode_on_startup() {
-        unsafe { libc::tcsetattr(STDIN_FILENO, libc::TCSANOW, modes) };
+        unsafe { libc::tcsetattr(STDIN_FILENO, TCSANOW, modes) };
     }
 }
 
@@ -1221,7 +1215,7 @@ pub fn reader_reading_interrupted(data:
 /// commandline.
 pub fn reader_readline(
     parser: &Parser,
-    old_modes: Option<Termios>,
+    old_modes: Option<libc::termios>,
     nchars: Option<NonZeroUsize>,
 ) -> Option<WString> {
     let data = current_data().unwrap();
@@ -2558,7 +2552,7 @@ impl<'a> Reader<'a> {
     /// Return the command, or none if we were asked to cancel (e.g. SIGHUP).
     fn readline(
         &mut self,
-        old_modes: Option<Termios>,
+        old_modes: Option<libc::termios>,
         nchars: Option<NonZeroUsize>,
     ) -> Option<WString> {
         let mut tty = TtyHandoff::new(reader_save_screen_state);
@@ -2656,14 +2650,10 @@ impl<'a> Reader<'a> {
             // The order of the two conditions below is important. Try to restore the mode
             // in all cases, but only complain if interactive.
             if let Some(old_modes) = old_modes {
-                if let Err(err) = tcsetattr(
-                    unsafe { BorrowedFd::borrow_raw(self.conf.inputfd) },
-                    SetArg::TCSANOW,
-                    &old_modes,
-                ) {
-                    if is_interactive_session() {
-                        perror_nix("tcsetattr", err);
-                    }
+                if unsafe { libc::tcsetattr(self.conf.inputfd, TCSANOW, &old_modes) } == -1
+                    && is_interactive_session()
+                {
+                    perror("tcsetattr");
                 }
             }
             Outputter::stdoutput().borrow_mut().reset_text_face();
@@ -4729,39 +4719,32 @@ impl ReaderData {
 
 // Turning off OPOST or ONLCR breaks output (staircase effect), we don't allow it.
 // See #7133.
-fn term_fix_oflag(modes: &mut Termios) {
-    modes.output_flags |= {
-        use termios::OutputFlags;
+fn term_fix_oflag(modes: &mut libc::termios) {
+    modes.c_oflag |= {
         // turn on "implementation-defined post processing" - this often changes how line breaks work.
-        OutputFlags::OPOST
+        OPOST
         // "translate newline to carriage return-newline" - without you see staircase output.
-        | OutputFlags::ONLCR
+        | ONLCR
     };
 }
 
 /// Restore terminal settings we care about, to prevent a broken shell.
-fn term_fix_shell_modes(modes: &mut Termios) {
-    modes.input_flags &= {
-        use termios::InputFlags;
+fn term_fix_shell_modes(modes: &mut libc::termios) {
+    modes.c_iflag &= {
         // disable mapping CR (\cM) to NL (\cJ)
-        !InputFlags::ICRNL
+        !ICRNL
         // disable mapping NL (\cJ) to CR (\cM)
-        & !InputFlags::INLCR
+        & !INLCR
     };
-    modes.local_flags &= {
-        use termios::LocalFlags;
-        let echo = LocalFlags::ECHO;
-        let flusho = LocalFlags::FLUSHO;
-        let icanon = LocalFlags::ICANON;
-        let iexten = LocalFlags::IEXTEN;
-        !echo
-        & !icanon
-        & !iexten // turn off handling of discard and lnext characters
-        & !flusho
+    modes.c_lflag &= {
+        !ECHO
+        & !ICANON
+        & !IEXTEN // turn off handling of discard and lnext characters
+        & !FLUSHO
     };
     term_fix_oflag(modes);
 
-    let c_cc = &mut modes.control_chars;
+    let c_cc = &mut modes.c_cc;
     c_cc[VMIN] = 1;
     c_cc[VTIME] = 0;
 
@@ -4775,95 +4758,81 @@ fn term_fix_shell_modes(modes: &mut Term
     c_cc[VQUIT] = disabling_char;
 }
 
-fn term_fix_external_modes(modes: &mut Termios) {
+fn term_fix_external_modes(modes: &mut libc::termios) {
     term_fix_oflag(modes);
     // These cause other ridiculous behaviors like input not being shown.
-    modes.local_flags = {
-        use termios::LocalFlags;
-        let echo = LocalFlags::ECHO;
-        let flusho = LocalFlags::FLUSHO;
-        let icanon = LocalFlags::ICANON;
-        let iexten = LocalFlags::IEXTEN;
-        (modes.local_flags | echo | icanon | iexten) & !flusho
-    };
-    modes.input_flags = {
-        use termios::InputFlags;
-        let icrnl = InputFlags::ICRNL;
-        let inlcr = InputFlags::INLCR;
-        (modes.input_flags | icrnl) & !inlcr
-    };
+    modes.c_lflag = (modes.c_lflag | ECHO | ICANON | IEXTEN) & !FLUSHO;
+    modes.c_iflag = (modes.c_iflag | ICRNL) & !INLCR;
 }
 
 /// Give up control of terminal.
 fn term_donate(quiet: bool /* = false */) {
-    loop {
-        match tcsetattr(
-            unsafe { BorrowedFd::borrow_raw(STDIN_FILENO) },
-            SetArg::TCSANOW,
-            &TTY_MODES_FOR_EXTERNAL_CMDS.lock().unwrap(),
-        ) {
-            Ok(_) => (),
-            Err(nix::Error::EINTR) => continue,
-            Err(err) => {
+    while unsafe {
+        libc::tcsetattr(
+            STDIN_FILENO,
+            TCSANOW,
+            &*TTY_MODES_FOR_EXTERNAL_CMDS.lock().unwrap(),
+        )
+    } == -1
+    {
+        if errno().0 != EINTR {
                 if !quiet {
                     flog!(
                         warning,
                         wgettext!("Could not set terminal mode for new job")
                     );
-                    perror_nix("tcsetattr", err);
+                perror("tcsetattr");
                 }
                 break;
             }
         }
-        break;
-    }
 }
 
 /// Copy the (potentially changed) terminal modes and use them from now on.
 pub fn term_copy_modes() {
-    let mut external_modes = tcgetattr(unsafe { BorrowedFd::borrow_raw(STDIN_FILENO) })
-        .unwrap_or_else(|_| zeroed_termios());
+    let mut modes = MaybeUninit::uninit();
+    unsafe { libc::tcgetattr(STDIN_FILENO, modes.as_mut_ptr()) };
+    let mut external_modes = unsafe { modes.assume_init() };
     // We still want to fix most egregious breakage.
     // E.g. OPOST is *not* something that should be set globally,
     // and 99% triggered by a crashed program.
     term_fix_external_modes(&mut external_modes);
-    let external_flow_control = external_modes.input_flags & FLOW_CONTROL_FLAGS;
     *TTY_MODES_FOR_EXTERNAL_CMDS.lock().unwrap() = external_modes;
 
     let mut shell_modes = shell_modes();
-    shell_modes.input_flags =
-        (shell_modes.input_flags & !FLOW_CONTROL_FLAGS) | external_flow_control;
+    shell_modes.c_iflag =
+        (shell_modes.c_iflag & !FLOW_CONTROL_FLAGS) | (external_modes.c_iflag & FLOW_CONTROL_FLAGS);
 }
 
 pub fn set_shell_modes(fd: RawFd, whence: &str) -> bool {
-    loop {
-        match tcsetattr(
-            unsafe { BorrowedFd::borrow_raw(fd) },
-            SetArg::TCSANOW,
-            &shell_modes(),
-        ) {
-            Ok(_) => return true,
-            Err(nix::Error::EINTR) => continue,
-            Err(err) => {
-                perror_nix("tcsetattr", err);
+    let ok = loop {
+        let ok = unsafe { libc::tcsetattr(fd, TCSANOW, &*shell_modes()) } != -1;
+        if ok || errno().0 != EINTR {
+            break ok;
+        }
+    };
+    if !ok {
+        perror("tcsetattr");
                 flog!(
                     warning,
                     wgettext_fmt!("Failed to set terminal mode (%s)", whence)
                 );
-                return false;
-            }
-        }
     }
+    ok
 }
 
-pub fn set_shell_modes_temporarily(inputfd: RawFd) -> Option<Termios> {
+pub fn set_shell_modes_temporarily(inputfd: RawFd) -> Option<libc::termios> {
     // It may happen that a command we ran when job control was disabled nevertheless stole the tty
     // from us. In that case when we read from our fd, it will trigger SIGTTIN. So just
     // unconditionally reclaim the tty. See #9181.
     unsafe { libc::tcsetpgrp(inputfd, libc::getpgrp()) };
 
     // Get the current terminal modes. These will be restored when the function returns.
-    let old_modes = tcgetattr(unsafe { BorrowedFd::borrow_raw(inputfd) }).ok();
+    let old_modes = {
+        let mut old_modes = MaybeUninit::uninit();
+        let ok = unsafe { libc::tcgetattr(inputfd, old_modes.as_mut_ptr()) } == 0;
+        ok.then(|| unsafe { old_modes.assume_init() })
+    };
 
     // Set the new modes.
     set_shell_modes(inputfd, "readline");
diff -wpruN --no-dereference a~/src/screen.rs a/src/screen.rs
--- a~/src/screen.rs	2026-05-07 16:02:14.000000000 +0000
+++ a/src/screen.rs	2026-05-20 13:38:23.298645182 +0000
@@ -28,8 +28,7 @@ use fish_common::write_loop;
 use fish_fallback::{fish_wcswidth_canonicalizing, fish_wcwidth};
 use fish_wcstringutil::{fish_wcwidth_visible, string_prefixes_string};
 use fish_widestring::{ELLIPSIS_CHAR, wcs2bytes};
-use libc::{STDERR_FILENO, STDOUT_FILENO};
-use nix::sys::termios;
+use libc::{ONLCR, STDERR_FILENO, STDOUT_FILENO};
 use std::cell::RefCell;
 use std::collections::LinkedList;
 use std::io::Write as _;
@@ -889,10 +888,7 @@ impl Screen {
         let s = if y_steps < 0 {
             Some(CursorUp)
         } else if y_steps > 0 {
-            if shell_modes()
-                .output_flags
-                .contains(termios::OutputFlags::ONLCR)
-            {
+            if (shell_modes().c_oflag & ONLCR) != 0 {
                 // See GitHub issue #4505.
                 // Most consoles use a simple newline as the cursor down escape.
                 // If ONLCR is enabled (which it normally is) this will of course
diff -wpruN --no-dereference a~/src/tty_handoff.rs a/src/tty_handoff.rs
--- a~/src/tty_handoff.rs	2026-05-07 16:02:14.000000000 +0000
+++ a/src/tty_handoff.rs	2026-05-20 13:38:23.299025193 +0000
@@ -16,13 +16,12 @@ use crate::terminal::TerminalCommand::{
     KittyKeyboardProgressiveEnhancementsEnable, ModifyOtherKeysDisable, ModifyOtherKeysEnable,
 };
 use crate::threads::assert_is_main_thread;
-use crate::wutil::{perror_nix, wcstoi};
+use crate::wutil::wcstoi;
 use fish_common::write_loop;
 use fish_util::perror;
 use libc::{EINVAL, ENOTTY, EPERM, STDIN_FILENO, WNOHANG};
-use nix::sys::termios::tcgetattr;
 use nix::unistd::getpgrp;
-use std::os::fd::BorrowedFd;
+use std::mem::MaybeUninit;
 use std::sync::{
     OnceLock,
     atomic::{AtomicPtr, Ordering},
@@ -402,17 +401,12 @@ impl TtyHandoff {
 
     /// Save the current tty modes into the owning job group, if we are transferred.
     pub fn save_tty_modes(&mut self) {
-        let Some(ref mut owner) = self.owner else {
-            return;
-        };
-        match tcgetattr(unsafe { BorrowedFd::borrow_raw(STDIN_FILENO) }) {
-            Ok(modes) => {
-                owner.tmodes.replace(Some(modes));
-            }
-            Err(err) => {
-                if err != nix::Error::ENOTTY {
-                    perror_nix("tcgetattr", err);
-                }
+        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");
             }
         }
     }
