use crate::error::{CliError, InterruptContext};
use std::io::Write;
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU8, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
const MESSAGE_GRACE: Duration = Duration::from_millis(200);
#[derive(Debug, Default)]
pub struct SignalState {
code: AtomicI32,
writing: AtomicBool,
phase: AtomicU8,
saved_termios: Mutex<Option<rustix::termios::Termios>>,
quiet: AtomicBool,
runtime_prompt: AtomicBool,
child: AtomicI32,
forwarded: AtomicBool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Phase {
Normal,
Prompt,
Waiting,
WaitingBody,
}
impl Phase {
fn from_raw(raw: u8) -> Self {
match raw {
1 => Self::Prompt,
2 => Self::Waiting,
3 => Self::WaitingBody,
_ => Self::Normal,
}
}
fn raw(self) -> u8 {
match self {
Self::Normal => 0,
Self::Prompt => 1,
Self::Waiting => 2,
Self::WaitingBody => 3,
}
}
}
pub const CANCELLED: &str = "Cancelled; no configuration saved.";
pub const CANCELLED_NOTHING_CHANGED: &str = "Cancelled; nothing was changed.";
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Action {
Cancel,
Exit(i32, Option<InterruptContext>),
CancelPrompt(i32),
ForwardToChild(i32),
KillChild,
}
impl Action {
pub fn restores_terminal(&self) -> bool {
!matches!(
self,
Self::Cancel | Self::ForwardToChild(_) | Self::KillChild
)
}
}
impl SignalState {
pub fn code(&self) -> Option<i32> {
match self.code.load(Ordering::SeqCst) {
0 => None,
code => Some(code),
}
}
pub fn begin_write(&self) -> WriteGuard<'_> {
self.writing.store(true, Ordering::SeqCst);
WriteGuard(self)
}
pub fn enter(&self, phase: Phase) -> PhaseGuard<'_> {
self.phase.store(phase.raw(), Ordering::SeqCst);
PhaseGuard(self)
}
pub fn phase(&self) -> Phase {
Phase::from_raw(self.phase.load(Ordering::SeqCst))
}
pub fn save_termios(&self, termios: Option<rustix::termios::Termios>) {
*self
.saved_termios
.lock()
.unwrap_or_else(PoisonError::into_inner) = termios;
}
pub fn set_quiet(&self, quiet: bool) {
self.quiet.store(quiet, Ordering::SeqCst);
}
pub fn set_runtime_prompt(&self) {
self.runtime_prompt.store(true, Ordering::SeqCst);
}
pub fn cancelled_status(&self) -> &'static str {
if self.runtime_prompt.load(Ordering::SeqCst) {
CANCELLED_NOTHING_CHANGED
} else {
CANCELLED
}
}
pub fn restore_termios(&self) -> bool {
let saved = self
.saved_termios
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take();
match saved {
Some(termios) => {
let _ = rustix::termios::tcsetattr(
std::io::stdin(),
rustix::termios::OptionalActions::Now,
&termios,
);
true
}
None => false,
}
}
pub fn attach_child(&self, pgid: i32) {
self.forwarded.store(false, Ordering::SeqCst);
self.child.store(pgid, Ordering::SeqCst);
if let Some(code) = self.code() {
self.forward_once(code);
}
}
pub fn detach_child(&self) {
self.child.store(0, Ordering::SeqCst);
}
pub fn child(&self) -> Option<i32> {
match self.child.load(Ordering::SeqCst) {
0 => None,
pgid => Some(pgid),
}
}
pub fn forward_once(&self, code: i32) {
let Some(pgid) = self.child() else {
return;
};
if self.forwarded.swap(true, Ordering::SeqCst) {
return;
}
let signal = if code == crate::error::EXIT_SIGTERM {
rustix::process::Signal::TERM
} else {
rustix::process::Signal::INT
};
signal_group(pgid, signal);
}
pub fn on_signal(&self, code: i32) -> Action {
match self
.code
.compare_exchange(0, code, Ordering::SeqCst, Ordering::SeqCst)
{
Ok(_) if self.child().is_some() => Action::ForwardToChild(code),
Err(_) if self.child().is_some() => Action::KillChild,
Ok(_) if self.writing.load(Ordering::SeqCst) => {
Action::Exit(code, Some(InterruptContext::OutputIncomplete))
}
Ok(_) => match self.phase() {
Phase::Prompt => Action::CancelPrompt(code),
Phase::Waiting => {
Action::Exit(code, Some(InterruptContext::NoConfigurationChanged))
}
Phase::WaitingBody => Action::Exit(code, Some(InterruptContext::NothingChanged)),
Phase::Normal => Action::Cancel,
},
Err(first) => Action::Exit(first, None),
}
}
}
fn signal_group(pgid: i32, signal: rustix::process::Signal) {
if let Some(pid) = rustix::process::Pid::from_raw(pgid) {
let _ = rustix::process::kill_process_group(pid, signal);
}
}
#[derive(Debug)]
pub struct PhaseGuard<'a>(&'a SignalState);
impl Drop for PhaseGuard<'_> {
fn drop(&mut self) {
self.0.phase.store(Phase::Normal.raw(), Ordering::SeqCst);
}
}
#[derive(Debug)]
pub struct WriteGuard<'a>(&'a SignalState);
impl Drop for WriteGuard<'_> {
fn drop(&mut self) {
self.0.writing.store(false, Ordering::SeqCst);
}
}
pub fn install(session: scm::Session) -> Arc<SignalState> {
let state = Arc::new(SignalState::default());
let thread_state = Arc::clone(&state);
let (ready, listening) = std::sync::mpsc::channel::<()>();
let started = std::thread::Builder::new()
.name("signals".into())
.spawn(move || {
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
else {
let _ = ready.send(());
return;
};
runtime.block_on(watch(&thread_state, &session, ready));
});
if started.is_ok() {
let _ = listening.recv_timeout(Duration::from_secs(5));
}
state
}
async fn watch(state: &SignalState, session: &scm::Session, ready: std::sync::mpsc::Sender<()>) {
#[cfg(unix)]
{
use crate::error::{EXIT_SIGINT, EXIT_SIGTERM};
use tokio::signal::unix::{SignalKind, signal as listen};
let (Ok(mut interrupt), Ok(mut terminate)) = (
listen(SignalKind::interrupt()),
listen(SignalKind::terminate()),
) else {
let _ = ready.send(());
return;
};
let _ = ready.send(());
loop {
let code = tokio::select! {
_ = interrupt.recv() => EXIT_SIGINT,
_ = terminate.recv() => EXIT_SIGTERM,
};
act(state, state.on_signal(code), session);
}
}
#[cfg(not(unix))]
{
let _ = ready.send(());
while tokio::signal::ctrl_c().await.is_ok() {
act(state, state.on_signal(crate::error::EXIT_SIGINT), session);
}
}
}
fn write_stderr_briefly(text: String) {
let (done, finished) = std::sync::mpsc::channel::<()>();
let _ = std::thread::Builder::new().spawn(move || {
let mut stderr = std::io::stderr().lock();
let _ = stderr.write_all(text.as_bytes());
let _ = stderr.flush();
let _ = done.send(());
});
let _ = finished.recv_timeout(MESSAGE_GRACE);
}
#[cfg(test)]
pub fn cancelled_text(quiet: bool, color: bool) -> String {
cancelled_text_with(CANCELLED, quiet, color)
}
pub fn cancelled_text_with(status: &str, quiet: bool, color: bool) -> String {
if quiet {
"\n".to_owned()
} else {
format!(
"\n {}\n",
crate::render::style::paint(crate::render::style::Style::Dim, status, color)
)
}
}
fn act(state: &SignalState, action: Action, session: &scm::Session) {
if action.restores_terminal() {
state.restore_termios();
}
match action {
Action::Cancel => session.cancel(),
Action::ForwardToChild(code) => {
session.cancel();
state.forward_once(code);
}
Action::KillChild => {
if let Some(pgid) = state.child() {
signal_group(pgid, rustix::process::Signal::KILL);
}
}
Action::CancelPrompt(code) => {
session.cancel();
write_stderr_briefly(cancelled_text_with(
state.cancelled_status(),
state.quiet.load(Ordering::SeqCst),
crate::render::style::stderr_color(),
));
std::process::exit(code);
}
Action::Exit(code, context) => {
session.cancel();
if let Some(context) = context {
write_stderr_briefly(
CliError::Interrupted(context)
.render_colored(crate::render::style::stderr_color()),
);
}
std::process::exit(code);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_signal_cancels_and_second_exits_with_the_first_code() {
let state = SignalState::default();
assert_eq!(state.code(), None);
assert_eq!(state.on_signal(130), Action::Cancel);
assert_eq!(state.code(), Some(130));
assert_eq!(state.on_signal(143), Action::Exit(130, None));
assert_eq!(state.code(), Some(130), "the first code is kept");
assert!(!Action::Cancel.restores_terminal());
assert!(Action::Exit(130, None).restores_terminal());
assert!(Action::Exit(143, Some(InterruptContext::OutputIncomplete)).restores_terminal());
assert!(Action::CancelPrompt(130).restores_terminal());
}
#[test]
fn a_running_child_gets_the_signal_and_the_command_waits() {
let state = SignalState::default();
state.attach_child(999_999);
assert_eq!(state.child(), Some(999_999));
assert_eq!(state.on_signal(143), Action::ForwardToChild(143));
assert_eq!(state.on_signal(130), Action::KillChild);
assert!(!Action::ForwardToChild(143).restores_terminal());
assert!(!Action::KillChild.restores_terminal());
state.detach_child();
assert_eq!(state.child(), None);
assert_eq!(state.on_signal(130), Action::Exit(143, None));
}
#[test]
fn a_signal_during_a_stdout_write_exits_at_once() {
let state = SignalState::default();
{
let _writing = state.begin_write();
assert_eq!(
state.on_signal(143),
Action::Exit(143, Some(InterruptContext::OutputIncomplete))
);
}
let state = SignalState::default();
drop(state.begin_write());
assert_eq!(
state.on_signal(143),
Action::Cancel,
"the guard cleared the flag"
);
}
#[test]
fn prompts_and_waits_end_the_process_at_once() {
let state = SignalState::default();
{
let _prompt = state.enter(Phase::Prompt);
assert_eq!(state.phase(), Phase::Prompt);
assert_eq!(state.on_signal(130), Action::CancelPrompt(130));
}
assert_eq!(state.phase(), Phase::Normal, "the guard restored the phase");
let state = SignalState::default();
{
let _wait = state.enter(Phase::Waiting);
assert_eq!(
state.on_signal(143),
Action::Exit(143, Some(InterruptContext::NoConfigurationChanged))
);
}
let state = SignalState::default();
{
let _wait = state.enter(Phase::WaitingBody);
assert_eq!(
state.on_signal(130),
Action::Exit(130, Some(InterruptContext::NothingChanged))
);
}
assert_eq!(state.phase(), Phase::Normal);
assert!(!state.restore_termios(), "nothing saved");
assert_eq!(
cancelled_text(false, false),
"\n Cancelled; no configuration saved.\n"
);
assert_eq!(cancelled_text(true, false), "\n");
assert_eq!(
cancelled_text(false, true),
"\n \u{1b}[2mCancelled; no configuration saved.\u{1b}[22m\n"
);
}
}