Skip to content

Commit d332b40

Browse files
committed
Improve SIGINT handling in uv run
There should be two functional changes here: - If we receive SIGINT twice, forward it to the child process - If the `uv run` child process changes its PGID, then forward SIGINT Previously, we never forwarded SIGINT to a child process. Instead, we relied on shell to do so.
1 parent 1ef47aa commit d332b40

File tree

1 file changed

+92
-18
lines changed

1 file changed

+92
-18
lines changed

crates/uv/src/commands/run.rs

+92-18
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,93 @@ use tokio::process::Child;
33
use tracing::debug;
44

55
/// Wait for the child process to complete, handling signals and error codes.
6+
///
7+
/// Note that this registers handles to ignore some signals in the parent process. This is safe as
8+
/// long as the command is the last thing that runs in this process; otherwise, we'd need to restore
9+
/// the default signal handlers after the command completes.
610
pub(crate) async fn run_to_completion(mut handle: Child) -> anyhow::Result<ExitStatus> {
7-
// Ignore signals in the parent process, deferring them to the child. This is safe as long as
8-
// the command is the last thing that runs in this process; otherwise, we'd need to restore the
9-
// signal handlers after the command completes.
10-
let _handler = tokio::spawn(async { while tokio::signal::ctrl_c().await.is_ok() {} });
11-
12-
// Exit based on the result of the command.
11+
// On Unix, shells will send SIGINT to the active process group when a user presses `Ctrl-C`. In
12+
// general, this means that uv should ignore SIGINT, allowing the child process to cleanly exit
13+
// instead. If uv forwarded the SIGINT immediately, the child process would receive _two_ SIGINT
14+
// signals which has semantic meaning for some programs, i.e., slow exit on the first signal and
15+
// fast exit on the second. The exception to this is if a child process changes its process
16+
// group, in which case the shell will _not_ send SIGINT to the child process and uv must take
17+
// ownership of forwarding the signal.
18+
//
19+
// Note this assumes an interactive shell. If a signal is sent directly to the uv parent process
20+
// (e.g., `kill -2 <pid>`), the process group is not involved and a signal is not sent to the
21+
// child by default. In this context, uv must forward the signal to the child. We work around
22+
// this by forwarding SIGINT if it is received more than once. We could attempt to infer if the
23+
// parent is a shell using TTY detection(?), but there hasn't been sufficient motivation to
24+
// explore alternatives yet.
25+
//
26+
// Use of SIGTERM is also a bit complicated. If a shell receives a SIGTERM, it just waits for
27+
// its children to exit — multiple SIGTERMs do not have any effect and the signals are not
28+
// forwarded to the children. Consequently, the description for SIGINT above does not apply to
29+
// SIGTERM in shells. It is _possible_ to have a parent process that sends a SIGTERM to the
30+
// process group; for example, `tini` supports this via a `-g` option. In this case, it's
31+
// possible that uv will improperly send a second SIGTERM to the child process. However,
32+
// this seems preferable to not forwarding it in the first place.
1333
#[cfg(unix)]
1434
let status = {
35+
use nix::sys::signal;
36+
use nix::unistd::{getpgid, Pid};
1537
use tokio::select;
16-
use tokio::signal::unix::{signal, SignalKind};
38+
use tokio::signal::unix::{signal as handle_signal, SignalKind};
39+
40+
// Get the parent and child process group ids
41+
let child_pid = handle
42+
.id()
43+
.and_then(|id| id.try_into().ok())
44+
.map(Pid::from_raw);
45+
let parent_pgid = getpgid(None)?;
46+
47+
let mut sigterm_handle = handle_signal(SignalKind::terminate())?;
48+
let mut sigint_handle = handle_signal(SignalKind::interrupt())?;
49+
let mut sigint_count = 0;
1750

18-
let mut term_signal = signal(SignalKind::terminate())?;
1951
loop {
2052
select! {
2153
result = handle.wait() => {
2254
break result;
2355
},
56+
_ = sigint_handle.recv() => {
57+
// See above for commentary on handling of SIGINT.
2458

25-
// `SIGTERM`
26-
_ = term_signal.recv() => {
27-
let _ = terminate_process(&mut handle);
59+
// Check if the child pgid has changed
60+
let child_pgid = getpgid(child_pid)?;
61+
62+
// Increment the number of interrupts seen
63+
sigint_count += 1;
64+
65+
// If the pgid _differs_ from the parent, the child will not receive a SIGINT
66+
// and we should forward it. If we've received multiple SIGINTs, forward it
67+
// regardless.
68+
if child_pgid == parent_pgid && sigint_count < 2 {
69+
continue;
70+
}
71+
72+
let _ = send_signal(&handle, child_pid, signal::Signal::SIGINT);
73+
},
74+
_ = sigterm_handle.recv() => {
75+
// We unconditionally forward SIGTERM to the child process; unlike SIGINT, this
76+
// isn't usually handled by the shell and in cases like
77+
let _ = send_signal(&handle, child_pid, signal::Signal::SIGTERM);
2878
}
2979
};
3080
}
3181
}?;
3282

83+
// On Windows, we just ignore the console CTRL_C_EVENT and assume it will always be sent to the
84+
// child by the console. There's not a clear programmatic way to forward the signal anyway.
3385
#[cfg(not(unix))]
34-
let status = handle.wait().await?;
86+
let status = {
87+
let _ctrl_c_handler =
88+
tokio::spawn(async { while tokio::signal::ctrl_c().await.is_ok() {} });
89+
handle.wait().await?
90+
}?;
3591

92+
// Exit based on the result of the command.
3693
if let Some(code) = status.code() {
3794
debug!("Command exited with code: {code}");
3895
if let Ok(code) = u8::try_from(code) {
@@ -60,12 +117,29 @@ pub(crate) async fn run_to_completion(mut handle: Child) -> anyhow::Result<ExitS
60117
}
61118
}
62119

120+
/// Send a signal to a child process on Unix.
121+
///
122+
/// Includes a safety check that the process has not exited.
63123
#[cfg(unix)]
64-
fn terminate_process(child: &mut Child) -> anyhow::Result<()> {
65-
use anyhow::Context;
66-
use nix::sys::signal::{self, Signal};
67-
use nix::unistd::Pid;
124+
fn send_signal(
125+
child: &Child,
126+
child_pid: Option<nix::unistd::Pid>,
127+
signal: nix::sys::signal::Signal,
128+
) -> anyhow::Result<()> {
129+
use nix::sys::signal;
130+
131+
// If the child has already exited, we can't send it signals
132+
let Some(child_pid) = child_pid else {
133+
anyhow::bail!("Child process has already exited");
134+
};
135+
136+
// The child can exit and a different process can take its PID; this may be
137+
// overly defensive but seems better than sending a signal to the wrong process.
138+
if child.id().is_none() {
139+
anyhow::bail!("Child process has already exited");
140+
}
141+
142+
signal::kill(child_pid, signal)?;
68143

69-
let pid = child.id().context("Failed to get child process ID")?;
70-
signal::kill(Pid::from_raw(pid.try_into()?), Signal::SIGTERM).context("Failed to send SIGTERM")
144+
Ok(())
71145
}

0 commit comments

Comments
 (0)