Improve signal handling (#2488)

This commit is contained in:
Casey Rodarmor 2025-03-14 17:32:28 -07:00 committed by GitHub
parent 679a9403ac
commit 731a2d20a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1096 additions and 433 deletions

52
Cargo.lock generated
View file

@ -193,9 +193,9 @@ dependencies = [
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.31" version = "4.5.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "027bb0d98429ae334a8698531da7077bdf906419543a35a55c2cb1b66437d767" checksum = "6088f3ae8c3608d19260cd7445411865a485688711b78b5be70d78cd96136f83"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@ -203,9 +203,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.5.31" version = "4.5.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5589e0cba072e0f3d23791efac0fd8627b49c829c196a492e88168e6a669d863" checksum = "22a7ef7f676155edfb82daa97f99441f3ebf4a58d5e32f295a56259f1b6facc8"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@ -225,9 +225,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.5.28" version = "4.5.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed" checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
@ -575,6 +575,7 @@ dependencies = [
"is_executable", "is_executable",
"lexiclean", "lexiclean",
"libc", "libc",
"nix",
"num_cpus", "num_cpus",
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
@ -607,9 +608,9 @@ checksum = "441225017b106b9f902e97947a6d31e44ebcf274b91bdbfb51e5c477fcd468e5"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.170" version = "0.2.171"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6"
[[package]] [[package]]
name = "libredox" name = "libredox"
@ -629,9 +630,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.9.2" version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db9c683daf087dc577b7506e9695b3d556a9f3849903fa28186283afd6809e9" checksum = "fe7db12097d22ec582439daf8618b8fdd1a7bef6270e9af3b1ebcd30893cf413"
[[package]] [[package]]
name = "log" name = "log"
@ -687,9 +688,9 @@ dependencies = [
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.20.3" version = "1.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc"
[[package]] [[package]]
name = "option-ext" name = "option-ext"
@ -754,9 +755,9 @@ dependencies = [
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.39" version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1f1914ce909e1658d9907913b4b91947430c7d9be598b15a1912935b8c04801" checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@ -873,14 +874,14 @@ dependencies = [
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.0.1" version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dade4812df5c384711475be5fcd8c162555352945401aed22a35bffeab61f657" checksum = "f7178faa4b75a30e269c71e61c353ce2748cf3d76f0c44c393f4e60abf49b825"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.9.2", "linux-raw-sys 0.9.3",
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
@ -1021,9 +1022,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.99" version = "2.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02e925281e18ffd9d640e234264753c43edc62d64b2d4cf898f1bc5e75f3fc2" checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -1038,15 +1039,14 @@ checksum = "1e8f05f774b2db35bdad5a8237a90be1102669f8ea013fea9777b366d34ab145"
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.18.0" version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c317e0a526ee6120d8dabad239c8dadca62b24b6f168914bbbc8e2fb1f0e567" checksum = "488960f40a3fd53d72c2a29a58722561dee8afdd175bd88e3db4677d7b2ba600"
dependencies = [ dependencies = [
"cfg-if",
"fastrand", "fastrand",
"getrandom 0.3.1", "getrandom 0.3.1",
"once_cell", "once_cell",
"rustix 1.0.1", "rustix 1.0.2",
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
@ -1065,7 +1065,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed"
dependencies = [ dependencies = [
"rustix 1.0.1", "rustix 1.0.2",
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
@ -1166,9 +1166,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.15.1" version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0f540e3240398cce6128b64ba83fdbdd86129c16a3aa1a3a252efd66eb3d587" checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9"
dependencies = [ dependencies = [
"getrandom 0.3.1", "getrandom 0.3.1",
] ]

View file

@ -54,6 +54,12 @@ typed-arena = "2.0.1"
unicode-width = "0.2.0" unicode-width = "0.2.0"
uuid = { version = "1.0.0", features = ["v4"] } uuid = { version = "1.0.0", features = ["v4"] }
[target.'cfg(unix)'.dependencies]
nix = { version = "0.29.0", features = ["user"] }
[target.'cfg(windows)'.dependencies]
ctrlc = { version = "3.1.1", features = ["termination"] }
[dev-dependencies] [dev-dependencies]
executable-path = "1.0.0" executable-path = "1.0.0"
pretty_assertions = "1.0.0" pretty_assertions = "1.0.0"
@ -74,6 +80,7 @@ struct_excessive_bools = "allow"
struct_field_names = "allow" struct_field_names = "allow"
too_many_arguments = "allow" too_many_arguments = "allow"
too_many_lines = "allow" too_many_lines = "allow"
undocumented_unsafe_blocks = "deny"
unnecessary_wraps = "allow" unnecessary_wraps = "allow"
wildcard_imports = "allow" wildcard_imports = "allow"

View file

@ -3976,6 +3976,57 @@ the
[`chrono` library docs](https://docs.rs/chrono/latest/chrono/format/strftime/index.html) [`chrono` library docs](https://docs.rs/chrono/latest/chrono/format/strftime/index.html)
for details. for details.
### Signal Handling
[Signals](https://en.wikipedia.org/wiki/Signal_(IPC)) are messsages sent to
running programs to trigger specific behavior. For example, `SIGINT` is sent to
all processes in the terminal forground process group when `CTRL-C` is pressed.
`just` tries to exit when requested by a signal, but it also tries to avoid
leaving behind running child proccesses, two goals which are somewhat in
conflict.
If `just` exits leaving behind child processes, the user will have no recourse
but to `ps aux | grep` for the children and manually `kill` them, a tedious
endevour.
#### Fatal Signals
`SIGHUP`, `SIGINT`, and `SIGQUIT` are generated when the user closes the
terminal, types `ctrl-c`, or types `ctrl-\`, respectively, and are sent to all
processes in the foreground process group.
`SIGTERM` is the default signal sent by the `kill` command, and is delivered
only to its intended victim.
When a child process is not running, `just` will exit immediately on receipt of
any of the above signals.
When a child process *is* running, `just` will wait until it terminates, to
avoid leaving it behind.
Additionally, on receipt of `SIGTERM`, `just` will forward `SIGTERM` to any
running children<sup>master</sup>, since unlike other fatal signals, `SIGTERM`,
was likely sent to `just` alone.
Regardless of whether a child process terminates successfully after `just`
receives a fatal signal, `just` halts execution.
#### `SIGINFO`
`SIGINFO` is sent to all processes in the foreground process group when the
user types `ctrl-t` on
[BSD](https://en.wikipedia.org/wiki/Berkeley_Software_Distribution)-derived
operating systems, including MacOS, but not Linux.
`just` responds by printing a list of all child process IDs and
commands<sup>master</sup>.
#### Windows
On Windows, `just` behaves as if it had received `SIGINT` when the user types
`ctrl-c`. Other signals are unsupported.
Changelog Changelog
--------- ---------

View file

@ -17,7 +17,7 @@ test:
cargo test --all cargo test --all
[group: 'check'] [group: 'check']
ci: forbid test build-book clippy ci: test clippy build-book forbid
cargo fmt --all -- --check cargo fmt --all -- --check
cargo update --locked --package just cargo update --locked --package just

View file

@ -7,9 +7,15 @@ pub(crate) trait CommandExt {
dotenv: &BTreeMap<String, String>, dotenv: &BTreeMap<String, String>,
scope: &Scope, scope: &Scope,
unexports: &HashSet<String>, unexports: &HashSet<String>,
); ) -> &mut Command;
fn export_scope(&mut self, settings: &Settings, scope: &Scope, unexports: &HashSet<String>); fn export_scope(&mut self, settings: &Settings, scope: &Scope, unexports: &HashSet<String>);
fn output_guard(self) -> (io::Result<process::Output>, Option<Signal>);
fn output_guard_stdout(self) -> Result<String, OutputError>;
fn status_guard(self) -> (io::Result<ExitStatus>, Option<Signal>);
} }
impl CommandExt for Command { impl CommandExt for Command {
@ -19,7 +25,7 @@ impl CommandExt for Command {
dotenv: &BTreeMap<String, String>, dotenv: &BTreeMap<String, String>,
scope: &Scope, scope: &Scope,
unexports: &HashSet<String>, unexports: &HashSet<String>,
) { ) -> &mut Command {
for (name, value) in dotenv { for (name, value) in dotenv {
self.env(name, value); self.env(name, value);
} }
@ -27,6 +33,8 @@ impl CommandExt for Command {
if let Some(parent) = scope.parent() { if let Some(parent) = scope.parent() {
self.export_scope(settings, parent, unexports); self.export_scope(settings, parent, unexports);
} }
self
} }
fn export_scope(&mut self, settings: &Settings, scope: &Scope, unexports: &HashSet<String>) { fn export_scope(&mut self, settings: &Settings, scope: &Scope, unexports: &HashSet<String>) {
@ -44,4 +52,34 @@ impl CommandExt for Command {
} }
} }
} }
fn output_guard(self) -> (io::Result<process::Output>, Option<Signal>) {
SignalHandler::spawn(self, process::Child::wait_with_output)
}
fn output_guard_stdout(self) -> Result<String, OutputError> {
let (result, caught) = self.output_guard();
let output = result.map_err(OutputError::Io)?;
OutputError::result_from_exit_status(output.status)?;
let output = str::from_utf8(&output.stdout).map_err(OutputError::Utf8)?;
if let Some(signal) = caught {
return Err(OutputError::Interrupted(signal));
}
Ok(
output
.strip_suffix("\r\n")
.or_else(|| output.strip_suffix("\n"))
.unwrap_or(output)
.into(),
)
}
fn status_guard(self) -> (io::Result<ExitStatus>, Option<Signal>) {
SignalHandler::spawn(self, |mut child| child.wait())
}
} }

View file

@ -112,6 +112,9 @@ pub(crate) enum Error<'src> {
Internal { Internal {
message: String, message: String,
}, },
Interrupted {
signal: Signal,
},
Io { Io {
recipe: &'src str, recipe: &'src str,
io_error: io::Error, io_error: io::Error,
@ -158,6 +161,23 @@ pub(crate) enum Error<'src> {
line_number: Option<usize>, line_number: Option<usize>,
signal: i32, signal: i32,
}, },
#[cfg(windows)]
SignalHandlerInstall {
source: ctrlc::Error,
},
#[cfg(unix)]
SignalHandlerPipeOpen {
io_error: io::Error,
},
#[cfg(unix)]
SignalHandlerSigaction {
signal: Signal,
io_error: io::Error,
},
#[cfg(unix)]
SignalHandlerSpawnThread {
io_error: io::Error,
},
StdoutIo { StdoutIo {
io_error: io::Error, io_error: io::Error,
}, },
@ -194,12 +214,23 @@ pub(crate) enum Error<'src> {
impl<'src> Error<'src> { impl<'src> Error<'src> {
pub(crate) fn code(&self) -> Option<i32> { pub(crate) fn code(&self) -> Option<i32> {
match self { match self {
Self::Code { code, .. } Self::Backtick {
| Self::Backtick {
output_error: OutputError::Code(code), output_error: OutputError::Code(code),
.. ..
} => Some(*code), }
| Self::Code { code, .. } => Some(*code),
Self::ChooserStatus { status, .. } | Self::EditorStatus { status, .. } => status.code(), Self::ChooserStatus { status, .. } | Self::EditorStatus { status, .. } => status.code(),
Self::Backtick {
output_error: OutputError::Signal(signal),
..
}
| Self::Signal { signal, .. } => 128i32.checked_add(*signal),
Self::Backtick {
output_error: OutputError::Interrupted(signal),
..
}
| Self::Interrupted { signal } => signal.code(),
_ => None, _ => None,
} }
} }
@ -291,6 +322,7 @@ impl ColorDisplay for Error<'_> {
OutputError::Code(code) => write!(f, "Backtick failed with exit code {code}")?, OutputError::Code(code) => write!(f, "Backtick failed with exit code {code}")?,
OutputError::Signal(signal) => write!(f, "Backtick was terminated by signal {signal}")?, OutputError::Signal(signal) => write!(f, "Backtick was terminated by signal {signal}")?,
OutputError::Unknown => write!(f, "Backtick failed for an unknown reason")?, OutputError::Unknown => write!(f, "Backtick failed for an unknown reason")?,
OutputError::Interrupted(signal) => write!(f, "Backtick succeeded but `just` was interrupted by signal {signal}")?,
OutputError::Io(io_error) => match io_error.kind() { OutputError::Io(io_error) => match io_error.kind() {
io::ErrorKind::NotFound => write!(f, "Backtick could not be run because just could not find the shell:\n{io_error}"), io::ErrorKind::NotFound => write!(f, "Backtick could not be run because just could not find the shell:\n{io_error}"),
io::ErrorKind::PermissionDenied => write!(f, "Backtick could not be run because just could not run the shell:\n{io_error}"), io::ErrorKind::PermissionDenied => write!(f, "Backtick could not be run because just could not run the shell:\n{io_error}"),
@ -340,6 +372,7 @@ impl ColorDisplay for Error<'_> {
OutputError::Code(code) => write!(f, "Cygpath failed with exit code {code} while translating recipe `{recipe}` shebang interpreter path")?, OutputError::Code(code) => write!(f, "Cygpath failed with exit code {code} while translating recipe `{recipe}` shebang interpreter path")?,
OutputError::Signal(signal) => write!(f, "Cygpath terminated by signal {signal} while translating recipe `{recipe}` shebang interpreter path")?, OutputError::Signal(signal) => write!(f, "Cygpath terminated by signal {signal} while translating recipe `{recipe}` shebang interpreter path")?,
OutputError::Unknown => write!(f, "Cygpath experienced an unknown failure while translating recipe `{recipe}` shebang interpreter path")?, OutputError::Unknown => write!(f, "Cygpath experienced an unknown failure while translating recipe `{recipe}` shebang interpreter path")?,
OutputError::Interrupted(signal) => write!(f, "Cygpath succeeded but `just` was interrupted by {signal}")?,
OutputError::Io(io_error) => { OutputError::Io(io_error) => {
match io_error.kind() { match io_error.kind() {
io::ErrorKind::NotFound => write!(f, "Could not find `cygpath` executable to translate recipe `{recipe}` shebang interpreter path:\n{io_error}"), io::ErrorKind::NotFound => write!(f, "Could not find `cygpath` executable to translate recipe `{recipe}` shebang interpreter path:\n{io_error}"),
@ -402,6 +435,9 @@ impl ColorDisplay for Error<'_> {
write!(f, "Internal runtime error, this may indicate a bug in just: {message} \ write!(f, "Internal runtime error, this may indicate a bug in just: {message} \
consider filing an issue: https://github.com/casey/just/issues/new")?; consider filing an issue: https://github.com/casey/just/issues/new")?;
} }
Interrupted { signal } => {
write!(f, "Interrupted by {signal}")?;
}
Io { recipe, io_error } => { Io { recipe, io_error } => {
match io_error.kind() { match io_error.kind() {
io::ErrorKind::NotFound => write!(f, "Recipe `{recipe}` could not be run because just could not find the shell: {io_error}"), io::ErrorKind::NotFound => write!(f, "Recipe `{recipe}` could not be run because just could not find the shell: {io_error}"),
@ -442,8 +478,24 @@ impl ColorDisplay for Error<'_> {
write!(f, "Recipe `{recipe}` was terminated by signal {signal}")?; write!(f, "Recipe `{recipe}` was terminated by signal {signal}")?;
} }
} }
#[cfg(windows)]
SignalHandlerInstall { source } => {
write!(f, "Could not install signal handler: {source}")?;
}
#[cfg(unix)]
SignalHandlerPipeOpen { io_error } => {
write!(f, "I/O error opening pipe for signal handler: {io_error}")?;
}
#[cfg(unix)]
SignalHandlerSigaction { io_error, signal } => {
write!(f, "I/O error setting sigaction for {signal}: {io_error}")?;
}
#[cfg(unix)]
SignalHandlerSpawnThread { io_error } => {
write!(f, "I/O error spawning thread for signal handler: {io_error}")?;
}
StdoutIo { io_error } => { StdoutIo { io_error } => {
write!(f, "I/O error writing to stdout: {io_error}?")?; write!(f, "I/O error writing to stdout: {io_error}")?;
} }
TempdirIo { recipe, io_error } => { TempdirIo { recipe, io_error } => {
write!(f, "Recipe `{recipe}` could not be run because of an IO error while trying to create a temporary \ write!(f, "Recipe `{recipe}` could not be run because of an IO error while trying to create a temporary \

View file

@ -273,22 +273,26 @@ impl<'src, 'run> Evaluator<'src, 'run> {
.module .module
.settings .settings
.shell_command(self.context.config); .shell_command(self.context.config);
cmd.arg(command);
cmd.args(args); cmd
cmd.current_dir(self.context.working_directory()); .arg(command)
cmd.export( .args(args)
&self.context.module.settings, .current_dir(self.context.working_directory())
self.context.dotenv, .export(
&self.scope, &self.context.module.settings,
&self.context.module.unexports, self.context.dotenv,
); &self.scope,
cmd.stdin(Stdio::inherit()); &self.context.module.unexports,
cmd.stderr(if self.context.config.verbosity.quiet() { )
Stdio::null() .stdin(Stdio::inherit())
} else { .stderr(if self.context.config.verbosity.quiet() {
Stdio::inherit() Stdio::null()
}); } else {
InterruptHandler::guard(|| output(cmd)) Stdio::inherit()
})
.stdout(Stdio::piped());
cmd.output_guard_stdout()
} }
pub(crate) fn evaluate_line( pub(crate) fn evaluate_line(

View file

@ -1,16 +0,0 @@
use super::*;
pub(crate) struct InterruptGuard;
impl InterruptGuard {
pub(crate) fn new() -> Self {
InterruptHandler::instance().block();
Self
}
}
impl Drop for InterruptGuard {
fn drop(&mut self) {
InterruptHandler::instance().unblock();
}
}

View file

@ -1,86 +0,0 @@
use super::*;
pub(crate) struct InterruptHandler {
blocks: u32,
interrupted: bool,
verbosity: Verbosity,
}
impl InterruptHandler {
pub(crate) fn install(verbosity: Verbosity) -> Result<(), ctrlc::Error> {
let mut instance = Self::instance();
instance.verbosity = verbosity;
ctrlc::set_handler(|| Self::instance().interrupt())
}
pub(crate) fn instance() -> MutexGuard<'static, Self> {
static INSTANCE: Mutex<InterruptHandler> = Mutex::new(InterruptHandler::new());
match INSTANCE.lock() {
Ok(guard) => guard,
Err(poison_error) => {
eprintln!(
"{}",
Error::Internal {
message: format!("interrupt handler mutex poisoned: {poison_error}"),
}
.color_display(Color::auto().stderr())
);
process::exit(EXIT_FAILURE);
}
}
}
const fn new() -> Self {
Self {
blocks: 0,
interrupted: false,
verbosity: Verbosity::default(),
}
}
fn interrupt(&mut self) {
self.interrupted = true;
if self.blocks > 0 {
return;
}
Self::exit();
}
fn exit() {
process::exit(130);
}
pub(crate) fn block(&mut self) {
self.blocks += 1;
}
pub(crate) fn unblock(&mut self) {
if self.blocks == 0 {
if self.verbosity.loud() {
eprintln!(
"{}",
Error::Internal {
message: "attempted to unblock interrupt handler, but handler was not blocked"
.to_owned(),
}
.color_display(Color::auto().stderr())
);
}
process::exit(EXIT_FAILURE);
}
self.blocks -= 1;
if self.interrupted {
Self::exit();
}
}
pub(crate) fn guard<T, F: FnOnce() -> T>(function: F) -> T {
let _guard = InterruptGuard::new();
function()
}
}

View file

@ -109,20 +109,20 @@ impl<'src> Justfile<'src> {
Command::new(binary) Command::new(binary)
}; };
command.args(arguments); command
.args(arguments)
command.current_dir(&search.working_directory); .current_dir(&search.working_directory);
let scope = scope.child(); let scope = scope.child();
command.export(&self.settings, &dotenv, &scope, &self.unexports); command.export(&self.settings, &dotenv, &scope, &self.unexports);
let status = InterruptHandler::guard(|| command.status()).map_err(|io_error| { let (result, caught) = command.status_guard();
Error::CommandInvoke {
binary: binary.clone(), let status = result.map_err(|io_error| Error::CommandInvoke {
arguments: arguments.clone(), binary: binary.clone(),
io_error, arguments: arguments.clone(),
} io_error,
})?; })?;
if !status.success() { if !status.success() {
@ -133,6 +133,10 @@ impl<'src> Justfile<'src> {
}); });
}; };
if let Some(signal) = caught {
return Err(Error::Interrupted { signal });
}
return Ok(()); return Ok(());
} }
Subcommand::Evaluate { variable, .. } => { Subcommand::Evaluate { variable, .. } => {

View file

@ -42,8 +42,6 @@ pub(crate) use {
fragment::Fragment, fragment::Fragment,
function::Function, function::Function,
interpreter::Interpreter, interpreter::Interpreter,
interrupt_guard::InterruptGuard,
interrupt_handler::InterruptHandler,
item::Item, item::Item,
justfile::Justfile, justfile::Justfile,
keyed::Keyed, keyed::Keyed,
@ -57,7 +55,6 @@ pub(crate) use {
name::Name, name::Name,
namepath::Namepath, namepath::Namepath,
ordinal::Ordinal, ordinal::Ordinal,
output::output,
output_error::OutputError, output_error::OutputError,
parameter::Parameter, parameter::Parameter,
parameter_kind::ParameterKind, parameter_kind::ParameterKind,
@ -80,6 +77,8 @@ pub(crate) use {
settings::Settings, settings::Settings,
shebang::Shebang, shebang::Shebang,
show_whitespace::ShowWhitespace, show_whitespace::ShowWhitespace,
signal::Signal,
signal_handler::SignalHandler,
source::Source, source::Source,
string_delimiter::StringDelimiter, string_delimiter::StringDelimiter,
string_kind::StringKind, string_kind::StringKind,
@ -221,8 +220,6 @@ mod expression;
mod fragment; mod fragment;
mod function; mod function;
mod interpreter; mod interpreter;
mod interrupt_guard;
mod interrupt_handler;
mod item; mod item;
mod justfile; mod justfile;
mod keyed; mod keyed;
@ -236,7 +233,6 @@ mod module_path;
mod name; mod name;
mod namepath; mod namepath;
mod ordinal; mod ordinal;
mod output;
mod output_error; mod output_error;
mod parameter; mod parameter;
mod parameter_kind; mod parameter_kind;
@ -260,6 +256,10 @@ mod setting;
mod settings; mod settings;
mod shebang; mod shebang;
mod show_whitespace; mod show_whitespace;
mod signal;
mod signal_handler;
#[cfg(unix)]
mod signals;
mod source; mod source;
mod string_delimiter; mod string_delimiter;
mod string_kind; mod string_kind;

View file

@ -1,34 +0,0 @@
use super::*;
/// Run a command and return the data it wrote to stdout as a string
pub(crate) fn output(mut command: Command) -> Result<String, OutputError> {
match command.output() {
Ok(output) => {
if let Some(code) = output.status.code() {
if code != 0 {
return Err(OutputError::Code(code));
}
} else {
let signal = Platform::signal_from_exit_status(output.status);
return Err(match signal {
Some(signal) => OutputError::Signal(signal),
None => OutputError::Unknown,
});
}
match str::from_utf8(&output.stdout) {
Err(error) => Err(OutputError::Utf8(error)),
Ok(output) => Ok(
if output.ends_with("\r\n") {
&output[0..output.len() - 2]
} else if output.ends_with('\n') {
&output[0..output.len() - 1]
} else {
output
}
.to_owned(),
),
}
}
Err(io_error) => Err(OutputError::Io(io_error)),
}
}

View file

@ -4,6 +4,8 @@ use super::*;
pub(crate) enum OutputError { pub(crate) enum OutputError {
/// Non-zero exit code /// Non-zero exit code
Code(i32), Code(i32),
/// Interrupted by signal
Interrupted(Signal),
/// IO error /// IO error
Io(io::Error), Io(io::Error),
/// Terminated by signal /// Terminated by signal
@ -14,10 +16,27 @@ pub(crate) enum OutputError {
Utf8(str::Utf8Error), Utf8(str::Utf8Error),
} }
impl OutputError {
pub(crate) fn result_from_exit_status(exit_status: ExitStatus) -> Result<(), OutputError> {
match exit_status.code() {
Some(0) => Ok(()),
Some(code) => Err(Self::Code(code)),
None => match Platform::signal_from_exit_status(exit_status) {
Some(signal) => Err(Self::Signal(signal)),
None => Err(Self::Unknown),
},
}
}
}
impl Display for OutputError { impl Display for OutputError {
fn fmt(&self, f: &mut Formatter) -> fmt::Result { fn fmt(&self, f: &mut Formatter) -> fmt::Result {
match *self { match *self {
Self::Code(code) => write!(f, "Process exited with status code {code}"), Self::Code(code) => write!(f, "Process exited with status code {code}"),
Self::Interrupted(signal) => write!(
f,
"Process succeded but `just` was interrupted by signal {signal}"
),
Self::Io(ref io_error) => write!(f, "Error executing process: {io_error}"), Self::Io(ref io_error) => write!(f, "Error executing process: {io_error}"),
Self::Signal(signal) => write!(f, "Process terminated by signal {signal}"), Self::Signal(signal) => write!(f, "Process terminated by signal {signal}"),
Self::Unknown => write!(f, "Process experienced an unknown failure"), Self::Unknown => write!(f, "Process experienced an unknown failure"),

View file

@ -3,113 +3,7 @@ use super::*;
pub(crate) struct Platform; pub(crate) struct Platform;
#[cfg(unix)] #[cfg(unix)]
impl PlatformInterface for Platform { mod unix;
fn make_shebang_command(
path: &Path,
working_directory: Option<&Path>,
_shebang: Shebang,
) -> Result<Command, OutputError> {
// shebang scripts can be executed directly on unix
let mut command = Command::new(path);
if let Some(working_directory) = working_directory {
command.current_dir(working_directory);
}
Ok(command)
}
fn set_execute_permission(path: &Path) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
// get current permissions
let mut permissions = fs::metadata(path)?.permissions();
// set the execute bit
let current_mode = permissions.mode();
permissions.set_mode(current_mode | 0o100);
// set the new permissions
fs::set_permissions(path, permissions)
}
fn signal_from_exit_status(exit_status: ExitStatus) -> Option<i32> {
use std::os::unix::process::ExitStatusExt;
exit_status.signal()
}
fn convert_native_path(_working_directory: &Path, path: &Path) -> FunctionResult {
path
.to_str()
.map(str::to_string)
.ok_or_else(|| String::from("Error getting current directory: unicode decode error"))
}
}
#[cfg(windows)] #[cfg(windows)]
impl PlatformInterface for Platform { mod windows;
fn make_shebang_command(
path: &Path,
working_directory: Option<&Path>,
shebang: Shebang,
) -> Result<Command, OutputError> {
use std::borrow::Cow;
// If the path contains forward slashes…
let command = if shebang.interpreter.contains('/') {
// …translate path to the interpreter from unix style to windows style.
let mut cygpath = Command::new("cygpath");
if let Some(working_directory) = working_directory {
cygpath.current_dir(working_directory);
}
cygpath.arg("--windows");
cygpath.arg(shebang.interpreter);
Cow::Owned(output(cygpath)?)
} else {
// …otherwise use it as-is.
Cow::Borrowed(shebang.interpreter)
};
let mut cmd = Command::new(command.as_ref());
if let Some(working_directory) = working_directory {
cmd.current_dir(working_directory);
}
if let Some(argument) = shebang.argument {
cmd.arg(argument);
}
cmd.arg(path);
Ok(cmd)
}
fn set_execute_permission(_path: &Path) -> io::Result<()> {
// it is not necessary to set an execute permission on a script on windows, so
// this is a nop
Ok(())
}
fn signal_from_exit_status(_exit_status: process::ExitStatus) -> Option<i32> {
// The rust standard library does not expose a way to extract a signal from a
// windows process exit status, so just return None
None
}
fn convert_native_path(working_directory: &Path, path: &Path) -> FunctionResult {
// Translate path from windows style to unix style
let mut cygpath = Command::new("cygpath");
cygpath.current_dir(working_directory);
cygpath.arg("--unix");
cygpath.arg(path);
match output(cygpath) {
Ok(shell_path) => Ok(shell_path),
Err(_) => path
.to_str()
.map(str::to_string)
.ok_or_else(|| String::from("Error getting current directory: unicode decode error")),
}
}
}

62
src/platform/unix.rs Normal file
View file

@ -0,0 +1,62 @@
use super::*;
impl PlatformInterface for Platform {
fn make_shebang_command(
path: &Path,
working_directory: Option<&Path>,
_shebang: Shebang,
) -> Result<Command, OutputError> {
// shebang scripts can be executed directly on unix
let mut command = Command::new(path);
if let Some(working_directory) = working_directory {
command.current_dir(working_directory);
}
Ok(command)
}
fn set_execute_permission(path: &Path) -> io::Result<()> {
use std::os::unix::fs::PermissionsExt;
// get current permissions
let mut permissions = fs::metadata(path)?.permissions();
// set the execute bit
let current_mode = permissions.mode();
permissions.set_mode(current_mode | 0o100);
// set the new permissions
fs::set_permissions(path, permissions)
}
fn signal_from_exit_status(exit_status: ExitStatus) -> Option<i32> {
use std::os::unix::process::ExitStatusExt;
exit_status.signal()
}
fn convert_native_path(_working_directory: &Path, path: &Path) -> FunctionResult {
path
.to_str()
.map(str::to_string)
.ok_or_else(|| String::from("Error getting current directory: unicode decode error"))
}
fn install_signal_handler<T: Fn(Signal) + Send + 'static>(handler: T) -> RunResult<'static> {
let signals = crate::signals::Signals::new()?;
std::thread::Builder::new()
.name("signal handler".into())
.spawn(move || {
for signal in signals {
match signal {
Ok(signal) => handler(signal),
Err(io_error) => eprintln!("warning: I/O error reading from signal pipe: {io_error}"),
}
}
})
.map_err(|io_error| Error::SignalHandlerSpawnThread { io_error })?;
Ok(())
}
}

84
src/platform/windows.rs Normal file
View file

@ -0,0 +1,84 @@
use super::*;
impl PlatformInterface for Platform {
fn make_shebang_command(
path: &Path,
working_directory: Option<&Path>,
shebang: Shebang,
) -> Result<Command, OutputError> {
use std::borrow::Cow;
// If the path contains forward slashes…
let command = if shebang.interpreter.contains('/') {
// …translate path to the interpreter from unix style to windows style.
let mut cygpath = Command::new("cygpath");
if let Some(working_directory) = working_directory {
cygpath.current_dir(working_directory);
}
cygpath
.arg("--windows")
.arg(shebang.interpreter)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
Cow::Owned(cygpath.output_guard_stdout()?)
} else {
// …otherwise use it as-is.
Cow::Borrowed(shebang.interpreter)
};
let mut cmd = Command::new(command.as_ref());
if let Some(working_directory) = working_directory {
cmd.current_dir(working_directory);
}
if let Some(argument) = shebang.argument {
cmd.arg(argument);
}
cmd.arg(path);
Ok(cmd)
}
fn set_execute_permission(_path: &Path) -> io::Result<()> {
// it is not necessary to set an execute permission on a script on windows, so
// this is a nop
Ok(())
}
fn signal_from_exit_status(_exit_status: process::ExitStatus) -> Option<i32> {
// The rust standard library does not expose a way to extract a signal from a
// windows process exit status, so just return None
None
}
fn convert_native_path(working_directory: &Path, path: &Path) -> FunctionResult {
// Translate path from windows style to unix style
let mut cygpath = Command::new("cygpath");
cygpath
.current_dir(working_directory)
.arg("--unix")
.arg(path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
match cygpath.output_guard_stdout() {
Ok(shell_path) => Ok(shell_path),
Err(_) => path
.to_str()
.map(str::to_string)
.ok_or_else(|| String::from("Error getting current directory: unicode decode error")),
}
}
fn install_signal_handler<T: Fn(Signal) + Send + 'static>(handler: T) -> RunResult<'static> {
ctrlc::set_handler(move || handler(Signal::Interrupt))
.map_err(|source| Error::SignalHandlerInstall { source })
}
}

View file

@ -1,21 +1,23 @@
use super::*; use super::*;
pub(crate) trait PlatformInterface { pub(crate) trait PlatformInterface {
/// Translate a path from a "native" path to a path the interpreter expects /// translate path from "native" path to path interpreter expects
fn convert_native_path(working_directory: &Path, path: &Path) -> FunctionResult; fn convert_native_path(working_directory: &Path, path: &Path) -> FunctionResult;
/// Construct a command equivalent to running the script at `path` with the /// install handler, may only be called once
/// shebang line `shebang` fn install_signal_handler<T: Fn(Signal) + Send + 'static>(handler: T) -> RunResult<'static>;
/// construct command equivalent to running script at `path` with shebang
/// line `shebang`
fn make_shebang_command( fn make_shebang_command(
path: &Path, path: &Path,
working_directory: Option<&Path>, working_directory: Option<&Path>,
shebang: Shebang, shebang: Shebang,
) -> Result<Command, OutputError>; ) -> Result<Command, OutputError>;
/// Set the execute permission on the file pointed to by `path` /// set the execute permission on file pointed to by `path`
fn set_execute_permission(path: &Path) -> io::Result<()>; fn set_execute_permission(path: &Path) -> io::Result<()>;
/// Extract the signal from a process exit status, if it was terminated by a /// extract signal from process exit status
/// signal
fn signal_from_exit_status(exit_status: ExitStatus) -> Option<i32>; fn signal_from_exit_status(exit_status: ExitStatus) -> Option<i32>;
} }

View file

@ -300,7 +300,9 @@ impl<'src, D> Recipe<'src, D> {
&context.module.unexports, &context.module.unexports,
); );
match InterruptHandler::guard(|| cmd.status()) { let (result, caught) = cmd.status_guard();
match result {
Ok(exit_status) => { Ok(exit_status) => {
if let Some(code) = exit_status.code() { if let Some(code) = exit_status.code() {
if code != 0 && !infallible_line { if code != 0 && !infallible_line {
@ -311,7 +313,7 @@ impl<'src, D> Recipe<'src, D> {
print_message: self.print_exit_message(&context.module.settings), print_message: self.print_exit_message(&context.module.settings),
}); });
} }
} else { } else if !infallible_line {
return Err(error_from_signal( return Err(error_from_signal(
self.name(), self.name(),
Some(line_number), Some(line_number),
@ -326,6 +328,12 @@ impl<'src, D> Recipe<'src, D> {
}); });
} }
}; };
if !infallible_line {
if let Some(signal) = caught {
return Err(Error::Interrupted { signal });
}
}
} }
} }
@ -442,7 +450,9 @@ impl<'src, D> Recipe<'src, D> {
); );
// run it! // run it!
match InterruptHandler::guard(|| command.status()) { let (result, caught) = command.status_guard();
match result {
Ok(exit_status) => exit_status.code().map_or_else( Ok(exit_status) => exit_status.code().map_or_else(
|| Err(error_from_signal(self.name(), None, exit_status)), || Err(error_from_signal(self.name(), None, exit_status)),
|code| { |code| {
@ -457,9 +467,15 @@ impl<'src, D> Recipe<'src, D> {
}) })
} }
}, },
), )?,
Err(io_error) => Err(executor.error(io_error, self.name())), Err(io_error) => return Err(executor.error(io_error, self.name())),
} }
if let Some(signal) = caught {
return Err(Error::Interrupted { signal });
}
Ok(())
} }
pub(crate) fn groups(&self) -> BTreeSet<String> { pub(crate) fn groups(&self) -> BTreeSet<String> {

View file

@ -4,10 +4,14 @@ use super::*;
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum Request { pub enum Request {
EnvironmentVariable(String), EnvironmentVariable(String),
#[cfg(not(windows))]
Signal,
} }
#[derive(Debug, Deserialize, PartialEq, Serialize)] #[derive(Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum Response { pub enum Response {
EnvironmentVariable(Option<OsString>), EnvironmentVariable(Option<OsString>),
#[cfg(not(windows))]
Signal(String),
} }

View file

@ -24,7 +24,7 @@ pub fn run(args: impl Iterator<Item = impl Into<OsString> + Clone>) -> Result<()
config config
.and_then(|config| { .and_then(|config| {
InterruptHandler::install(config.verbosity).ok(); SignalHandler::install(config.verbosity)?;
config.subcommand.execute(&config, &loader) config.subcommand.execute(&config, &loader)
}) })
.map_err(|error| { .map_err(|error| {

155
src/signal.rs Normal file
View file

@ -0,0 +1,155 @@
use super::*;
#[derive(Clone, Copy, Debug, PartialEq)]
#[repr(i32)]
pub(crate) enum Signal {
Hangup = 1,
#[cfg(any(
target_os = "dragonfly",
target_os = "freebsd",
target_os = "ios",
target_os = "macos",
target_os = "netbsd",
target_os = "openbsd",
))]
Info = 29,
Interrupt = 2,
Quit = 3,
Terminate = 15,
}
impl Signal {
#[cfg(not(windows))]
pub(crate) const ALL: &[Signal] = &[
Signal::Hangup,
#[cfg(any(
target_os = "dragonfly",
target_os = "freebsd",
target_os = "ios",
target_os = "macos",
target_os = "netbsd",
target_os = "openbsd",
))]
Signal::Info,
Signal::Interrupt,
Signal::Quit,
Signal::Terminate,
];
pub(crate) fn code(self) -> Option<i32> {
128i32.checked_add(self.number())
}
pub(crate) fn number(self) -> i32 {
self as libc::c_int
}
}
impl Display for Signal {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
write!(
f,
"{}",
match self {
Signal::Hangup => "SIGHUP",
#[cfg(any(
target_os = "dragonfly",
target_os = "freebsd",
target_os = "ios",
target_os = "macos",
target_os = "netbsd",
target_os = "openbsd",
))]
Signal::Info => "SIGINFO",
Signal::Interrupt => "SIGINT",
Signal::Quit => "SIGQUIT",
Signal::Terminate => "SIGTERM",
}
)
}
}
#[cfg(not(windows))]
impl From<Signal> for nix::sys::signal::Signal {
fn from(signal: Signal) -> Self {
match signal {
Signal::Hangup => Self::SIGHUP,
#[cfg(any(
target_os = "dragonfly",
target_os = "freebsd",
target_os = "ios",
target_os = "macos",
target_os = "netbsd",
target_os = "openbsd",
))]
Signal::Info => Self::SIGINFO,
Signal::Interrupt => Self::SIGINT,
Signal::Quit => Self::SIGQUIT,
Signal::Terminate => Self::SIGTERM,
}
}
}
impl TryFrom<u8> for Signal {
type Error = io::Error;
fn try_from(n: u8) -> Result<Signal, Self::Error> {
match n {
1 => Ok(Signal::Hangup),
#[cfg(any(
target_os = "dragonfly",
target_os = "freebsd",
target_os = "ios",
target_os = "macos",
target_os = "netbsd",
target_os = "openbsd",
))]
29 => Ok(Signal::Info),
2 => Ok(Signal::Interrupt),
3 => Ok(Signal::Quit),
15 => Ok(Signal::Terminate),
_ => Err(io::Error::new(
io::ErrorKind::Other,
format!("unexpected signal: {n}"),
)),
}
}
}
#[cfg(test)]
#[cfg(not(windows))]
mod tests {
use super::*;
#[test]
fn signals_fit_in_u8() {
for signal in Signal::ALL {
assert!(signal.number() <= i32::from(u8::MAX));
}
}
#[test]
fn signal_numbers_are_correct() {
for &signal in Signal::ALL {
let n = match signal {
Signal::Hangup => libc::SIGHUP,
#[cfg(any(
target_os = "dragonfly",
target_os = "freebsd",
target_os = "ios",
target_os = "macos",
target_os = "netbsd",
target_os = "openbsd",
))]
Signal::Info => libc::SIGINFO,
Signal::Interrupt => libc::SIGINT,
Signal::Quit => libc::SIGQUIT,
Signal::Terminate => libc::SIGTERM,
};
assert_eq!(signal as i32, n);
assert_eq!(Signal::try_from(u8::try_from(n).unwrap()).unwrap(), signal);
}
}
}

147
src/signal_handler.rs Normal file
View file

@ -0,0 +1,147 @@
use super::*;
pub(crate) struct SignalHandler {
caught: Option<Signal>,
children: BTreeMap<i32, Command>,
verbosity: Verbosity,
}
impl SignalHandler {
pub(crate) fn install(verbosity: Verbosity) -> RunResult<'static> {
let mut instance = Self::instance();
instance.verbosity = verbosity;
Platform::install_signal_handler(|signal| Self::instance().interrupt(signal))
}
pub(crate) fn instance() -> MutexGuard<'static, Self> {
static INSTANCE: Mutex<SignalHandler> = Mutex::new(SignalHandler::new());
match INSTANCE.lock() {
Ok(guard) => guard,
Err(poison_error) => {
eprintln!(
"{}",
Error::Internal {
message: format!("signal handler mutex poisoned: {poison_error}"),
}
.color_display(Color::auto().stderr())
);
process::exit(EXIT_FAILURE);
}
}
}
const fn new() -> Self {
Self {
caught: None,
children: BTreeMap::new(),
verbosity: Verbosity::default(),
}
}
fn interrupt(&mut self, signal: Signal) {
if self.children.is_empty() {
process::exit(signal.code().unwrap_or(1));
}
#[cfg(any(
target_os = "dragonfly",
target_os = "freebsd",
target_os = "ios",
target_os = "macos",
target_os = "netbsd",
target_os = "openbsd",
))]
if signal != Signal::Info && self.caught.is_none() {
self.caught = Some(signal);
}
match signal {
// SIGHUP, SIGINT, and SIGQUIT are normally sent on terminal close,
// ctrl-c, and ctrl-\, respectively, and are sent to all processes in the
// foreground process group. this includes child processes, so we ignore
// the signal and wait for them to exit
Signal::Hangup | Signal::Interrupt | Signal::Quit => {}
#[cfg(any(
target_os = "dragonfly",
target_os = "freebsd",
target_os = "ios",
target_os = "macos",
target_os = "netbsd",
target_os = "openbsd",
))]
Signal::Info => {
let id = process::id();
if self.children.is_empty() {
eprintln!("just {id}: no child processes");
} else {
let n = self.children.len();
let mut message = format!(
"just {id}: {n} child {}:\n",
if n == 1 { "process" } else { "processes" }
);
for (&child, command) in &self.children {
message.push_str(&format!("{child}: {command:?}\n"));
}
eprint!("{message}");
}
}
// SIGTERM is the default signal sent by kill. forward it to child
// processes and wait for them to exit
Signal::Terminate =>
{
#[cfg(not(windows))]
for &child in self.children.keys() {
if self.verbosity.loquacious() {
eprintln!("just: sending SIGTERM to child process {child}");
}
nix::sys::signal::kill(
nix::unistd::Pid::from_raw(child),
Some(Signal::Terminate.into()),
)
.ok();
}
}
}
}
pub(crate) fn spawn<T>(
mut command: Command,
f: impl Fn(process::Child) -> io::Result<T>,
) -> (io::Result<T>, Option<Signal>) {
let mut instance = Self::instance();
let child = match command.spawn() {
Err(err) => return (Err(err), None),
Ok(child) => child,
};
let pid = match child.id().try_into() {
Err(err) => {
return (
Err(io::Error::new(
io::ErrorKind::Other,
format!("invalid child PID: {err}"),
)),
None,
)
}
Ok(pid) => pid,
};
instance.children.insert(pid, command);
drop(instance);
let result = f(child);
let mut instance = Self::instance();
instance.children.remove(&pid);
(result, instance.caught)
}
}

122
src/signals.rs Normal file
View file

@ -0,0 +1,122 @@
use {
super::*,
nix::{
errno::Errno,
sys::signal::{SaFlags, SigAction, SigHandler, SigSet},
},
std::{
fs::File,
os::fd::{BorrowedFd, IntoRawFd},
sync::atomic::{self, AtomicI32},
},
};
const INVALID_FILENO: i32 = -1;
static WRITE: AtomicI32 = AtomicI32::new(INVALID_FILENO);
fn die(message: &str) -> ! {
// SAFETY:
//
// Standard error is open for the duration of the program.
const STDERR: BorrowedFd = unsafe { BorrowedFd::borrow_raw(libc::STDERR_FILENO) };
let mut i = 0;
let mut buffer = [0; 512];
let mut append = |s: &str| {
let remaining = buffer.len() - i;
let n = s.len().min(remaining);
let end = i + n;
buffer[i..end].copy_from_slice(&s.as_bytes()[0..n]);
i = end;
};
append("error: ");
append(message);
append("\n");
nix::unistd::write(STDERR, &buffer[0..i]).ok();
process::abort();
}
extern "C" fn handler(signal: libc::c_int) {
let errno = Errno::last();
let Ok(signal) = u8::try_from(signal) else {
die("unexpected signal");
};
// SAFETY:
//
// `WRITE` is initialized before the signal handler can run and remains open
// for the duration of the program.
let fd = unsafe { BorrowedFd::borrow_raw(WRITE.load(atomic::Ordering::Relaxed)) };
if let Err(err) = nix::unistd::write(fd, &[signal]) {
die(err.desc());
}
errno.set();
}
pub(crate) struct Signals(File);
impl Signals {
pub(crate) fn new() -> RunResult<'static, Self> {
let (read, write) = nix::unistd::pipe().map_err(|errno| Error::SignalHandlerPipeOpen {
io_error: errno.into(),
})?;
if WRITE
.compare_exchange(
INVALID_FILENO,
write.into_raw_fd(),
atomic::Ordering::Relaxed,
atomic::Ordering::Relaxed,
)
.is_err()
{
panic!("signal iterator cannot be initialized twice");
}
let sa = SigAction::new(
SigHandler::Handler(handler),
SaFlags::SA_RESTART,
SigSet::empty(),
);
for &signal in Signal::ALL {
// SAFETY:
//
// This is the only place we modify signal handlers, and
// `nix::sys::signal::sigaction` is unsafe only if an invalid signal
// handler has already been installed.
unsafe {
nix::sys::signal::sigaction(signal.into(), &sa).map_err(|errno| {
Error::SignalHandlerSigaction {
signal,
io_error: errno.into(),
}
})?;
}
}
Ok(Self(File::from(read)))
}
}
impl Iterator for Signals {
type Item = io::Result<Signal>;
fn next(&mut self) -> Option<Self::Item> {
let mut signal = [0];
Some(
self
.0
.read_exact(&mut signal)
.and_then(|()| Signal::try_from(signal[0])),
)
}
}

View file

@ -406,6 +406,16 @@ impl Subcommand {
fn request(request: &Request) -> RunResult<'static> { fn request(request: &Request) -> RunResult<'static> {
let response = match request { let response = match request {
Request::EnvironmentVariable(key) => Response::EnvironmentVariable(env::var_os(key)), Request::EnvironmentVariable(key) => Response::EnvironmentVariable(env::var_os(key)),
#[cfg(not(windows))]
Request::Signal => {
let sigset = nix::sys::signal::SigSet::all();
sigset.thread_block().unwrap();
let received = sigset.wait().unwrap();
Response::Signal(received.as_str().into())
}
}; };
serde_json::to_writer(io::stdout(), &response).map_err(|source| Error::DumpJson { source })?; serde_json::to_writer(io::stdout(), &response).map_err(|source| Error::DumpJson { source })?;

View file

@ -128,7 +128,7 @@ fn write_error() {
// skip this test if running as root, since root can write files even if // skip this test if running as root, since root can write files even if
// permissions would otherwise forbid it // permissions would otherwise forbid it
#[cfg(not(windows))] #[cfg(not(windows))]
if unsafe { libc::getuid() } == 0 { if nix::unistd::getuid() == nix::unistd::ROOT {
return; return;
} }

View file

@ -1,90 +0,0 @@
use {
super::*,
std::time::{Duration, Instant},
};
fn kill(process_id: u32) {
unsafe {
libc::kill(process_id.try_into().unwrap(), libc::SIGINT);
}
}
fn interrupt_test(arguments: &[&str], justfile: &str) {
let tmp = tempdir();
let mut justfile_path = tmp.path().to_path_buf();
justfile_path.push("justfile");
fs::write(justfile_path, unindent(justfile)).unwrap();
let start = Instant::now();
let mut child = Command::new(executable_path("just"))
.current_dir(&tmp)
.args(arguments)
.spawn()
.expect("just invocation failed");
while start.elapsed() < Duration::from_millis(500) {}
kill(child.id());
let status = child.wait().unwrap();
let elapsed = start.elapsed();
assert!(
elapsed <= Duration::from_secs(2),
"process returned too late: {elapsed:?}"
);
assert!(
elapsed >= Duration::from_millis(100),
"process returned too early : {elapsed:?}"
);
assert_eq!(status.code(), Some(130));
}
#[test]
#[ignore]
fn interrupt_shebang() {
interrupt_test(
&[],
"
default:
#!/usr/bin/env sh
sleep 1
",
);
}
#[test]
#[ignore]
fn interrupt_line() {
interrupt_test(
&[],
"
default:
@sleep 1
",
);
}
#[test]
#[ignore]
fn interrupt_backtick() {
interrupt_test(
&[],
"
foo := `sleep 1`
default:
@echo {{foo}}
",
);
}
#[test]
#[ignore]
fn interrupt_command() {
interrupt_test(&["--command", "sleep", "1"], "");
}

View file

@ -1,4 +1,4 @@
pub(crate) use { use {
crate::{ crate::{
assert_stdout::assert_stdout, assert_stdout::assert_stdout,
assert_success::assert_success, assert_success::assert_success,
@ -29,6 +29,12 @@ pub(crate) use {
which::which, which::which,
}; };
#[cfg(not(windows))]
use std::{
thread,
time::{Duration, Instant},
};
fn default<T: Default>() -> T { fn default<T: Default>() -> T {
Default::default() Default::default()
} }
@ -75,8 +81,6 @@ mod groups;
mod ignore_comments; mod ignore_comments;
mod imports; mod imports;
mod init; mod init;
#[cfg(unix)]
mod interrupts;
mod invocation_directory; mod invocation_directory;
mod json; mod json;
mod line_prefixes; mod line_prefixes;
@ -111,6 +115,8 @@ mod shebang;
mod shell; mod shell;
mod shell_expansion; mod shell_expansion;
mod show; mod show;
#[cfg(unix)]
mod signals;
mod slash_operator; mod slash_operator;
mod string; mod string;
mod subsequents; mod subsequents;

View file

@ -37,7 +37,12 @@ fn flag() {
assert_stdout(&output, stdout); assert_stdout(&output, stdout);
} }
const JUSTFILE_CMD: &str = r#" /// Test that we can use `set shell` to use cmd.exe on windows
#[test]
#[cfg(windows)]
fn cmd() {
let tmp = temptree! {
justfile: r#"
set shell := ["cmd.exe", "/C"] set shell := ["cmd.exe", "/C"]
@ -46,14 +51,7 @@ x := `Echo`
recipe: recipe:
REM foo REM foo
Echo "{{x}}" Echo "{{x}}"
"#; "#,
/// Test that we can use `set shell` to use cmd.exe on windows
#[test]
#[cfg_attr(unix, ignore)]
fn cmd() {
let tmp = temptree! {
justfile: JUSTFILE_CMD,
}; };
let output = Command::new(executable_path("just")) let output = Command::new(executable_path("just"))
@ -66,7 +64,12 @@ fn cmd() {
assert_stdout(&output, stdout); assert_stdout(&output, stdout);
} }
const JUSTFILE_POWERSHELL: &str = r#" /// Test that we can use `set shell` to use cmd.exe on windows
#[test]
#[cfg(windows)]
fn powershell() {
let tmp = temptree! {
justfile: r#"
set shell := ["powershell.exe", "-c"] set shell := ["powershell.exe", "-c"]
@ -75,15 +78,9 @@ x := `Write-Host "Hello, world!"`
recipe: recipe:
For ($i=0; $i -le 10; $i++) { Write-Host $i } For ($i=0; $i -le 10; $i++) { Write-Host $i }
Write-Host "{{x}}" Write-Host "{{x}}"
"#; "#
,
/// Test that we can use `set shell` to use cmd.exe on windows };
#[test]
#[cfg_attr(unix, ignore)]
fn powershell() {
let tmp = temptree! {
justfile: JUSTFILE_POWERSHELL,
};
let output = Command::new(executable_path("just")) let output = Command::new(executable_path("just"))
.current_dir(tmp.path()) .current_dir(tmp.path())

215
tests/signals.rs Normal file
View file

@ -0,0 +1,215 @@
use {super::*, nix::sys::signal::Signal, nix::unistd::Pid, std::process::Child};
fn kill(child: &Child, signal: Signal) {
nix::sys::signal::kill(Pid::from_raw(child.id().try_into().unwrap()), signal).unwrap();
}
fn interrupt_test(arguments: &[&str], justfile: &str) {
let tmp = tempdir();
let mut justfile_path = tmp.path().to_path_buf();
justfile_path.push("justfile");
fs::write(justfile_path, unindent(justfile)).unwrap();
let start = Instant::now();
let mut child = Command::new(executable_path("just"))
.current_dir(&tmp)
.args(arguments)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("just invocation failed");
while start.elapsed() < Duration::from_millis(500) {}
kill(&child, Signal::SIGINT);
let status = child.wait().unwrap();
let elapsed = start.elapsed();
assert!(
elapsed <= Duration::from_secs(2),
"process returned too late: {elapsed:?}"
);
assert!(
elapsed >= Duration::from_millis(100),
"process returned too early : {elapsed:?}"
);
assert_eq!(status.code(), Some(130));
}
#[test]
#[ignore]
fn interrupt_shebang() {
interrupt_test(
&[],
"
default:
#!/usr/bin/env sh
sleep 1
",
);
}
#[test]
#[ignore]
fn interrupt_line() {
interrupt_test(
&[],
"
default:
@sleep 1
",
);
}
#[test]
#[ignore]
fn interrupt_backtick() {
interrupt_test(
&[],
"
foo := `sleep 1`
default:
@echo {{foo}}
",
);
}
#[test]
#[ignore]
fn interrupt_command() {
interrupt_test(&["--command", "sleep", "1"], "");
}
/// This test is sensitive to the process signal mask. Programs like
/// `watchexec` and `cargo-watch` change the signal mask to ignore `SIGHUP`,
/// which causes this test to fail.
#[test]
#[ignore]
fn forwarding() {
let just = executable_path("just");
let tempdir = tempdir();
fs::write(
tempdir.path().join("justfile"),
"foo:\n @{{just_executable()}} --request '\"signal\"'",
)
.unwrap();
for signal in [Signal::SIGINT, Signal::SIGQUIT, Signal::SIGHUP] {
let mut child = Command::new(&just)
.current_dir(&tempdir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
// wait for child to start
thread::sleep(Duration::from_millis(500));
// send non-forwarded signal
kill(&child, signal);
// wait for child to receive signal
thread::sleep(Duration::from_millis(500));
// assert that child does not exit, because signal is not forwarded
assert!(child.try_wait().unwrap().is_none());
// send forwarded signal
kill(&child, Signal::SIGTERM);
// child exits
let output = child.wait_with_output().unwrap();
let status = output.status;
let stderr = str::from_utf8(&output.stderr).unwrap();
let stdout = str::from_utf8(&output.stdout).unwrap();
let mut failures = 0;
if status.code() != Some(128 + signal as i32) {
failures += 1;
eprintln!("unexpected status: {status}");
}
// just reports that it was interrupted by first, non-forwarded signal
if stderr != format!("error: Interrupted by {signal}\n") {
failures += 1;
eprintln!("unexpected stderr: {stderr}");
}
// child reports that it was terminated by forwarded signal
if stdout != r#"{"signal":"SIGTERM"}"# {
failures += 1;
eprintln!("unexpected stdout: {stdout}");
}
assert!(failures == 0, "{failures} failures");
}
}
/// This test is ignored because it includes a 500ms wait, and because signal
/// tests are often flakey.
#[test]
#[ignore]
#[cfg(any(
target_os = "dragonfly",
target_os = "freebsd",
target_os = "ios",
target_os = "macos",
target_os = "netbsd",
target_os = "openbsd",
))]
fn siginfo_prints_current_process() {
let just = executable_path("just");
let tempdir = tempdir();
fs::write(tempdir.path().join("justfile"), "foo:\n @sleep 1").unwrap();
let child = Command::new(&just)
.current_dir(&tempdir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
thread::sleep(Duration::from_millis(500));
kill(&child, Signal::SIGINFO);
let output = child.wait_with_output().unwrap();
let status = output.status;
let stderr = str::from_utf8(&output.stderr).unwrap();
let stdout = str::from_utf8(&output.stdout).unwrap();
let mut failures = 0;
if !status.success() {
failures += 1;
eprintln!("unexpected status: {status}");
}
let re =
Regex::new(r#"just \d+: 1 child process:\n\d+: cd ".*" && "sh" "-cu" "sleep 1"\n"#).unwrap();
if !re.is_match(stderr) {
failures += 1;
eprintln!("unexpected stderr: {stderr}");
}
if !stdout.is_empty() {
failures += 1;
eprintln!("unexpected stdout: {stdout}");
}
assert!(failures == 0, "{failures} failures");
}