Skip to content

Commit a9d7492

Browse files
committed
[cargo-nextest] add the experimental ability to double-spawn test processes
We're going to need this to support SIGTSTP properly, as well as for PID namespacing (#624). Add an experimental implementation gated behind an env var.
1 parent 50c2238 commit a9d7492

File tree

14 files changed

+329
-49
lines changed

14 files changed

+329
-49
lines changed

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ jobs:
9090
uses: taiki-e/install-action@nextest
9191
- name: Test with latest nextest release
9292
run: cargo nextest run --profile ci
93+
- name: Test double-spawning test processes
94+
if: ${{ matrix.os == 'ubuntu-latest' || matrix.os == 'macos-latest' }}
95+
env:
96+
NEXTEST_EXPERIMENTAL_DOUBLE_SPAWN: 1
97+
run: cargo local-nt run --profile ci
9398

9499
test-archive-target-runner:
95100
name: Test archives with a target runner

cargo-nextest/src/dispatch.rs

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,12 @@ use nextest_runner::{
1919
get_num_cpus, NextestConfig, NextestProfile, PreBuildPlatform, RetryPolicy, TestThreads,
2020
ToolConfigFile,
2121
},
22+
double_spawn::DoubleSpawnInfo,
2223
errors::WriteTestListError,
23-
list::{BinaryList, OutputFormat, RustTestArtifact, SerializableFormat, TestList},
24+
list::{
25+
BinaryList, OutputFormat, RustTestArtifact, SerializableFormat, TestExecuteContext,
26+
TestList,
27+
},
2428
partition::PartitionerBuilder,
2529
platform::BuildPlatforms,
2630
reporter::{FinalStatusLevel, StatusLevel, TestOutputDisplay, TestReporterBuilder},
@@ -53,15 +57,22 @@ pub struct CargoNextestApp {
5357
impl CargoNextestApp {
5458
/// Executes the app.
5559
pub fn exec(self, output_writer: &mut OutputWriter) -> Result<i32> {
56-
let NextestSubcommand::Nextest(app) = self.subcommand;
57-
app.exec(output_writer)
60+
match self.subcommand {
61+
NextestSubcommand::Nextest(app) => app.exec(output_writer),
62+
#[cfg(unix)]
63+
NextestSubcommand::DoubleSpawn(opts) => opts.exec(),
64+
}
5865
}
5966
}
6067

6168
#[derive(Debug, Subcommand)]
6269
enum NextestSubcommand {
6370
/// A next-generation test runner for Rust. <https://nexte.st>
64-
Nextest(AppOpts),
71+
Nextest(Box<AppOpts>),
72+
/// Private command, used to double-spawn test processes.
73+
#[cfg(unix)]
74+
#[command(name = nextest_runner::double_spawn::DoubleSpawnInfo::SUBCOMMAND_NAME, hide = true)]
75+
DoubleSpawn(crate::double_spawn::DoubleSpawnOpts),
6576
}
6677

6778
#[derive(Debug, Args)]
@@ -442,10 +453,10 @@ struct TestBuildFilter {
442453
impl TestBuildFilter {
443454
fn compute_test_list<'g>(
444455
&self,
456+
ctx: &TestExecuteContext<'_>,
445457
graph: &'g PackageGraph,
446458
binary_list: Arc<BinaryList>,
447459
test_filter_builder: TestFilterBuilder,
448-
runner: &TargetRunner,
449460
env: EnvironmentMap,
450461
reuse_build: &ReuseBuildInfo,
451462
) -> Result<TestList<'g>> {
@@ -464,10 +475,10 @@ impl TestBuildFilter {
464475
self.platform_filter.into(),
465476
)?;
466477
TestList::new(
478+
ctx,
467479
test_artifacts,
468480
rust_build_meta,
469481
&test_filter_builder,
470-
runner,
471482
env,
472483
// TODO: do we need to allow customizing this?
473484
get_num_cpus(),
@@ -835,6 +846,7 @@ struct BaseApp {
835846
config_opts: ConfigOpts,
836847

837848
cargo_configs: CargoConfigs,
849+
double_spawn: OnceCell<DoubleSpawnInfo>,
838850
target_runner: OnceCell<TargetRunner>,
839851
}
840852

@@ -918,10 +930,22 @@ impl BaseApp {
918930
config_opts,
919931
cargo_configs,
920932

933+
double_spawn: OnceCell::new(),
921934
target_runner: OnceCell::new(),
922935
})
923936
}
924937

938+
fn load_double_spawn(&self) -> &DoubleSpawnInfo {
939+
self.double_spawn.get_or_init(|| {
940+
if std::env::var("NEXTEST_EXPERIMENTAL_DOUBLE_SPAWN") == Ok("1".to_owned()) {
941+
log::info!("using experimental double-spawn method for test processes");
942+
DoubleSpawnInfo::enabled()
943+
} else {
944+
DoubleSpawnInfo::disabled()
945+
}
946+
})
947+
}
948+
925949
fn load_runner(&self, build_platforms: &BuildPlatforms) -> &TargetRunner {
926950
self.target_runner
927951
.get_or_init(|| runner_for_target(&self.cargo_configs, build_platforms))
@@ -1039,16 +1063,16 @@ impl App {
10391063

10401064
fn build_test_list(
10411065
&self,
1066+
ctx: &TestExecuteContext<'_>,
10421067
binary_list: Arc<BinaryList>,
10431068
test_filter_builder: TestFilterBuilder,
1044-
target_runner: &TargetRunner,
10451069
) -> Result<TestList> {
10461070
let env = EnvironmentMap::new(&self.base.cargo_configs);
10471071
self.build_filter.compute_test_list(
1072+
ctx,
10481073
self.base.graph(),
10491074
binary_list,
10501075
test_filter_builder,
1051-
target_runner,
10521076
env,
10531077
&self.base.reuse_build,
10541078
)
@@ -1101,11 +1125,16 @@ impl App {
11011125
writer.flush().map_err(WriteTestListError::Io)?;
11021126
}
11031127
ListType::Full => {
1128+
let double_spawn = self.base.load_double_spawn();
11041129
let target_runner = self
11051130
.base
11061131
.load_runner(&binary_list.rust_build_meta.build_platforms()?);
1107-
let test_list =
1108-
self.build_test_list(binary_list, test_filter_builder, target_runner)?;
1132+
let ctx = TestExecuteContext {
1133+
double_spawn,
1134+
target_runner,
1135+
};
1136+
1137+
let test_list = self.build_test_list(&ctx, binary_list, test_filter_builder)?;
11091138

11101139
let mut writer = output_writer.stdout_writer();
11111140
test_list.write(
@@ -1138,9 +1167,14 @@ impl App {
11381167

11391168
let binary_list = self.base.build_binary_list()?;
11401169
let build_platforms = binary_list.rust_build_meta.build_platforms()?;
1170+
let double_spawn = self.base.load_double_spawn();
11411171
let target_runner = self.base.load_runner(&build_platforms);
1172+
let ctx = TestExecuteContext {
1173+
double_spawn,
1174+
target_runner,
1175+
};
11421176

1143-
let test_list = self.build_test_list(binary_list, test_filter_builder, target_runner)?;
1177+
let test_list = self.build_test_list(&ctx, binary_list, test_filter_builder)?;
11441178

11451179
let output = output_writer.reporter_output();
11461180
let profile = profile.apply_build_platforms(&build_platforms);
@@ -1162,8 +1196,13 @@ impl App {
11621196
}
11631197
};
11641198

1165-
let mut runner =
1166-
runner_builder.build(&test_list, profile, handler, target_runner.clone())?;
1199+
let mut runner = runner_builder.build(
1200+
&test_list,
1201+
profile,
1202+
handler,
1203+
double_spawn.clone(),
1204+
target_runner.clone(),
1205+
)?;
11671206

11681207
configure_handle_inheritance(no_capture)?;
11691208
let run_stats = runner.try_execute(|event| {

cargo-nextest/src/double_spawn.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright (c) The nextest Contributors
2+
// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+
use crate::{output::Color, ExpectedError, Result};
5+
use camino::Utf8PathBuf;
6+
use clap::Args;
7+
use std::os::unix::process::CommandExt;
8+
9+
#[derive(Debug, Args)]
10+
pub(crate) struct DoubleSpawnOpts {
11+
/// The program to execute.
12+
program: Utf8PathBuf,
13+
14+
/// The args to execute the program with, provided as a string.
15+
args: String,
16+
}
17+
18+
impl DoubleSpawnOpts {
19+
pub(crate) fn exec(self) -> Result<i32> {
20+
// This double-spawned process should never use coloring.
21+
Color::Never.init();
22+
let args = shell_words::split(&self.args).map_err(|err| {
23+
ExpectedError::DoubleSpawnParseArgsError {
24+
args: self.args,
25+
err,
26+
}
27+
})?;
28+
let mut command = std::process::Command::new(&self.program);
29+
// Note: exec only returns an error -- in the success case it never returns.
30+
let err = command.args(args).exec();
31+
Err(ExpectedError::DoubleSpawnExecError { command, err })
32+
}
33+
}

cargo-nextest/src/errors.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,18 @@ pub enum ExpectedError {
192192
reason: &'static str,
193193
args: Vec<String>,
194194
},
195+
#[error("double-spawn parse error")]
196+
DoubleSpawnParseArgsError {
197+
args: String,
198+
#[source]
199+
err: shell_words::ParseError,
200+
},
201+
#[error("double-spawn execution error")]
202+
DoubleSpawnExecError {
203+
command: std::process::Command,
204+
#[source]
205+
err: std::io::Error,
206+
},
195207
}
196208

197209
impl ExpectedError {
@@ -319,6 +331,9 @@ impl ExpectedError {
319331
| Self::SignalHandlerSetupError { .. } => NextestExitCode::SETUP_ERROR,
320332
#[cfg(feature = "self-update")]
321333
Self::UpdateVersionParseError { .. } => NextestExitCode::SETUP_ERROR,
334+
Self::DoubleSpawnParseArgsError { .. } | Self::DoubleSpawnExecError { .. } => {
335+
NextestExitCode::DOUBLE_SPAWN_ERROR
336+
}
322337
Self::FromMessagesError { .. } | Self::CreateTestListError { .. } => {
323338
NextestExitCode::TEST_LIST_CREATION_FAILED
324339
}
@@ -591,6 +606,14 @@ impl ExpectedError {
591606
);
592607
None
593608
}
609+
Self::DoubleSpawnParseArgsError { args, err } => {
610+
log::error!("[double-spawn] failed to parse arguments `{args}`");
611+
Some(err as &dyn Error)
612+
}
613+
Self::DoubleSpawnExecError { command, err } => {
614+
log::error!("[double-spawn] failed to exec `{command:?}`");
615+
Some(err as &dyn Error)
616+
}
594617
};
595618

596619
while let Some(err) = next_error {

cargo-nextest/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
mod cargo_cli;
1717
mod dispatch;
18+
#[cfg(unix)]
19+
mod double_spawn;
1820
mod errors;
1921
mod output;
2022
mod reuse_build;

cargo-nextest/src/output.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ impl Default for Color {
6868
static INIT_LOGGER: std::sync::Once = std::sync::Once::new();
6969

7070
impl Color {
71-
fn init(self) {
71+
pub(crate) fn init(self) {
7272
match self {
7373
Color::Auto => owo_colors::unset_override(),
7474
Color::Always => owo_colors::set_override(true),

nextest-metadata/src/exit_codes.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ impl NextestExitCode {
1616
/// Building tests produced an error.
1717
pub const BUILD_FAILED: i32 = 101;
1818

19+
/// An error was encountered while attempting to double-spawn a nextest process.
20+
pub const DOUBLE_SPAWN_ERROR: i32 = 70;
21+
1922
/// One or more tests failed.
2023
pub const TEST_RUN_FAILED: i32 = 100;
2124

nextest-runner/src/double_spawn.rs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright (c) The nextest Contributors
2+
// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+
//! Support for double-spawning test processes.
5+
//!
6+
//! Nextest has experimental support on Unix for spawning test processes twice, to enable better
7+
//! isolation and solve some thorny issues.
8+
9+
use self::imp::DoubleSpawnInfoImp;
10+
use std::path::Path;
11+
12+
/// Information about double-spawning processes. This determines whether a process will be
13+
/// double-spawned.
14+
///
15+
/// This is used by the main nextest process.
16+
#[derive(Clone, Debug)]
17+
pub struct DoubleSpawnInfo {
18+
inner: DoubleSpawnInfoImp,
19+
}
20+
21+
impl DoubleSpawnInfo {
22+
/// The name of the double-spawn subcommand, used throughout nextest.
23+
pub const SUBCOMMAND_NAME: &'static str = "__double-spawn";
24+
25+
/// This returns a `DoubleSpawnInfo` which attempts to perform double-spawning.
26+
///
27+
/// This is super experimental, and should be used with caution.
28+
pub fn enabled() -> Self {
29+
Self {
30+
inner: DoubleSpawnInfoImp::enabled(),
31+
}
32+
}
33+
34+
/// This returns a `DoubleSpawnInfo` which disables double-spawning.
35+
pub fn disabled() -> Self {
36+
Self {
37+
inner: DoubleSpawnInfoImp::disabled(),
38+
}
39+
}
40+
/// Returns the current executable, if one is available.
41+
///
42+
/// If `None`, double-spawning is not used.
43+
pub fn current_exe(&self) -> Option<&Path> {
44+
self.inner.current_exe()
45+
}
46+
}
47+
48+
#[cfg(unix)]
49+
mod imp {
50+
use super::*;
51+
use std::path::PathBuf;
52+
53+
#[derive(Clone, Debug)]
54+
pub(super) struct DoubleSpawnInfoImp {
55+
current_exe: Option<PathBuf>,
56+
}
57+
58+
impl DoubleSpawnInfoImp {
59+
#[inline]
60+
pub(super) fn enabled() -> Self {
61+
// Attempt to obtain the current exe, and warn if it couldn't be found.
62+
// TODO: maybe add an option to fail?
63+
let current_exe = std::env::current_exe().map_or_else(
64+
|error| {
65+
log::warn!(
66+
"unable to determine current exe, tests will be less isolated: {error}"
67+
);
68+
None
69+
},
70+
Some,
71+
);
72+
Self { current_exe }
73+
}
74+
75+
#[inline]
76+
pub(super) fn disabled() -> Self {
77+
Self { current_exe: None }
78+
}
79+
80+
#[inline]
81+
pub(super) fn current_exe(&self) -> Option<&Path> {
82+
self.current_exe.as_deref()
83+
}
84+
}
85+
}
86+
87+
#[cfg(not(unix))]
88+
mod imp {
89+
use super::*;
90+
91+
#[derive(Clone, Debug)]
92+
pub(super) struct DoubleSpawnInfoImp {}
93+
94+
impl DoubleSpawnInfoImp {
95+
#[inline]
96+
pub(super) fn enabled() -> Self {
97+
Self {}
98+
}
99+
100+
#[inline]
101+
pub(super) fn disabled() -> Self {
102+
Self {}
103+
}
104+
105+
#[inline]
106+
pub(super) fn current_exe(&self) -> Option<&Path> {
107+
None
108+
}
109+
}
110+
}

0 commit comments

Comments
 (0)