Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[nextest-runner] improve error handling for child process management #1930

Merged
merged 1 commit into from
Dec 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 18 additions & 16 deletions nextest-runner/src/config/scripts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
use crate::{
double_spawn::{DoubleSpawnContext, DoubleSpawnInfo},
errors::{
ConfigCompileError, ConfigCompileErrorKind, ConfigCompileSection, InvalidConfigScriptName,
SetupScriptError,
ChildStartError, ConfigCompileError, ConfigCompileErrorKind, ConfigCompileSection,
InvalidConfigScriptName, SetupScriptOutputError,
},
list::TestList,
platform::BuildPlatforms,
Expand All @@ -28,6 +28,7 @@
collections::{BTreeMap, HashMap, HashSet},
fmt,
process::Command,
sync::Arc,
time::Duration,
};
use tokio::io::{AsyncBufReadExt, BufReader};
Expand Down Expand Up @@ -169,7 +170,7 @@
config: &ScriptConfig,
double_spawn: &DoubleSpawnInfo,
test_list: &TestList<'_>,
) -> Result<Self, SetupScriptError> {
) -> Result<Self, ChildStartError> {
let mut cmd = create_command(config.program().to_owned(), config.args(), double_spawn);

// NB: we will always override user-provided environment variables with the
Expand All @@ -179,7 +180,7 @@
let env_path = camino_tempfile::Builder::new()
.prefix("nextest-env")
.tempfile()
.map_err(SetupScriptError::TempPath)?
.map_err(|error| ChildStartError::TempPath(Arc::new(error)))?
.into_temp_path();

cmd.current_dir(test_list.workspace_root())
Expand Down Expand Up @@ -249,31 +250,32 @@
}

impl SetupScriptEnvMap {
pub(crate) async fn new(env_path: &Utf8Path) -> Result<Self, SetupScriptError> {
pub(crate) async fn new(env_path: &Utf8Path) -> Result<Self, SetupScriptOutputError> {
let mut env_map = BTreeMap::new();
let f = tokio::fs::File::open(env_path).await.map_err(|error| {
SetupScriptError::EnvFileOpen {
SetupScriptOutputError::EnvFileOpen {

Check warning on line 256 in nextest-runner/src/config/scripts.rs

View check run for this annotation

Codecov / codecov/patch

nextest-runner/src/config/scripts.rs#L256

Added line #L256 was not covered by tests
path: env_path.to_owned(),
error,
error: Arc::new(error),

Check warning on line 258 in nextest-runner/src/config/scripts.rs

View check run for this annotation

Codecov / codecov/patch

nextest-runner/src/config/scripts.rs#L258

Added line #L258 was not covered by tests
}
})?;
let reader = BufReader::new(f);
let mut lines = reader.lines();
loop {
let line = lines
.next_line()
.await
.map_err(|error| SetupScriptError::EnvFileRead {
path: env_path.to_owned(),
error,
})?;
let line =
lines
.next_line()
.await
.map_err(|error| SetupScriptOutputError::EnvFileRead {
path: env_path.to_owned(),
error: Arc::new(error),

Check warning on line 270 in nextest-runner/src/config/scripts.rs

View check run for this annotation

Codecov / codecov/patch

nextest-runner/src/config/scripts.rs#L269-L270

Added lines #L269 - L270 were not covered by tests
})?;
let Some(line) = line else { break };

// Split this line into key and value.
let (key, value) = match line.split_once('=') {
Some((key, value)) => (key, value),
None => {
return Err(SetupScriptError::EnvFileParse {
return Err(SetupScriptOutputError::EnvFileParse {

Check warning on line 278 in nextest-runner/src/config/scripts.rs

View check run for this annotation

Codecov / codecov/patch

nextest-runner/src/config/scripts.rs#L278

Added line #L278 was not covered by tests
path: env_path.to_owned(),
line: line.to_owned(),
})
Expand All @@ -282,7 +284,7 @@

// Ban keys starting with `NEXTEST`.
if key.starts_with("NEXTEST") {
return Err(SetupScriptError::EnvFileReservedKey {
return Err(SetupScriptOutputError::EnvFileReservedKey {

Check warning on line 287 in nextest-runner/src/config/scripts.rs

View check run for this annotation

Codecov / codecov/patch

nextest-runner/src/config/scripts.rs#L287

Added line #L287 was not covered by tests
key: key.to_owned(),
});
}
Expand Down
142 changes: 89 additions & 53 deletions nextest-runner/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,40 +274,21 @@
}
}

/// An execution error was returned while running a test.
///
/// Internal error type.
#[derive(Debug, Error)]
#[non_exhaustive]
pub(crate) enum RunTestError {
#[error("error spawning test process")]
Spawn(#[source] std::io::Error),

#[error("errors while managing test process")]
Child {
/// The errors that occurred; guaranteed to be non-empty.
errors: ErrorList<ChildError>,
},
}

/// An error that occurred while setting up or running a setup script.
#[derive(Debug, Error)]
pub(crate) enum SetupScriptError {
/// An error occurred while creating a temporary path for the setup script.
/// An execution error occurred while attempting to start a test.
#[derive(Clone, Debug, Error)]
pub enum ChildStartError {
/// An error occurred while creating a temporary path for a setup script.
#[error("error creating temporary path for setup script")]
TempPath(#[source] std::io::Error),

/// An error occurred while executing the setup script.
#[error("error executing setup script")]
ExecFail(#[source] std::io::Error),
TempPath(#[source] Arc<std::io::Error>),

/// One or more errors occurred while managing the child process.
#[error("errors while managing setup script process")]
Child {
/// The errors that occurred; guaranteed to be non-empty.
errors: ErrorList<ChildError>,
},
/// An error occurred while spawning the child process.
#[error("error spawning child process")]
Spawn(#[source] Arc<std::io::Error>),
}

/// An error that occurred while reading the output of a setup script.
#[derive(Clone, Debug, Error)]
pub enum SetupScriptOutputError {
/// An error occurred while opening the setup script environment file.
#[error("error opening environment file `{path}`")]
EnvFileOpen {
Expand All @@ -316,7 +297,7 @@

/// The underlying error.
#[source]
error: std::io::Error,
error: Arc<std::io::Error>,
},

/// An error occurred while reading the setup script environment file.
Expand All @@ -327,42 +308,78 @@

/// The underlying error.
#[source]
error: std::io::Error,
error: Arc<std::io::Error>,
},

/// An error occurred while parsing the setup script environment file.
#[error("line `{line}` in environment file `{path}` not in KEY=VALUE format")]
EnvFileParse { path: Utf8PathBuf, line: String },
EnvFileParse {
/// The path to the environment file.
path: Utf8PathBuf,
/// The line at issue.
line: String,
},

/// An environment variable key was reserved.
#[error("key `{key}` begins with `NEXTEST`, which is reserved for internal use")]
EnvFileReservedKey { key: String },
EnvFileReservedKey {
/// The environment variable name.
key: String,
},
}

/// A list of errors that implements `Error`.
///
/// In the future, we'll likely want to replace this with a `miette::Diagnostic`-based error, since
/// that supports multiple causes via "related".
#[derive(Clone, Debug)]
pub struct ErrorList<T>(pub Vec<T>);
pub struct ErrorList<T> {
// A description of what the errors are.
description: &'static str,
// Invariant: this list is non-empty.
inner: Vec<T>,
}

impl<T: std::error::Error> ErrorList<T> {
pub(crate) fn new<U>(description: &'static str, errors: Vec<U>) -> Option<Self>
where
T: From<U>,
{
if errors.is_empty() {
None
} else {
Some(Self {
description,
inner: errors.into_iter().map(T::from).collect(),
})
}
}

impl<T> ErrorList<T> {
/// Returns true if the error list is empty.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
/// Returns a 1 line summary of the error list.
pub(crate) fn as_one_line_summary(&self) -> String {
if self.inner.len() == 1 {
format!("{}", self.inner[0])

Check warning on line 361 in nextest-runner/src/errors.rs

View check run for this annotation

Codecov / codecov/patch

nextest-runner/src/errors.rs#L359-L361

Added lines #L359 - L361 were not covered by tests
} else {
format!("{} errors occurred {}", self.inner.len(), self.description)

Check warning on line 363 in nextest-runner/src/errors.rs

View check run for this annotation

Codecov / codecov/patch

nextest-runner/src/errors.rs#L363

Added line #L363 was not covered by tests
}
}
}

impl<T: std::error::Error> fmt::Display for ErrorList<T> {
fn fmt(&self, mut f: &mut fmt::Formatter) -> fmt::Result {
// If a single error occurred, pretend that this is just that.
if self.0.len() == 1 {
return write!(f, "{}", self.0[0]);
if self.inner.len() == 1 {
return write!(f, "{}", self.inner[0]);
}

// Otherwise, list all errors.
writeln!(f, "{} errors occurred:", self.0.len())?;
for error in &self.0 {
writeln!(
f,
"{} errors occurred {}:",
self.inner.len(),
self.description,
)?;
for error in &self.inner {
let mut indent = IndentWriter::new_skip_initial(" ", f);
writeln!(indent, "* {}", DisplayErrorChain(error))?;
f = indent.into_inner();
Expand All @@ -373,8 +390,8 @@

impl<T: std::error::Error> std::error::Error for ErrorList<T> {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
if self.0.len() == 1 {
self.0[0].source()
if self.inner.len() == 1 {
self.inner[0].source()
} else {
// More than one error occurred, so we can't return a single error here. Instead, we
// return `None` and display the chain of causes in `fmt::Display`.
Expand Down Expand Up @@ -421,10 +438,21 @@
}
}

/// An error was returned during the process of managing a child process.
/// An error was returned while managing a child process or reading its output.
#[derive(Clone, Debug, Error)]
#[non_exhaustive]
pub enum ChildError {
/// An error occurred while reading from a child file descriptor.
#[error(transparent)]
Fd(#[from] ChildFdError),

/// An error occurred while reading the output of a setup script.
#[error(transparent)]
SetupScriptOutput(#[from] SetupScriptOutputError),
}

/// An error was returned while reading from child a file descriptor.
#[derive(Clone, Debug, Error)]
pub enum ChildFdError {
/// An error occurred while reading standard output.
#[error("error reading standard output")]
ReadStdout(#[source] Arc<std::io::Error>),
Expand Down Expand Up @@ -1822,14 +1850,18 @@
fn display_error_list() {
let err1 = StringError::new("err1", None);

let error_list = ErrorList(vec![err1.clone()]);
let error_list =
ErrorList::<StringError>::new("waiting on the water to boil", vec![err1.clone()])
.expect(">= 1 error");
insta::assert_snapshot!(format!("{}", error_list), @"err1");
insta::assert_snapshot!(format!("{}", DisplayErrorChain::new(&error_list)), @"err1");

let err2 = StringError::new("err2", Some(err1));
let err3 = StringError::new("err3", Some(err2));

let error_list = ErrorList(vec![err3.clone()]);
let error_list =
ErrorList::<StringError>::new("waiting on flowers to bloom", vec![err3.clone()])
.expect(">= 1 error");
insta::assert_snapshot!(format!("{}", error_list), @"err3");
insta::assert_snapshot!(format!("{}", DisplayErrorChain::new(&error_list)), @r"
err3
Expand All @@ -1842,10 +1874,14 @@
let err5 = StringError::new("err5", Some(err4));
let err6 = StringError::new("err6\nerr6 line 2", Some(err5));

let error_list = ErrorList(vec![err3, err6]);
let error_list = ErrorList::<StringError>::new(
"waiting for the heat death of the universe",
vec![err3, err6],
)
.expect(">= 1 error");

insta::assert_snapshot!(format!("{}", error_list), @r"
2 errors occurred:
2 errors occurred waiting for the heat death of the universe:
* err3
caused by:
- err2
Expand All @@ -1857,7 +1893,7 @@
- err4
");
insta::assert_snapshot!(format!("{}", DisplayErrorChain::new(&error_list)), @r"
2 errors occurred:
2 errors occurred waiting for the heat death of the universe:
* err3
caused by:
- err2
Expand Down
24 changes: 13 additions & 11 deletions nextest-runner/src/reporter/aggregator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
use super::TestEvent;
use crate::{
config::{EvaluatableProfile, NextestJunitConfig},
errors::WriteEventError,
errors::{DisplayErrorChain, WriteEventError},
list::TestInstance,
reporter::TestEventKind,
runner::{ExecuteStatus, ExecutionDescription, ExecutionResult},
test_output::{TestExecutionOutput, TestOutput},
test_output::{ChildExecutionResult, ChildOutput},
};
use camino::Utf8PathBuf;
use debug_ignore::DebugIgnore;
Expand Down Expand Up @@ -303,8 +303,13 @@
mut out: TestcaseOrRerun<'_>,
) {
match &execute_status.output {
TestExecutionOutput::Output(output) => {
ChildExecutionResult::Output { output, errors } => {

Check warning on line 306 in nextest-runner/src/reporter/aggregator.rs

View check run for this annotation

Codecov / codecov/patch

nextest-runner/src/reporter/aggregator.rs#L306

Added line #L306 was not covered by tests
if !is_success {
if let Some(errors) = errors {
// Use the child errors as the message and description.
out.set_message(errors.as_one_line_summary());
out.set_description(DisplayErrorChain::new(errors).to_string());
};

Check warning on line 312 in nextest-runner/src/reporter/aggregator.rs

View check run for this annotation

Codecov / codecov/patch

nextest-runner/src/reporter/aggregator.rs#L308-L312

Added lines #L308 - L312 were not covered by tests
let description = output.heuristic_extract_description(execute_status.result);
if let Some(description) = description {
out.set_description(description.display_human().to_string());
Expand All @@ -313,27 +318,24 @@

if store_stdout_stderr {
match output {
TestOutput::Split(split) => {
ChildOutput::Split(split) => {

Check warning on line 321 in nextest-runner/src/reporter/aggregator.rs

View check run for this annotation

Codecov / codecov/patch

nextest-runner/src/reporter/aggregator.rs#L321

Added line #L321 was not covered by tests
if let Some(stdout) = &split.stdout {
out.set_system_out(stdout.as_str_lossy());
}
if let Some(stderr) = &split.stderr {
out.set_system_err(stderr.as_str_lossy());
}
}
TestOutput::Combined { output } => {
ChildOutput::Combined { output } => {

Check warning on line 329 in nextest-runner/src/reporter/aggregator.rs

View check run for this annotation

Codecov / codecov/patch

nextest-runner/src/reporter/aggregator.rs#L329

Added line #L329 was not covered by tests
out.set_system_out(output.as_str_lossy())
.set_system_err("(stdout and stderr are combined)");
}
}
}
}
TestExecutionOutput::ExecFail {
message,
description,
} => {
out.set_message(format!("Test execution failed: {message}"));
out.set_description(description);
ChildExecutionResult::StartError(error) => {
out.set_message(format!("Test execution failed: {error}"));
out.set_description(DisplayErrorChain::new(error).to_string());

Check warning on line 338 in nextest-runner/src/reporter/aggregator.rs

View check run for this annotation

Codecov / codecov/patch

nextest-runner/src/reporter/aggregator.rs#L336-L338

Added lines #L336 - L338 were not covered by tests
}
}
}
Loading
Loading