Propagate SIGINT from foreground jobs to interrupt shell loops, add SIGUSR1 for async prompt refresh, and support SHED_HPAGER override

This commit is contained in:
2026-03-16 19:00:33 -04:00
parent 958dad9942
commit db3f1b5108
7 changed files with 64 additions and 23 deletions

View File

@@ -1,6 +1,6 @@
# shed
A Linux shell written in Rust. The name is a nod to the original Unix utilities `sh` and `ed`. It's a shell with a heavy emphasis on smooth line editing.
A Linux shell written in Rust. The name is a nod to the original Unix utilities `sh` and `ed`. It's a shell with a heavy emphasis on smooth line editing and general interactive UX improvements over existing shells. <small>Btw if you don't use `vim` this probably isn't your shell.</small>
<img width="506" height="407" alt="shed" src="https://github.com/user-attachments/assets/3945f663-a361-4418-bf20-0c4eaa2a36d2" />
@@ -40,6 +40,8 @@ gitbranch() { git branch --show-current 2>/dev/null; }
export PS1='\u@\h \W \@gitbranch \$ '
```
If `shed` receives `SIGUSR1` while in interactive mode, it will refresh and redraw the prompt. This can be used to create asynchronous, dynamic prompt content.
Additionally, `echo` now has a `-p` flag that expands prompt escape sequences, similar to how the `-e` flag expands conventional escape sequences.
---

View File

@@ -121,7 +121,11 @@ pub fn help(node: Node) -> ShResult<()> {
}
pub fn open_help(content: &str, line: Option<usize>, file_name: Option<String>) -> ShResult<()> {
let pager = env::var("PAGER").unwrap_or("less -R".into());
let pager = env::var("SHED_HPAGER")
.unwrap_or(
env::var("PAGER")
.unwrap_or("less -R".into()),
);
let line_arg = line.map(|ln| format!("+{ln}")).unwrap_or_default();
let prompt_arg = file_name
.map(|name| format!("-Ps'{name}'"))

View File

@@ -1,6 +1,7 @@
use std::collections::VecDeque;
use ariadne::Fmt;
use nix::unistd::getpid;
use scopeguard::defer;
use yansi::Color;
@@ -872,7 +873,12 @@ pub fn wait_fg(job: Job, interactive: bool) -> ShResult<()> {
write_jobs(|j| j.fg_to_bg(*status))?;
}
WtStat::Signaled(_, sig, _) => {
if *sig == Signal::SIGTSTP {
if *sig == Signal::SIGINT {
// interrupt propagates to the shell
// necessary for interrupting stuff like
// while/for loops
kill(getpid(), Signal::SIGINT)?;
} else if *sig == Signal::SIGTSTP {
was_stopped = true;
write_jobs(|j| j.fg_to_bg(*status))?;
}

View File

@@ -459,7 +459,7 @@ pub enum ShErrKind {
FuncReturn(i32),
LoopContinue(i32),
LoopBreak(i32),
ClearReadline,
Interrupt,
Null,
}
@@ -471,7 +471,7 @@ impl ShErrKind {
| Self::FuncReturn(_)
| Self::LoopContinue(_)
| Self::LoopBreak(_)
| Self::ClearReadline
| Self::Interrupt
)
}
}
@@ -496,7 +496,7 @@ impl Display for ShErrKind {
Self::LoopBreak(_) => "Syntax Error",
Self::ReadlineErr => "Readline Error",
Self::ExCommand => "Ex Command Error",
Self::ClearReadline => "",
Self::Interrupt => "",
Self::Null => "",
};
write!(f, "{output}")

View File

@@ -38,7 +38,7 @@ use crate::prelude::*;
use crate::procio::borrow_fd;
use crate::readline::term::{LineWriter, RawModeGuard, raw_mode};
use crate::readline::{Prompt, ReadlineEvent, ShedVi};
use crate::signal::{GOT_SIGWINCH, JOB_DONE, QUIT_CODE, check_signals, sig_setup, signals_pending};
use crate::signal::{GOT_SIGUSR1, GOT_SIGWINCH, JOB_DONE, QUIT_CODE, check_signals, sig_setup, signals_pending};
use crate::state::{
AutoCmdKind, read_logic, read_shopts, source_env, source_login, source_rc, write_jobs,
write_meta, write_shopts,
@@ -259,7 +259,7 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
while signals_pending() {
if let Err(e) = check_signals() {
match e.kind() {
ShErrKind::ClearReadline => {
ShErrKind::Interrupt => {
// We got Ctrl+C - clear current input and redraw
readline.reset_active_widget(false)?;
}
@@ -285,9 +285,16 @@ fn shed_interactive(args: ShedArgs) -> ShResult<()> {
readline.prompt_mut().refresh();
}
if GOT_SIGUSR1.swap(false, Ordering::SeqCst) {
log::info!("SIGUSR1 received: refreshing readline state");
readline.mark_dirty();
readline.prompt_mut().refresh();
}
readline.print_line(false)?;
// Poll for stdin input
// Poll for
// stdin input
let mut fds = [PollFd::new(
unsafe { BorrowedFd::borrow_raw(*TTY_FILENO) },
PollFlags::POLLIN,
@@ -435,6 +442,10 @@ fn handle_readline_event(readline: &mut ShedVi, event: ShResult<ReadlineEvent>)
}) {
// CleanExit signals an intentional shell exit; any other error is printed.
match e.kind() {
ShErrKind::Interrupt => {
// We got Ctrl+C during command execution
// Just fall through here
}
ShErrKind::CleanExit(code) => {
QUIT_CODE.store(*code, Ordering::SeqCst);
return Ok(true);

View File

@@ -32,19 +32,13 @@ use crate::{
test::double_bracket_test,
trap::{TrapTarget, trap},
varcmds::{export, local, readonly, unset},
},
expand::{expand_aliases, expand_case_pattern, glob_to_regex},
jobs::{ChildProc, JobStack, attach_tty, dispatch_job},
libsh::{
}, expand::{expand_aliases, expand_case_pattern, glob_to_regex}, jobs::{ChildProc, JobStack, attach_tty, dispatch_job}, libsh::{
error::{ShErr, ShErrKind, ShResult, ShResultExt, next_color},
guards::{scope_guard, var_ctx_guard},
utils::RedirVecUtils,
},
prelude::*,
procio::{IoMode, IoStack, PipeGenerator},
state::{
}, prelude::*, procio::{IoMode, IoStack, PipeGenerator}, signal::{check_signals, signals_pending}, state::{
self, ShFunc, VarFlags, VarKind, read_logic, read_shopts, write_jobs, write_logic, write_vars,
},
}
};
use super::{
@@ -273,6 +267,13 @@ impl Dispatcher {
Ok(())
}
pub fn dispatch_node(&mut self, node: Node) -> ShResult<()> {
while signals_pending() {
// If we have received SIGINT,
// this will stop the execution here
// and propagate back to the functions in main.rs
check_signals()?;
}
match node.class {
NdRule::Conjunction { .. } => self.exec_conjunction(node)?,
NdRule::Pipeline { .. } => self.exec_pipeline(node)?,

View File

@@ -3,7 +3,7 @@ use std::{
sync::atomic::{AtomicBool, AtomicI32, AtomicU64, Ordering},
};
use nix::sys::signal::{SaFlags, SigAction, sigaction};
use nix::{sys::signal::{SaFlags, SigAction, sigaction}, unistd::getpid};
use crate::{
builtin::trap::TrapTarget,
@@ -21,17 +21,22 @@ static SIGNALS: AtomicU64 = AtomicU64::new(0);
pub static REAPING_ENABLED: AtomicBool = AtomicBool::new(true);
pub static SHOULD_QUIT: AtomicBool = AtomicBool::new(false);
pub static GOT_SIGWINCH: AtomicBool = AtomicBool::new(false);
pub static JOB_DONE: AtomicBool = AtomicBool::new(false);
pub static QUIT_CODE: AtomicI32 = AtomicI32::new(0);
const MISC_SIGNALS: [Signal; 22] = [
/// Window size change signal
pub static GOT_SIGWINCH: AtomicBool = AtomicBool::new(false);
/// SIGUSR1 tells the prompt that it needs to fully refresh.
/// Useful for dynamic prompt content and asynchronous refreshing
pub static GOT_SIGUSR1: AtomicBool = AtomicBool::new(false);
const MISC_SIGNALS: [Signal; 21] = [
Signal::SIGILL,
Signal::SIGTRAP,
Signal::SIGABRT,
Signal::SIGBUS,
Signal::SIGFPE,
Signal::SIGUSR1,
Signal::SIGSEGV,
Signal::SIGUSR2,
Signal::SIGPIPE,
@@ -71,7 +76,7 @@ pub fn check_signals() -> ShResult<()> {
if got_signal(Signal::SIGINT) {
interrupt()?;
run_trap(Signal::SIGINT)?;
return Err(ShErr::simple(ShErrKind::ClearReadline, ""));
return Err(ShErr::simple(ShErrKind::Interrupt, ""));
}
if got_signal(Signal::SIGHUP) {
run_trap(Signal::SIGHUP)?;
@@ -93,6 +98,10 @@ pub fn check_signals() -> ShResult<()> {
GOT_SIGWINCH.store(true, Ordering::SeqCst);
run_trap(Signal::SIGWINCH)?;
}
if got_signal(Signal::SIGUSR1) {
GOT_SIGUSR1.store(true, Ordering::SeqCst);
run_trap(Signal::SIGUSR1)?;
}
for sig in MISC_SIGNALS {
if got_signal(sig) {
@@ -324,6 +333,14 @@ pub fn child_exited(pid: Pid, status: WtStat) -> ShResult<()> {
let job_complete_msg = job.display(&job_order, JobCmdFlags::PIDS).to_string();
let statuses = job.get_stats();
for status in &statuses {
if let WtStat::Signaled(_, sig, _) = status
&& *sig == Signal::SIGINT {
// Necessary to interrupt stuff like shell loops
kill(getpid(), Signal::SIGINT).ok();
}
}
if let Some(pipe_status) = Job::pipe_status(&statuses) {
let pipe_status = pipe_status
.into_iter()