Error on non-zero exit statuses (#13515)

# Description
This PR makes it so that non-zero exit codes and termination by signal
are treated as a normal `ShellError`. Currently, these are silent
errors. That is, if an external command fails, then it's code block is
aborted, but the parent block can sometimes continue execution. E.g.,
see #8569 and this example:
```nushell
[1 2] | each { ^false }
```

Before this would give:
```
╭───┬──╮
│ 0 │  │
│ 1 │  │
╰───┴──╯
```

Now, this shows an error:
```
Error: nu:🐚:eval_block_with_input

  × Eval block failed with pipeline input
   ╭─[entry #1:1:2]
 1 │ [1 2] | each { ^false }
   ·  ┬
   ·  ╰── source value
   ╰────

Error: nu:🐚:non_zero_exit_code

  × External command had a non-zero exit code
   ╭─[entry #1:1:17]
 1 │ [1 2] | each { ^false }
   ·                 ──┬──
   ·                   ╰── exited with code 1
   ╰────
```

This PR fixes #12874, fixes #5960, fixes #10856, and fixes #5347. This
PR also partially addresses #10633 and #10624 (only the last command of
a pipeline is currently checked). It looks like #8569 is already fixed,
but this PR will make sure it is definitely fixed (fixes #8569).

# User-Facing Changes
- Non-zero exit codes and termination by signal now cause an error to be
thrown.
- The error record value passed to a `catch` block may now have an
`exit_code` column containing the integer exit code if the error was due
to an external command.
- Adds new config values, `display_errors.exit_code` and
`display_errors.termination_signal`, which determine whether an error
message should be printed in the respective error cases. For
non-interactive sessions, these are set to `true`, and for interactive
sessions `display_errors.exit_code` is false (via the default config).

# Tests
Added a few tests.

# After Submitting
- Update docs and book.
- Future work:
- Error if other external commands besides the last in a pipeline exit
with a non-zero exit code. Then, deprecate `do -c` since this will be
the default behavior everywhere.
- Add a better mechanism for exit codes and deprecate
`$env.LAST_EXIT_CODE` (it's buggy).
This commit is contained in:
Ian Manske
2024-09-06 23:44:26 -07:00
committed by GitHub
parent 6c1c7f9509
commit 3d008e2c4e
54 changed files with 566 additions and 650 deletions

View File

@ -1,7 +1,7 @@
use nu_engine::{command_prelude::*, get_eval_block_with_early_return, redirect_env};
use nu_protocol::{
engine::Closure,
process::{ChildPipe, ChildProcess, ExitStatus},
process::{ChildPipe, ChildProcess},
ByteStream, ByteStreamSource, OutDest,
};
use std::{
@ -147,13 +147,7 @@ impl Command for Do {
None
};
if child.wait()? != ExitStatus::Exited(0) {
return Err(ShellError::ExternalCommand {
label: "External command failed".to_string(),
help: stderr_msg,
span,
});
}
child.wait()?;
let mut child = ChildProcess::from_raw(None, None, None, span);
if let Some(stdout) = stdout {

View File

@ -93,25 +93,10 @@ impl Command for For {
stack.add_var(var_id, x);
match eval_block(&engine_state, stack, block, PipelineData::empty()) {
Err(ShellError::Break { .. }) => {
break;
}
Err(ShellError::Continue { .. }) => {
continue;
}
Err(err) => {
return Err(err);
}
Ok(data) => {
if let Some(status) = data.drain()? {
let code = status.code();
if code != 0 {
return Ok(
PipelineData::new_external_stream_with_only_exit_code(code),
);
}
}
}
Err(ShellError::Break { .. }) => break,
Err(ShellError::Continue { .. }) => continue,
Err(err) => return Err(err),
Ok(data) => data.drain()?,
}
}
}
@ -121,25 +106,10 @@ impl Command for For {
stack.add_var(var_id, x);
match eval_block(&engine_state, stack, block, PipelineData::empty()) {
Err(ShellError::Break { .. }) => {
break;
}
Err(ShellError::Continue { .. }) => {
continue;
}
Err(err) => {
return Err(err);
}
Ok(data) => {
if let Some(status) = data.drain()? {
let code = status.code();
if code != 0 {
return Ok(
PipelineData::new_external_stream_with_only_exit_code(code),
);
}
}
}
Err(ShellError::Break { .. }) => break,
Err(ShellError::Continue { .. }) => continue,
Err(err) => return Err(err),
Ok(data) => data.drain()?,
}
}
}

View File

@ -127,10 +127,9 @@ impl Command for If {
eval_block(engine_state, stack, block, input)
} else {
eval_expression_with_input(engine_state, stack, else_expr, input)
.map(|res| res.0)
}
} else {
eval_expression_with_input(engine_state, stack, else_case, input).map(|res| res.0)
eval_expression_with_input(engine_state, stack, else_case, input)
}
} else {
Ok(PipelineData::empty())

View File

@ -56,23 +56,10 @@ impl Command for Loop {
engine_state.signals().check(head)?;
match eval_block(engine_state, stack, block, PipelineData::empty()) {
Err(ShellError::Break { .. }) => {
break;
}
Err(ShellError::Continue { .. }) => {
continue;
}
Err(err) => {
return Err(err);
}
Ok(data) => {
if let Some(status) = data.drain()? {
let code = status.code();
if code != 0 {
return Ok(PipelineData::new_external_stream_with_only_exit_code(code));
}
}
}
Err(ShellError::Break { .. }) => break,
Err(ShellError::Continue { .. }) => continue,
Err(err) => return Err(err),
Ok(data) => data.drain()?,
}
}
Ok(PipelineData::empty())

View File

@ -81,7 +81,7 @@ impl Command for Match {
let block = engine_state.get_block(block_id);
eval_block(engine_state, stack, block, input)
} else {
eval_expression_with_input(engine_state, stack, expr, input).map(|x| x.0)
eval_expression_with_input(engine_state, stack, expr, input)
};
}
} else {

View File

@ -47,6 +47,7 @@ impl Command for Try {
call: &Call,
input: PipelineData,
) -> Result<PipelineData, ShellError> {
let head = call.head;
// This is compiled specially by the IR compiler. The code here is never used when
// running in IR mode.
let call = call.assert_ast_call()?;
@ -61,30 +62,15 @@ impl Command for Try {
let try_block = engine_state.get_block(try_block);
let eval_block = get_eval_block(engine_state);
match eval_block(engine_state, stack, try_block, input) {
Err(error) => {
let error = intercept_block_control(error)?;
let err_record = err_to_record(error, call.head);
handle_catch(err_record, catch_block, engine_state, stack, eval_block)
}
let result = eval_block(engine_state, stack, try_block, input)
.and_then(|pipeline| pipeline.write_to_out_dests(engine_state, stack));
match result {
Err(err) => run_catch(err, head, catch_block, engine_state, stack, eval_block),
Ok(PipelineData::Value(Value::Error { error, .. }, ..)) => {
let error = intercept_block_control(*error)?;
let err_record = err_to_record(error, call.head);
handle_catch(err_record, catch_block, engine_state, stack, eval_block)
}
// external command may fail to run
Ok(pipeline) => {
let (pipeline, external_failed) = pipeline.check_external_failed()?;
if external_failed {
let status = pipeline.drain()?;
let code = status.map(|status| status.code()).unwrap_or(0);
stack.add_env_var("LAST_EXIT_CODE".into(), Value::int(code.into(), call.head));
let err_value = Value::nothing(call.head);
handle_catch(err_value, catch_block, engine_state, stack, eval_block)
} else {
Ok(pipeline)
}
run_catch(*error, head, catch_block, engine_state, stack, eval_block)
}
Ok(pipeline) => Ok(pipeline),
}
}
@ -109,28 +95,33 @@ impl Command for Try {
}
}
fn handle_catch(
err_value: Value,
catch_block: Option<Closure>,
fn run_catch(
error: ShellError,
span: Span,
catch: Option<Closure>,
engine_state: &EngineState,
stack: &mut Stack,
eval_block_fn: EvalBlockFn,
eval_block: EvalBlockFn,
) -> Result<PipelineData, ShellError> {
if let Some(catch_block) = catch_block {
let catch_block = engine_state.get_block(catch_block.block_id);
let error = intercept_block_control(error)?;
if let Some(catch) = catch {
stack.set_last_error(&error);
let error = error.into_value(span);
let block = engine_state.get_block(catch.block_id);
// Put the error value in the positional closure var
if let Some(var) = catch_block.signature.get_positional(0) {
if let Some(var) = block.signature.get_positional(0) {
if let Some(var_id) = &var.var_id {
stack.add_var(*var_id, err_value.clone());
stack.add_var(*var_id, error.clone());
}
}
eval_block_fn(
eval_block(
engine_state,
stack,
catch_block,
block,
// Make the error accessible with $in, too
err_value.into_pipeline_data(),
error.into_pipeline_data(),
)
} else {
Ok(PipelineData::empty())
@ -143,25 +134,13 @@ fn handle_catch(
/// `Err` when flow control to bubble up with `?`
fn intercept_block_control(error: ShellError) -> Result<ShellError, ShellError> {
match error {
nu_protocol::ShellError::Break { .. } => Err(error),
nu_protocol::ShellError::Continue { .. } => Err(error),
nu_protocol::ShellError::Return { .. } => Err(error),
ShellError::Break { .. } => Err(error),
ShellError::Continue { .. } => Err(error),
ShellError::Return { .. } => Err(error),
_ => Ok(error),
}
}
/// Convert from `error` to [`Value::Record`] so the error information can be easily accessed in catch.
fn err_to_record(error: ShellError, head: Span) -> Value {
Value::record(
record! {
"msg" => Value::string(error.to_string(), head),
"debug" => Value::string(format!("{error:?}"), head),
"raw" => Value::error(error, head),
},
head,
)
}
#[cfg(test)]
mod test {
use super::*;

View File

@ -73,27 +73,10 @@ impl Command for While {
let block = engine_state.get_block(block_id);
match eval_block(engine_state, stack, block, PipelineData::empty()) {
Err(ShellError::Break { .. }) => {
break;
}
Err(ShellError::Continue { .. }) => {
continue;
}
Err(err) => {
return Err(err);
}
Ok(data) => {
if let Some(status) = data.drain()? {
let code = status.code();
if code != 0 {
return Ok(
PipelineData::new_external_stream_with_only_exit_code(
code,
),
);
}
}
}
Err(ShellError::Break { .. }) => break,
Err(ShellError::Continue { .. }) => continue,
Err(err) => return Err(err),
Ok(data) => data.drain()?,
}
} else {
break;