Skip to content

Commit e54a132

Browse files
committed
[nextest-runner] improve error handling for child process management
* Don't eat up stdout/stderr if there's an error that occurs after the process is spawned * Scripts can now combine stdout and stderr (though this isn't exposed in the UI yet). * Fix up names to reflect that "child" can mean either a test process or a script process. This is going to need a bunch of TLC, but I think this is generally a good shape for the types to be in.
1 parent c7c8de1 commit e54a132

File tree

12 files changed

+375
-310
lines changed

12 files changed

+375
-310
lines changed

nextest-runner/src/config/scripts.rs

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ use super::{
1010
use crate::{
1111
double_spawn::{DoubleSpawnContext, DoubleSpawnInfo},
1212
errors::{
13-
ConfigCompileError, ConfigCompileErrorKind, ConfigCompileSection, InvalidConfigScriptName,
14-
SetupScriptError,
13+
ChildStartError, ConfigCompileError, ConfigCompileErrorKind, ConfigCompileSection,
14+
InvalidConfigScriptName, SetupScriptOutputError,
1515
},
1616
list::TestList,
1717
platform::BuildPlatforms,
@@ -28,6 +28,7 @@ use std::{
2828
collections::{BTreeMap, HashMap, HashSet},
2929
fmt,
3030
process::Command,
31+
sync::Arc,
3132
time::Duration,
3233
};
3334
use tokio::io::{AsyncBufReadExt, BufReader};
@@ -169,7 +170,7 @@ impl SetupScriptCommand {
169170
config: &ScriptConfig,
170171
double_spawn: &DoubleSpawnInfo,
171172
test_list: &TestList<'_>,
172-
) -> Result<Self, SetupScriptError> {
173+
) -> Result<Self, ChildStartError> {
173174
let mut cmd = create_command(config.program().to_owned(), config.args(), double_spawn);
174175

175176
// NB: we will always override user-provided environment variables with the
@@ -179,7 +180,7 @@ impl SetupScriptCommand {
179180
let env_path = camino_tempfile::Builder::new()
180181
.prefix("nextest-env")
181182
.tempfile()
182-
.map_err(SetupScriptError::TempPath)?
183+
.map_err(|error| ChildStartError::TempPath(Arc::new(error)))?
183184
.into_temp_path();
184185

185186
cmd.current_dir(test_list.workspace_root())
@@ -249,31 +250,32 @@ pub(crate) struct SetupScriptEnvMap {
249250
}
250251

251252
impl SetupScriptEnvMap {
252-
pub(crate) async fn new(env_path: &Utf8Path) -> Result<Self, SetupScriptError> {
253+
pub(crate) async fn new(env_path: &Utf8Path) -> Result<Self, SetupScriptOutputError> {
253254
let mut env_map = BTreeMap::new();
254255
let f = tokio::fs::File::open(env_path).await.map_err(|error| {
255-
SetupScriptError::EnvFileOpen {
256+
SetupScriptOutputError::EnvFileOpen {
256257
path: env_path.to_owned(),
257-
error,
258+
error: Arc::new(error),
258259
}
259260
})?;
260261
let reader = BufReader::new(f);
261262
let mut lines = reader.lines();
262263
loop {
263-
let line = lines
264-
.next_line()
265-
.await
266-
.map_err(|error| SetupScriptError::EnvFileRead {
267-
path: env_path.to_owned(),
268-
error,
269-
})?;
264+
let line =
265+
lines
266+
.next_line()
267+
.await
268+
.map_err(|error| SetupScriptOutputError::EnvFileRead {
269+
path: env_path.to_owned(),
270+
error: Arc::new(error),
271+
})?;
270272
let Some(line) = line else { break };
271273

272274
// Split this line into key and value.
273275
let (key, value) = match line.split_once('=') {
274276
Some((key, value)) => (key, value),
275277
None => {
276-
return Err(SetupScriptError::EnvFileParse {
278+
return Err(SetupScriptOutputError::EnvFileParse {
277279
path: env_path.to_owned(),
278280
line: line.to_owned(),
279281
})
@@ -282,7 +284,7 @@ impl SetupScriptEnvMap {
282284

283285
// Ban keys starting with `NEXTEST`.
284286
if key.starts_with("NEXTEST") {
285-
return Err(SetupScriptError::EnvFileReservedKey {
287+
return Err(SetupScriptOutputError::EnvFileReservedKey {
286288
key: key.to_owned(),
287289
});
288290
}

nextest-runner/src/errors.rs

Lines changed: 89 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -274,40 +274,21 @@ impl ConfigCompileErrorKind {
274274
}
275275
}
276276

277-
/// An execution error was returned while running a test.
278-
///
279-
/// Internal error type.
280-
#[derive(Debug, Error)]
281-
#[non_exhaustive]
282-
pub(crate) enum RunTestError {
283-
#[error("error spawning test process")]
284-
Spawn(#[source] std::io::Error),
285-
286-
#[error("errors while managing test process")]
287-
Child {
288-
/// The errors that occurred; guaranteed to be non-empty.
289-
errors: ErrorList<ChildError>,
290-
},
291-
}
292-
293-
/// An error that occurred while setting up or running a setup script.
294-
#[derive(Debug, Error)]
295-
pub(crate) enum SetupScriptError {
296-
/// An error occurred while creating a temporary path for the setup script.
277+
/// An execution error occurred while attempting to start a test.
278+
#[derive(Clone, Debug, Error)]
279+
pub enum ChildStartError {
280+
/// An error occurred while creating a temporary path for a setup script.
297281
#[error("error creating temporary path for setup script")]
298-
TempPath(#[source] std::io::Error),
299-
300-
/// An error occurred while executing the setup script.
301-
#[error("error executing setup script")]
302-
ExecFail(#[source] std::io::Error),
282+
TempPath(#[source] Arc<std::io::Error>),
303283

304-
/// One or more errors occurred while managing the child process.
305-
#[error("errors while managing setup script process")]
306-
Child {
307-
/// The errors that occurred; guaranteed to be non-empty.
308-
errors: ErrorList<ChildError>,
309-
},
284+
/// An error occurred while spawning the child process.
285+
#[error("error spawning child process")]
286+
Spawn(#[source] Arc<std::io::Error>),
287+
}
310288

289+
/// An error that occurred while reading the output of a setup script.
290+
#[derive(Clone, Debug, Error)]
291+
pub enum SetupScriptOutputError {
311292
/// An error occurred while opening the setup script environment file.
312293
#[error("error opening environment file `{path}`")]
313294
EnvFileOpen {
@@ -316,7 +297,7 @@ pub(crate) enum SetupScriptError {
316297

317298
/// The underlying error.
318299
#[source]
319-
error: std::io::Error,
300+
error: Arc<std::io::Error>,
320301
},
321302

322303
/// An error occurred while reading the setup script environment file.
@@ -327,42 +308,78 @@ pub(crate) enum SetupScriptError {
327308

328309
/// The underlying error.
329310
#[source]
330-
error: std::io::Error,
311+
error: Arc<std::io::Error>,
331312
},
332313

333314
/// An error occurred while parsing the setup script environment file.
334315
#[error("line `{line}` in environment file `{path}` not in KEY=VALUE format")]
335-
EnvFileParse { path: Utf8PathBuf, line: String },
316+
EnvFileParse {
317+
/// The path to the environment file.
318+
path: Utf8PathBuf,
319+
/// The line at issue.
320+
line: String,
321+
},
336322

337323
/// An environment variable key was reserved.
338324
#[error("key `{key}` begins with `NEXTEST`, which is reserved for internal use")]
339-
EnvFileReservedKey { key: String },
325+
EnvFileReservedKey {
326+
/// The environment variable name.
327+
key: String,
328+
},
340329
}
341330

342331
/// A list of errors that implements `Error`.
343332
///
344333
/// In the future, we'll likely want to replace this with a `miette::Diagnostic`-based error, since
345334
/// that supports multiple causes via "related".
346335
#[derive(Clone, Debug)]
347-
pub struct ErrorList<T>(pub Vec<T>);
336+
pub struct ErrorList<T> {
337+
// A description of what the errors are.
338+
description: &'static str,
339+
// Invariant: this list is non-empty.
340+
inner: Vec<T>,
341+
}
342+
343+
impl<T: std::error::Error> ErrorList<T> {
344+
pub(crate) fn new<U>(description: &'static str, errors: Vec<U>) -> Option<Self>
345+
where
346+
T: From<U>,
347+
{
348+
if errors.is_empty() {
349+
None
350+
} else {
351+
Some(Self {
352+
description,
353+
inner: errors.into_iter().map(T::from).collect(),
354+
})
355+
}
356+
}
348357

349-
impl<T> ErrorList<T> {
350-
/// Returns true if the error list is empty.
351-
pub fn is_empty(&self) -> bool {
352-
self.0.is_empty()
358+
/// Returns a 1 line summary of the error list.
359+
pub(crate) fn as_one_line_summary(&self) -> String {
360+
if self.inner.len() == 1 {
361+
format!("{}", self.inner[0])
362+
} else {
363+
format!("{} errors occurred {}", self.inner.len(), self.description)
364+
}
353365
}
354366
}
355367

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

363375
// Otherwise, list all errors.
364-
writeln!(f, "{} errors occurred:", self.0.len())?;
365-
for error in &self.0 {
376+
writeln!(
377+
f,
378+
"{} errors occurred {}:",
379+
self.inner.len(),
380+
self.description,
381+
)?;
382+
for error in &self.inner {
366383
let mut indent = IndentWriter::new_skip_initial(" ", f);
367384
writeln!(indent, "* {}", DisplayErrorChain(error))?;
368385
f = indent.into_inner();
@@ -373,8 +390,8 @@ impl<T: std::error::Error> fmt::Display for ErrorList<T> {
373390

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

424-
/// An error was returned during the process of managing a child process.
441+
/// An error was returned while managing a child process or reading its output.
425442
#[derive(Clone, Debug, Error)]
426-
#[non_exhaustive]
427443
pub enum ChildError {
444+
/// An error occurred while reading from a child file descriptor.
445+
#[error(transparent)]
446+
Fd(#[from] ChildFdError),
447+
448+
/// An error occurred while reading the output of a setup script.
449+
#[error(transparent)]
450+
SetupScriptOutput(#[from] SetupScriptOutputError),
451+
}
452+
453+
/// An error was returned while reading from child a file descriptor.
454+
#[derive(Clone, Debug, Error)]
455+
pub enum ChildFdError {
428456
/// An error occurred while reading standard output.
429457
#[error("error reading standard output")]
430458
ReadStdout(#[source] Arc<std::io::Error>),
@@ -1822,14 +1850,18 @@ mod tests {
18221850
fn display_error_list() {
18231851
let err1 = StringError::new("err1", None);
18241852

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

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

1832-
let error_list = ErrorList(vec![err3.clone()]);
1862+
let error_list =
1863+
ErrorList::<StringError>::new("waiting on flowers to bloom", vec![err3.clone()])
1864+
.expect(">= 1 error");
18331865
insta::assert_snapshot!(format!("{}", error_list), @"err3");
18341866
insta::assert_snapshot!(format!("{}", DisplayErrorChain::new(&error_list)), @r"
18351867
err3
@@ -1842,10 +1874,14 @@ mod tests {
18421874
let err5 = StringError::new("err5", Some(err4));
18431875
let err6 = StringError::new("err6\nerr6 line 2", Some(err5));
18441876

1845-
let error_list = ErrorList(vec![err3, err6]);
1877+
let error_list = ErrorList::<StringError>::new(
1878+
"waiting for the heat death of the universe",
1879+
vec![err3, err6],
1880+
)
1881+
.expect(">= 1 error");
18461882

18471883
insta::assert_snapshot!(format!("{}", error_list), @r"
1848-
2 errors occurred:
1884+
2 errors occurred waiting for the heat death of the universe:
18491885
* err3
18501886
caused by:
18511887
- err2
@@ -1857,7 +1893,7 @@ mod tests {
18571893
- err4
18581894
");
18591895
insta::assert_snapshot!(format!("{}", DisplayErrorChain::new(&error_list)), @r"
1860-
2 errors occurred:
1896+
2 errors occurred waiting for the heat death of the universe:
18611897
* err3
18621898
caused by:
18631899
- err2

nextest-runner/src/reporter/aggregator.rs

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66
use super::TestEvent;
77
use crate::{
88
config::{EvaluatableProfile, NextestJunitConfig},
9-
errors::WriteEventError,
9+
errors::{DisplayErrorChain, WriteEventError},
1010
list::TestInstance,
1111
reporter::TestEventKind,
1212
runner::{ExecuteStatus, ExecutionDescription, ExecutionResult},
13-
test_output::{TestExecutionOutput, TestOutput},
13+
test_output::{ChildExecutionResult, ChildOutput},
1414
};
1515
use camino::Utf8PathBuf;
1616
use debug_ignore::DebugIgnore;
@@ -303,8 +303,13 @@ fn set_execute_status_props(
303303
mut out: TestcaseOrRerun<'_>,
304304
) {
305305
match &execute_status.output {
306-
TestExecutionOutput::Output(output) => {
306+
ChildExecutionResult::Output { output, errors } => {
307307
if !is_success {
308+
if let Some(errors) = errors {
309+
// Use the child errors as the message and description.
310+
out.set_message(errors.as_one_line_summary());
311+
out.set_description(DisplayErrorChain::new(errors).to_string());
312+
};
308313
let description = output.heuristic_extract_description(execute_status.result);
309314
if let Some(description) = description {
310315
out.set_description(description.display_human().to_string());
@@ -313,27 +318,24 @@ fn set_execute_status_props(
313318

314319
if store_stdout_stderr {
315320
match output {
316-
TestOutput::Split(split) => {
321+
ChildOutput::Split(split) => {
317322
if let Some(stdout) = &split.stdout {
318323
out.set_system_out(stdout.as_str_lossy());
319324
}
320325
if let Some(stderr) = &split.stderr {
321326
out.set_system_err(stderr.as_str_lossy());
322327
}
323328
}
324-
TestOutput::Combined { output } => {
329+
ChildOutput::Combined { output } => {
325330
out.set_system_out(output.as_str_lossy())
326331
.set_system_err("(stdout and stderr are combined)");
327332
}
328333
}
329334
}
330335
}
331-
TestExecutionOutput::ExecFail {
332-
message,
333-
description,
334-
} => {
335-
out.set_message(format!("Test execution failed: {message}"));
336-
out.set_description(description);
336+
ChildExecutionResult::StartError(error) => {
337+
out.set_message(format!("Test execution failed: {error}"));
338+
out.set_description(DisplayErrorChain::new(error).to_string());
337339
}
338340
}
339341
}

0 commit comments

Comments
 (0)