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

@ -278,7 +278,6 @@ impl BlockBuilder {
Instruction::OnError { index: _ } => Ok(()),
Instruction::OnErrorInto { index: _, dst } => allocate(&[], &[*dst]),
Instruction::PopErrorHandler => Ok(()),
Instruction::CheckExternalFailed { dst, src } => allocate(&[*src], &[*dst, *src]),
Instruction::ReturnEarly { src } => allocate(&[*src], &[]),
Instruction::Return { src } => allocate(&[*src], &[]),
};
@ -499,6 +498,7 @@ impl BlockBuilder {
}
/// Mark an unreachable code path. Produces an error at runtime if executed.
#[allow(dead_code)] // currently unused, but might be used in the future.
pub(crate) fn unreachable(&mut self, span: Span) -> Result<(), CompileError> {
self.push(Instruction::Unreachable.into_spanned(span))
}

View File

@ -362,12 +362,8 @@ pub(crate) fn compile_try(
//
// on-error-into ERR, %io_reg // or without
// %io_reg <- <...block...> <- %io_reg
// check-external-failed %failed_reg, %io_reg
// branch-if %failed_reg, FAIL
// pop-error-handler
// jump END
// FAIL: drain %io_reg
// unreachable
// ERR: clone %err_reg, %io_reg
// store-variable $err_var, %err_reg // or without
// %io_reg <- <...catch block...> <- %io_reg // set to empty if no catch block
@ -378,12 +374,8 @@ pub(crate) fn compile_try(
// %closure_reg <- <catch_expr>
// on-error-into ERR, %io_reg
// %io_reg <- <...block...> <- %io_reg
// check-external-failed %failed_reg, %io_reg
// branch-if %failed_reg, FAIL
// pop-error-handler
// jump END
// FAIL: drain %io_reg
// unreachable
// ERR: clone %err_reg, %io_reg
// push-positional %closure_reg
// push-positional %err_reg
@ -405,7 +397,6 @@ pub(crate) fn compile_try(
let catch_span = catch_expr.map(|e| e.span).unwrap_or(call.head);
let err_label = builder.label(None);
let failed_label = builder.label(None);
let end_label = builder.label(None);
// We have two ways of executing `catch`: if it was provided as a literal, we can inline it.
@ -470,32 +461,13 @@ pub(crate) fn compile_try(
io_reg,
)?;
// Check for external command exit code failure, and also redirect that to the catch handler
let failed_reg = builder.next_register()?;
builder.push(
Instruction::CheckExternalFailed {
dst: failed_reg,
src: io_reg,
}
.into_spanned(catch_span),
)?;
builder.branch_if(failed_reg, failed_label, catch_span)?;
// Successful case: pop the error handler
builder.push(Instruction::PopErrorHandler.into_spanned(call.head))?;
// Jump over the failure case
builder.jump(end_label, catch_span)?;
// Set up an error handler preamble for failed external.
// Draining the %io_reg results in the error handler being called with Empty, and sets
// $env.LAST_EXIT_CODE
builder.set_label(failed_label, builder.here())?;
builder.drain(io_reg, catch_span)?;
builder.add_comment("branches to err");
builder.unreachable(catch_span)?;
// This is the real error handler
// This is the error handler
builder.set_label(err_label, builder.here())?;
// Mark out register as likely not clean - state in error handler is not well defined

View File

@ -250,7 +250,7 @@ pub fn eval_expression_with_input<D: DebugContext>(
stack: &mut Stack,
expr: &Expression,
mut input: PipelineData,
) -> Result<(PipelineData, bool), ShellError> {
) -> Result<PipelineData, ShellError> {
match &expr.expr {
Expr::Call(call) => {
input = eval_call::<D>(engine_state, stack, call, input)?;
@ -298,16 +298,7 @@ pub fn eval_expression_with_input<D: DebugContext>(
}
};
// If input an external command,
// then `might_consume_external_result` will consume `stderr` if `stdout` is `None`.
// This should not happen if the user wants to capture stderr.
if !matches!(stack.stdout(), OutDest::Pipe | OutDest::Capture)
&& matches!(stack.stderr(), OutDest::Capture)
{
Ok((input, false))
} else {
input.check_external_failed()
}
Ok(input)
}
fn eval_redirection<D: DebugContext>(
@ -401,9 +392,8 @@ fn eval_element_with_input_inner<D: DebugContext>(
stack: &mut Stack,
element: &PipelineElement,
input: PipelineData,
) -> Result<(PipelineData, bool), ShellError> {
let (data, failed) =
eval_expression_with_input::<D>(engine_state, stack, &element.expr, input)?;
) -> Result<PipelineData, ShellError> {
let data = eval_expression_with_input::<D>(engine_state, stack, &element.expr, input)?;
if let Some(redirection) = element.redirection.as_ref() {
let is_external = if let PipelineData::ByteStream(stream, ..) = &data {
@ -473,7 +463,7 @@ fn eval_element_with_input_inner<D: DebugContext>(
PipelineData::Empty => PipelineData::Empty,
};
Ok((data, failed))
Ok(data)
}
fn eval_element_with_input<D: DebugContext>(
@ -481,20 +471,11 @@ fn eval_element_with_input<D: DebugContext>(
stack: &mut Stack,
element: &PipelineElement,
input: PipelineData,
) -> Result<(PipelineData, bool), ShellError> {
) -> Result<PipelineData, ShellError> {
D::enter_element(engine_state, element);
match eval_element_with_input_inner::<D>(engine_state, stack, element, input) {
Ok((data, failed)) => {
let res = Ok(data);
D::leave_element(engine_state, element, &res);
res.map(|data| (data, failed))
}
Err(err) => {
let res = Err(err);
D::leave_element(engine_state, element, &res);
res.map(|data| (data, false))
}
}
let result = eval_element_with_input_inner::<D>(engine_state, stack, element, input);
D::leave_element(engine_state, element, &result);
result
}
pub fn eval_block_with_early_return<D: DebugContext>(
@ -509,7 +490,7 @@ pub fn eval_block_with_early_return<D: DebugContext>(
}
}
pub fn eval_block<D: DebugContext>(
fn eval_block_inner<D: DebugContext>(
engine_state: &EngineState,
stack: &mut Stack,
block: &Block,
@ -520,8 +501,6 @@ pub fn eval_block<D: DebugContext>(
return eval_ir_block::<D>(engine_state, stack, block, input);
}
D::enter_block(engine_state, block);
let num_pipelines = block.len();
for (pipeline_idx, pipeline) in block.pipelines.iter().enumerate() {
@ -542,14 +521,7 @@ pub fn eval_block<D: DebugContext>(
(next_out.or(Some(OutDest::Pipe)), next_err),
)?;
let stack = &mut stack.push_redirection(stdout, stderr);
let (output, failed) =
eval_element_with_input::<D>(engine_state, stack, element, input)?;
if failed {
// External command failed.
// Don't return `Err(ShellError)`, so nushell won't show an extra error message.
return Ok(output);
}
input = output;
input = eval_element_with_input::<D>(engine_state, stack, element, input)?;
}
if last_pipeline {
@ -560,13 +532,7 @@ pub fn eval_block<D: DebugContext>(
(stack.pipe_stdout().cloned(), stack.pipe_stderr().cloned()),
)?;
let stack = &mut stack.push_redirection(stdout, stderr);
let (output, failed) = eval_element_with_input::<D>(engine_state, stack, last, input)?;
if failed {
// External command failed.
// Don't return `Err(ShellError)`, so nushell won't show an extra error message.
return Ok(output);
}
input = output;
input = eval_element_with_input::<D>(engine_state, stack, last, input)?;
} else {
let (stdout, stderr) = eval_element_redirection::<D>(
engine_state,
@ -575,40 +541,41 @@ pub fn eval_block<D: DebugContext>(
(None, None),
)?;
let stack = &mut stack.push_redirection(stdout, stderr);
let (output, failed) = eval_element_with_input::<D>(engine_state, stack, last, input)?;
if failed {
// External command failed.
// Don't return `Err(ShellError)`, so nushell won't show an extra error message.
return Ok(output);
}
input = PipelineData::Empty;
match output {
match eval_element_with_input::<D>(engine_state, stack, last, input)? {
PipelineData::ByteStream(stream, ..) => {
let span = stream.span();
let status = stream.drain()?;
if let Some(status) = status {
stack.add_env_var(
"LAST_EXIT_CODE".into(),
Value::int(status.code().into(), span),
);
if status.code() != 0 {
break;
}
if let Err(err) = stream.drain() {
stack.set_last_error(&err);
return Err(err);
} else {
stack.set_last_exit_code(0, span);
}
}
PipelineData::ListStream(stream, ..) => {
stream.drain()?;
}
PipelineData::ListStream(stream, ..) => stream.drain()?,
PipelineData::Value(..) | PipelineData::Empty => {}
}
input = PipelineData::Empty;
}
}
D::leave_block(engine_state, block);
Ok(input)
}
pub fn eval_block<D: DebugContext>(
engine_state: &EngineState,
stack: &mut Stack,
block: &Block,
input: PipelineData,
) -> Result<PipelineData, ShellError> {
D::enter_block(engine_state, block);
let result = eval_block_inner::<D>(engine_state, stack, block, input);
D::leave_block(engine_state, block);
if let Err(err) = &result {
stack.set_last_error(err);
}
result
}
pub fn eval_collect<D: DebugContext>(
engine_state: &EngineState,
stack: &mut Stack,
@ -639,8 +606,7 @@ pub fn eval_collect<D: DebugContext>(
expr,
// We still have to pass it as input
input.into_pipeline_data_with_metadata(metadata),
)
.map(|(result, _failed)| result);
);
stack.remove_var(var_id);

View File

@ -25,12 +25,8 @@ pub type EvalBlockWithEarlyReturnFn =
pub type EvalExpressionFn = fn(&EngineState, &mut Stack, &Expression) -> Result<Value, ShellError>;
/// Type of eval_expression_with_input() function
pub type EvalExpressionWithInputFn = fn(
&EngineState,
&mut Stack,
&Expression,
PipelineData,
) -> Result<(PipelineData, bool), ShellError>;
pub type EvalExpressionWithInputFn =
fn(&EngineState, &mut Stack, &Expression, PipelineData) -> Result<PipelineData, ShellError>;
/// Type of eval_subexpression() function
pub type EvalSubexpressionFn =

View File

@ -6,9 +6,9 @@ use nu_protocol::{
debugger::DebugContext,
engine::{Argument, Closure, EngineState, ErrorHandler, Matcher, Redirection, Stack},
ir::{Call, DataSlice, Instruction, IrAstRef, IrBlock, Literal, RedirectMode},
record, ByteStreamSource, DataSource, DeclId, ErrSpan, Flag, IntoPipelineData, IntoSpanned,
ListStream, OutDest, PipelineData, PipelineMetadata, PositionalArg, Range, Record, RegId,
ShellError, Signals, Signature, Span, Spanned, Type, Value, VarId, ENV_VARIABLE_ID,
ByteStreamSource, DataSource, DeclId, ErrSpan, Flag, IntoPipelineData, IntoSpanned, ListStream,
OutDest, PipelineData, PipelineMetadata, PositionalArg, Range, Record, RegId, ShellError,
Signals, Signature, Span, Spanned, Type, Value, VarId, ENV_VARIABLE_ID,
};
use nu_utils::IgnoreCaseExt;
@ -207,18 +207,6 @@ fn eval_ir_block_impl<D: DebugContext>(
Ok(InstructionResult::Return(reg_id)) => {
return Ok(ctx.take_reg(reg_id));
}
Ok(InstructionResult::ExitCode(exit_code)) => {
if let Some(error_handler) = ctx.stack.error_handlers.pop(ctx.error_handler_base) {
// If an error handler is set, branch there
prepare_error_handler(ctx, error_handler, None);
pc = error_handler.handler_index;
} else {
// If not, exit the block with the exit code
return Ok(PipelineData::new_external_stream_with_only_exit_code(
exit_code,
));
}
}
Err(
err @ (ShellError::Return { .. }
| ShellError::Continue { .. }
@ -259,15 +247,10 @@ fn prepare_error_handler(
if let Some(reg_id) = error_handler.error_register {
if let Some(error) = error {
// Create the error value and put it in the register
let value = Value::record(
record! {
"msg" => Value::string(format!("{}", error.item), error.span),
"debug" => Value::string(format!("{:?}", error.item), error.span),
"raw" => Value::error(error.item, error.span),
},
error.span,
ctx.put_reg(
reg_id,
error.item.into_value(error.span).into_pipeline_data(),
);
ctx.put_reg(reg_id, PipelineData::Value(value, None));
} else {
// Set the register to empty
ctx.put_reg(reg_id, PipelineData::Empty);
@ -281,7 +264,6 @@ enum InstructionResult {
Continue,
Branch(usize),
Return(RegId),
ExitCode(i32),
}
/// Perform an instruction
@ -788,13 +770,6 @@ fn eval_instruction<D: DebugContext>(
ctx.stack.error_handlers.pop(ctx.error_handler_base);
Ok(Continue)
}
Instruction::CheckExternalFailed { dst, src } => {
let data = ctx.take_reg(*src);
let (data, failed) = data.check_external_failed()?;
ctx.put_reg(*src, data);
ctx.put_reg(*dst, Value::bool(failed, *span).into_pipeline_data());
Ok(Continue)
}
Instruction::ReturnEarly { src } => {
let val = ctx.collect_reg(*src, *span)?;
Err(ShellError::Return {
@ -1362,23 +1337,23 @@ fn collect(data: PipelineData, fallback_span: Span) -> Result<PipelineData, Shel
Ok(PipelineData::Value(value, metadata))
}
/// Helper for drain behavior. Returns `Ok(ExitCode)` on failed external.
/// Helper for drain behavior.
fn drain(ctx: &mut EvalContext<'_>, data: PipelineData) -> Result<InstructionResult, ShellError> {
use self::InstructionResult::*;
let span = data.span().unwrap_or(Span::unknown());
if let Some(exit_status) = data.drain()? {
ctx.stack.add_env_var(
"LAST_EXIT_CODE".into(),
Value::int(exit_status.code() as i64, span),
);
if exit_status.code() == 0 {
Ok(Continue)
} else {
Ok(ExitCode(exit_status.code()))
match data {
PipelineData::ByteStream(stream, ..) => {
let span = stream.span();
if let Err(err) = stream.drain() {
ctx.stack.set_last_error(&err);
return Err(err);
} else {
ctx.stack.set_last_exit_code(0, span);
}
}
} else {
Ok(Continue)
PipelineData::ListStream(stream, ..) => stream.drain()?,
PipelineData::Value(..) | PipelineData::Empty => {}
}
Ok(Continue)
}
enum RedirectionStream {