Skip to content

Commit aeb2339

Browse files
authored
PHD: improve ctrl-c handling (#634)
Install a signal handler in the PHD runner that catches SIGINT and relays it via `tokio::sync::watch` to any tasks that are currently executing tests. This allows the test tasks to exit at the next available `await` point. Since these tasks are themselves awaited before the runner exits, this ensures that all VMs and disks created by a test can be dropped and cleaned up before the runner goes away. Ctrl-C characters sent to a shell will typically cause SIGINT to be sent to all processes in the foreground process group. This is bad for PHD because any Propolis servers the runner spawned will be killed before `TestVm::drop` gets a chance to gracefully stop the VMs inside. Since dropping a `PropolisServer` kills the process in any case, get around this by configuring PHD-spawned Propolis servers to ignore SIGINT. Tested with local Debian 11 runs, observing in particular that interrupting a run with Ctrl-C does not leak any VMMs or temporary disk objects.
1 parent c7cdaf1 commit aeb2339

File tree

2 files changed

+108
-26
lines changed

2 files changed

+108
-26
lines changed

phd-tests/framework/src/test_vm/server.rs

+39-12
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
//! Routines and data structures for working with Propolis server processes.
66
7-
use std::{fmt::Debug, net::SocketAddrV4};
7+
use std::{fmt::Debug, net::SocketAddrV4, os::unix::process::CommandExt};
88

99
use anyhow::Result;
1010
use camino::{Utf8Path, Utf8PathBuf};
@@ -62,18 +62,45 @@ impl PropolisServer {
6262
let (server_stdout, server_stderr) =
6363
log_mode.get_handles(&data_dir, vm_name)?;
6464

65+
let mut server_cmd = std::process::Command::new("pfexec");
66+
server_cmd
67+
.args([
68+
server_path.as_str(),
69+
"run",
70+
config_toml_path.as_str(),
71+
server_addr.to_string().as_str(),
72+
vnc_addr.to_string().as_str(),
73+
])
74+
.stdout(server_stdout)
75+
.stderr(server_stderr);
76+
77+
// Gracefully shutting down a Propolis server requires PHD to send an
78+
// instance stop request to the server before it is actually terminated.
79+
// This ensures that the server has a chance to clean up kernel VMM
80+
// resources. It's desirable for the server to do this and not PHD
81+
// because a failure to clean up VMMs on stop is a Propolis bug.
82+
//
83+
// The PHD runner sets up a SIGINT handler that tries to give the
84+
// framework an opportunity to issue these requests before the runner
85+
// exits. However, pressing Ctrl-C in a shell will typically broadcast
86+
// SIGINT to all of the processes in the foreground process's group, not
87+
// just to the foreground process itself. This means that a Ctrl-C press
88+
// will usually kill all of PHD's Propolis servers before the cleanup
89+
// logic can run.
90+
//
91+
// To avoid this problem, add a pre-`exec` hook that directs Propolis
92+
// servers to ignore SIGINT. On Ctrl-C, the runner will drop all active
93+
// `TestVm`s, and this drop path (if allowed to complete) will kill all
94+
// these processes.
95+
unsafe {
96+
server_cmd.pre_exec(move || {
97+
libc::signal(libc::SIGINT, libc::SIG_IGN);
98+
Ok(())
99+
});
100+
}
101+
65102
let server = PropolisServer {
66-
server: std::process::Command::new("pfexec")
67-
.args([
68-
server_path.as_str(),
69-
"run",
70-
config_toml_path.as_str(),
71-
server_addr.to_string().as_str(),
72-
vnc_addr.to_string().as_str(),
73-
])
74-
.stdout(server_stdout)
75-
.stderr(server_stderr)
76-
.spawn()?,
103+
server: server_cmd.spawn()?,
77104
address: server_addr,
78105
};
79106

phd-tests/runner/src/execute.rs

+69-14
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ use std::sync::Arc;
66
use std::time::{Duration, Instant};
77

88
use phd_tests::phd_testcase::{Framework, TestCase, TestOutcome};
9-
use tracing::{error, info};
9+
use tokio::signal::unix::{signal, SignalKind};
10+
use tokio::sync::watch;
11+
use tracing::{error, info, warn};
1012

1113
use crate::config::RunOptions;
1214
use crate::fixtures::TestFixtures;
@@ -76,32 +78,53 @@ pub async fn run_tests_with_ctx(
7678
}
7779

7880
fixtures.execution_setup().unwrap();
79-
81+
let sigint_rx = set_sigint_handler();
8082
info!("Running {} test(s)", executions.len());
8183
let start_time = Instant::now();
82-
'exec_loop: for execution in &mut executions {
84+
for execution in &mut executions {
85+
if *sigint_rx.borrow() {
86+
info!("Test run interrupted by SIGINT");
87+
break;
88+
}
89+
8390
info!("Starting test {}", execution.tc.fully_qualified_name());
8491

8592
// Failure to run a setup fixture is fatal to the rest of the run, but
8693
// it's still possible to report results, so return gracefully instead
8794
// of panicking.
8895
if let Err(e) = fixtures.test_setup() {
8996
error!("Error running test setup fixture: {}", e);
90-
break 'exec_loop;
97+
break;
9198
}
9299

93100
stats.tests_not_run -= 1;
94101
let test_ctx = ctx.clone();
95102
let tc = execution.tc;
96-
let test_outcome =
97-
match tokio::spawn(async move { tc.run(test_ctx.as_ref()).await })
98-
.await
99-
{
100-
Ok(outcome) => outcome,
101-
Err(_) => TestOutcome::Failed(Some(
102-
"test task panicked, see test logs".to_string(),
103-
)),
104-
};
103+
let mut sigint_rx_task = sigint_rx.clone();
104+
let test_outcome = tokio::spawn(async move {
105+
tokio::select! {
106+
// Ensure interrupt signals are always handled instead of
107+
// continuing to run the test.
108+
biased;
109+
result = sigint_rx_task.changed() => {
110+
assert!(
111+
result.is_ok(),
112+
"SIGINT channel shouldn't drop while tests are running"
113+
);
114+
115+
TestOutcome::Failed(
116+
Some("test interrupted by SIGINT".to_string())
117+
)
118+
}
119+
outcome = tc.run(test_ctx.as_ref()) => outcome
120+
}
121+
})
122+
.await
123+
.unwrap_or_else(|_| {
124+
TestOutcome::Failed(Some(
125+
"test task panicked, see test logs".to_string(),
126+
))
127+
});
105128

106129
info!(
107130
"test {} ... {}{}",
@@ -132,7 +155,7 @@ pub async fn run_tests_with_ctx(
132155
execution.status = Status::Ran(test_outcome);
133156
if let Err(e) = fixtures.test_cleanup().await {
134157
error!("Error running cleanup fixture: {}", e);
135-
break 'exec_loop;
158+
break;
136159
}
137160
}
138161
stats.duration = start_time.elapsed();
@@ -141,3 +164,35 @@ pub async fn run_tests_with_ctx(
141164

142165
stats
143166
}
167+
168+
/// Sets a global handler for SIGINT and hands the resulting signal channel over
169+
/// to a task that handles this signal. Returns a receiver to which the signal
170+
/// handler task publishes `true` to the channel when SIGINT is received.
171+
fn set_sigint_handler() -> watch::Receiver<bool> {
172+
let mut sigint =
173+
signal(SignalKind::interrupt()).expect("failed to set SIGINT handler");
174+
175+
let (sigint_tx, sigint_rx) = watch::channel(false);
176+
tokio::spawn(async move {
177+
loop {
178+
sigint.recv().await;
179+
180+
// If a signal was previously dispatched to the channel, exit
181+
// immediately with the customary SIGINT exit code (130 is 128 +
182+
// SIGINT). This allows users to interrupt tests even if they aren't
183+
// at an await point (at the cost of not having destructors run).
184+
if *sigint_tx.borrow() {
185+
error!(
186+
"SIGINT received while shutting down, rudely terminating"
187+
);
188+
error!("some processes and resources may have been leaked!");
189+
std::process::exit(130);
190+
}
191+
192+
warn!("SIGINT received, sending shutdown signal to tests");
193+
let _ = sigint_tx.send(true);
194+
}
195+
});
196+
197+
sigint_rx
198+
}

0 commit comments

Comments
 (0)