use std::io::{self, Read, Write};
use std::os::fd::{AsFd, OwnedFd};
use std::os::unix::net::UnixStream;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError, mpsc};
use std::time::{Duration, Instant};
use crossterm::event as ct;
use nix::sys::signal::{SigSet, Signal as NixSignal};
use rustix::event::{PollFd, PollFlags, Timespec, poll};
use rustix::fs::{Mode, OFlags};
use rustix::process::{Signal, getpgrp, kill_process_group};
use rustix::termios::{OptionalActions, Termios, isatty, tcgetattr, tcgetpgrp, tcsetattr};
use signal_hook::consts::{SIGHUP, SIGINT, SIGTERM, SIGWINCH};
use signal_hook::iterator::Signals as Caught;
use signal_hook::low_level::emulate_default_handler;
use super::termination::{self, Ending, Step, Termination};
const FORCE_MARGIN: Duration = Duration::from_secs(1);
static RUN: Mutex<Option<Run>> = Mutex::new(None);
static SERIAL: AtomicU64 = AtomicU64::new(0);
static HUNG_UP: AtomicBool = AtomicBool::new(false);
struct Run {
serial: u64,
epoch: Instant,
ending: Option<Ending>,
signal: i32,
heard: Vec<Termination>,
resized: bool,
wake: UnixStream,
tty: OwnedFd,
original: Option<Termios>,
handoff: Option<bool>,
force_at: Option<Instant>,
reads_stdin: bool,
stand_in: Option<UnixStream>,
}
#[derive(Debug, Default)]
pub(crate) struct Heard {
pub(crate) causes: Vec<Termination>,
pub(crate) resized: bool,
}
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct Woken {
pub(crate) keyboard: bool,
pub(crate) hung_up: bool,
}
pub(crate) struct Signals {
wake: UnixStream,
tty: OwnedFd,
}
impl Signals {
pub(crate) fn catch() -> io::Result<Self> {
start_catching()?;
let reads_stdin = isatty(io::stdin());
let tty = terminal()?;
let (wake, wake_write) = UnixStream::pair()?;
wake.set_nonblocking(true)?;
wake_write.set_nonblocking(true)?;
let run = Run {
serial: SERIAL.fetch_add(1, Ordering::Relaxed),
epoch: Instant::now(),
ending: None,
signal: SIGTERM,
heard: Vec::new(),
resized: false,
wake: wake_write,
tty: tty.try_clone()?,
original: tcgetattr(&tty).ok(),
handoff: None,
force_at: None,
reads_stdin,
stand_in: None,
};
let mut slot = lock();
if slot.is_some() {
return Err(io::Error::other("a terminal runtime is already running"));
}
*slot = Some(run);
Ok(Self { wake, tty })
}
pub(crate) fn take(&self) -> Heard {
let mut slot = lock();
let Some(run) = slot.as_mut() else {
return Heard::default();
};
Heard { causes: std::mem::take(&mut run.heard), resized: std::mem::take(&mut run.resized) }
}
pub(crate) fn pending(&self) -> bool {
lock().as_ref().is_some_and(|run| !run.heard.is_empty())
}
pub(crate) fn hung_up(&self) {
if let Some(run) = lock().as_mut() {
note(run, Termination::Hangup, SIGHUP);
release_input(run);
}
}
pub(crate) fn terminal_gone(&self) -> bool {
tcgetattr(&self.tty).is_err()
}
pub(crate) fn hung_up_now(&self) -> bool {
let mut fds = [PollFd::new(&self.tty, PollFlags::empty())];
let now = Timespec { tv_sec: 0, tv_nsec: 0 };
poll(&mut fds, Some(&now)).is_ok() && fds[0].revents().contains(PollFlags::HUP)
}
pub(crate) fn handoff(&self, running: bool) {
let owned = running && tcgetpgrp(&self.tty).is_ok_and(|group| group == getpgrp());
if let Some(run) = lock().as_mut() {
run.handoff = running.then_some(owned);
}
}
pub(crate) fn wait(&self, timeout: Duration, keyboard: bool) -> io::Result<Woken> {
let limit = Timespec::try_from(timeout).map_err(|_| io::Error::other("wait too long"))?;
let mut fds = [PollFd::new(&self.wake, PollFlags::IN), PollFd::new(&self.tty, PollFlags::IN)];
let count = if keyboard { 2 } else { 1 };
match poll(&mut fds[..count], Some(&limit)) {
Ok(_) | Err(rustix::io::Errno::INTR) => {}
Err(error) => return Err(error.into()),
}
let mut drained = [0_u8; 64];
while matches!((&self.wake).read(&mut drained), Ok(1..)) {}
if !keyboard {
return Ok(Woken::default());
}
let terminal = fds[1].revents();
if terminal.contains(PollFlags::NVAL) {
return Ok(Woken { keyboard: ct::poll(timeout)?, hung_up: false });
}
Ok(Woken {
keyboard: terminal.intersects(PollFlags::IN | PollFlags::ERR),
hung_up: terminal.contains(PollFlags::HUP),
})
}
}
impl Drop for Signals {
fn drop(&mut self) {
*lock() = None;
}
}
fn lock() -> MutexGuard<'static, Option<Run>> {
RUN.lock().unwrap_or_else(PoisonError::into_inner)
}
fn terminal() -> io::Result<OwnedFd> {
let stdin = io::stdin();
if isatty(&stdin) {
return stdin.as_fd().try_clone_to_owned();
}
Ok(rustix::fs::open("/dev/tty", OFlags::RDWR | OFlags::NOCTTY | OFlags::CLOEXEC, Mode::empty())?)
}
fn start_catching() -> io::Result<()> {
static STARTED: OnceLock<Result<(), String>> = OnceLock::new();
STARTED
.get_or_init(|| {
let (started, result) = mpsc::channel();
std::thread::Builder::new()
.name("quvyta-signals".to_owned())
.spawn(move || match Caught::new([SIGTERM, SIGINT, SIGHUP, SIGWINCH]) {
Ok(mut caught) => {
let _ = started.send(Ok(()));
for signal in caught.forever() {
hear(signal);
}
}
Err(error) => {
let _ = started.send(Err(error.to_string()));
}
})
.map_err(|error| error.to_string())?;
result.recv().map_err(|error| error.to_string())?
})
.clone()
.map_err(io::Error::other)
}
fn hear(signal: i32) {
let mut slot = lock();
let Some(run) = slot.as_mut() else {
drop(slot);
let echo = signal == SIGHUP && HUNG_UP.load(Ordering::Relaxed);
if signal != SIGWINCH && !echo {
let _ = emulate_default_handler(signal);
}
return;
};
if signal == SIGWINCH {
run.resized = true;
} else {
let cause = if signal == SIGHUP { Termination::Hangup } else { Termination::Terminate };
if note(run, cause, signal) != Step::Ignore {
forward(run, signal);
}
if signal == SIGHUP && tcgetattr(&run.tty).is_err() {
release_input(run);
}
}
let _ = (&run.wake).write(&[0]);
}
fn release_input(run: &mut Run) {
HUNG_UP.store(true, Ordering::Relaxed);
if !run.reads_stdin || run.stand_in.is_some() {
return;
}
let Ok((ours, theirs)) = UnixStream::pair() else {
return;
};
if theirs.set_nonblocking(true).is_ok() && rustix::stdio::dup2_stdin(&theirs).is_ok() {
run.stand_in = Some(ours);
}
}
fn note(run: &mut Run, cause: Termination, signal: i32) -> Step {
let step = termination::receive(&mut run.ending, cause, run.epoch.elapsed());
let force_at = match step {
Step::Ignore => return step,
Step::Ask(_) => run.epoch + run.ending.map_or(Duration::ZERO, |ending| ending.deadline) + FORCE_MARGIN,
Step::End => Instant::now() + FORCE_MARGIN,
};
run.signal = signal;
run.heard.push(cause);
if run.force_at.is_none_or(|at| force_at < at) {
run.force_at = Some(force_at);
schedule(run.serial, force_at);
}
step
}
fn schedule(serial: u64, at: Instant) {
let _ = std::thread::Builder::new().name("quvyta-ending".to_owned()).spawn(move || {
std::thread::sleep(at.saturating_duration_since(Instant::now()));
let slot = lock();
if let Some(run) = slot.as_ref()
&& run.serial == serial
&& run.force_at.is_some_and(|due| due <= Instant::now())
{
force(run);
}
});
}
fn forward(run: &Run, signal: i32) {
if run.handoff != Some(true) {
return;
}
if let (Ok(group), Some(signal)) = (tcgetpgrp(&run.tty), Signal::from_named_raw(signal))
&& group != getpgrp()
{
let _ = kill_process_group(group, signal);
}
}
fn force(run: &Run) -> ! {
if run.handoff == Some(true)
&& let Ok(group) = tcgetpgrp(&run.tty)
&& group != getpgrp()
{
let _ = kill_process_group(group, Signal::KILL);
}
restore(run);
let _ = emulate_default_handler(run.signal);
std::process::exit(128 + run.signal)
}
fn restore(run: &Run) {
let Some(original) = run.original.as_ref() else {
return;
};
if tcgetattr(&run.tty).is_err() {
return;
}
let mut ttou = SigSet::empty();
ttou.add(NixSignal::SIGTTOU);
let _ = ttou.thread_block();
let raw_off = || tcsetattr(&run.tty, OptionalActions::Now, original).map_err(io::Error::from);
match rustix::fs::open(
"/dev/tty",
OFlags::WRONLY | OFlags::NOCTTY | OFlags::CLOEXEC | OFlags::NONBLOCK,
Mode::empty(),
) {
Ok(screen) => {
let _ = super::terminal::give_back(&mut std::fs::File::from(screen), true, raw_off);
}
Err(_) => {
let _ = raw_off();
}
}
}