Skip to content

Commit 9b17551

Browse files
APT-703 Properly Kill Subprocesses With ctrl-c (#81)
Running commands like "rojo serve" and pressing ctrl-c does not kill the rojo process as intended. Foreman should pass the signal to the tools it runs. This PR implements the same solution from [Aftman](LPGhatguy/aftman#13) Manually tested ctrl-c kills subprocesses on M2 mac and Windows machines Added 2 scripts to CI to test subprocess are properly killed on linux and windows machines
1 parent 1a21963 commit 9b17551

File tree

11 files changed

+314
-20
lines changed

11 files changed

+314
-20
lines changed

.github/workflows/ci.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,39 @@ jobs:
7070
foreman --version
7171
PATH=$PATH:~/.foreman/bin
7272
./scripts/end-to-end-tests.sh
73+
74+
kill-process-test-unix:
75+
strategy:
76+
matrix:
77+
os: [ubuntu-latest]
78+
79+
runs-on: ${{ matrix.os }}
80+
needs: build
81+
steps:
82+
- uses: actions/checkout@v2
83+
84+
- name: kill-process-test-unix
85+
shell: bash
86+
run: |
87+
cargo install --path .
88+
foreman --version
89+
PATH=$PATH:~/.foreman/bin
90+
./scripts/kill-process-test-unix.sh
91+
92+
kill-process-test-windows:
93+
strategy:
94+
matrix:
95+
os: [windows-latest]
96+
runs-on: ${{ matrix.os }}
97+
needs: build
98+
steps:
99+
- uses: actions/checkout@v2
100+
101+
- name: kill-process-test-windows
102+
shell: pwsh
103+
run: |
104+
cargo install --path .
105+
foreman --version
106+
$env:Path += '%USERPROFILE%/.foreman/bin'
107+
.\scripts\kill-process-test-windows.ps1
108+

.github/workflows/clabot.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ jobs:
99
call-clabot-workflow:
1010
uses: Roblox/cla-signature-bot/.github/workflows/clabot-workflow.yml@master
1111
with:
12-
whitelist: "LPGhatguy,ZoteTheMighty,cliffchapmanrbx,MagiMaster,MisterUncloaked,amatosov-rbx"
12+
whitelist: "LPGhatguy,ZoteTheMighty,cliffchapmanrbx,MagiMaster,MisterUncloaked,amatosov-rbx,afujiwara-roblox"
1313
use-remote-repo: true
1414
remote-repo-name: "roblox/cla-bot-store"
1515
secrets: inherit

Cargo.lock

Lines changed: 34 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ toml_edit = "0.14.4"
3434
urlencoding = "2.1.0"
3535
zip = "0.5"
3636

37+
[target.'cfg(windows)'.dependencies]
38+
command-group = "1.0.8"
39+
40+
[target.'cfg(unix)'.dependencies]
41+
tokio = { version = "1.18.2", features = ["macros", "sync", "process"] }
42+
signal-hook = "0.3.14"
43+
3744
[dev_dependencies]
3845
assert_cmd = "2.0.2"
3946
insta = "1.14"

scripts/kill-process-test-unix.sh

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#!/bin/bash
2+
3+
write_foreman_toml () {
4+
echo "writing foreman.toml"
5+
echo "[tools]" > foreman.toml
6+
echo "$2 = { $1 = \"$3\", version = \"=$4\" }" >> foreman.toml
7+
}
8+
9+
create_rojo_files() {
10+
echo "writing default.project.json"
11+
echo "{
12+
\"name\": \"test\",
13+
\"tree\": {
14+
\"\$path\": \"src\"
15+
}
16+
}" > default.project.json
17+
}
18+
19+
setup_rojo() {
20+
write_foreman_toml github rojo "rojo-rbx/rojo" "7.3.0"
21+
cargo run --release -- install
22+
create_rojo_files
23+
}
24+
25+
kill_process_and_check_delayed() {
26+
echo "waiting 5 seconds before killing rojo"
27+
sleep 5
28+
ps -ef | grep "rojo serve" | grep -v grep | awk '{print $2}' | xargs kill -INT
29+
echo "waiting 5 seconds for rojo to be killed"
30+
sleep 5
31+
check_killed_subprocess
32+
}
33+
34+
run_rojo_serve_and_kill_process() {
35+
setup_rojo
36+
(rojo serve default.project.json) & (kill_process_and_check_delayed)
37+
}
38+
39+
check_killed_subprocess(){
40+
echo "checking if process was killed properly"
41+
if ps -ef | grep "rojo" | grep -v grep
42+
then
43+
echo "rojo subprocess was not killed properly"
44+
rm foreman.toml
45+
rm default.project.json
46+
exit 1
47+
else
48+
echo "rojo subprocess was killed properly"
49+
rm foreman.toml
50+
rm default.project.json
51+
exit 0
52+
fi
53+
}
54+
55+
run_rojo_serve_and_kill_process

scripts/kill-process-test-windows.ps1

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
function write_foreman_toml($protocol, $tool, $source, $version) {
2+
Write-Output "writing foreman.toml"
3+
Write-Output "[tools]" | Out-File -FilePath foreman.toml -Encoding utf8
4+
Write-Output "$tool = { $protocol = `"$source`", version = `"=$version`" }" | Out-File -FilePath foreman.toml -append -Encoding utf8
5+
}
6+
7+
function create_rojo_files() {
8+
Write-Output "writing default.project.json"
9+
Write-Output "{
10+
`"name`": `"test`",
11+
`"tree`": {
12+
`"`$path`": `"src`"
13+
}
14+
}" | Out-File -FilePath default.project.json -Encoding utf8
15+
}
16+
17+
function setup_rojo() {
18+
write_foreman_toml github rojo "rojo-rbx/rojo" "7.3.0"
19+
cargo run --release -- install
20+
create_rojo_files
21+
}
22+
23+
function kill_process_and_check_delayed() {
24+
Write-Output "waiting 15 seconds before killing rojo"
25+
Start-Sleep 15
26+
Get-Process | Where-Object { $_.Name -eq "rojo" } | Select-Object -First 1 | Stop-Process
27+
Write-Output "waiting 5 seconds to stop rojo"
28+
Start-Sleep 5
29+
check_killed_subprocess
30+
}
31+
32+
function run_rojo_serve_and_kill_process() {
33+
setup_rojo
34+
Start-job -ScriptBlock { rojo serve default.project.json }
35+
kill_process_and_check_delayed
36+
}
37+
38+
function check_killed_subprocess() {
39+
Write-Output "Checking if process was killed properly"
40+
$rojo = Get-Process -name "rojo-rbx__rojo-7.3.0" -ErrorAction SilentlyContinue
41+
if ($rojo) {
42+
Write-Output "rojo subprocess was not killed properly"
43+
remove-item foreman.toml
44+
remove-item default.project.json
45+
exit 1
46+
}
47+
else {
48+
Write-Output "rojo subprocess was killed properly"
49+
remove-item foreman.toml
50+
remove-item default.project.json
51+
exit 0
52+
}
53+
}
54+
55+
run_rojo_serve_and_kill_process

src/main.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ mod config;
66
mod error;
77
mod fs;
88
mod paths;
9+
mod process;
910
mod tool_cache;
1011
mod tool_provider;
1112

12-
use std::{env, ffi::OsStr, process};
13+
use std::{env, ffi::OsStr};
1314

1415
use paths::ForemanPaths;
1516
use structopt::StructOpt;
@@ -67,7 +68,7 @@ impl ToolInvocation {
6768
let exit_code = tool_cache.run(tool_spec, &version, self.args)?;
6869

6970
if exit_code != 0 {
70-
process::exit(exit_code);
71+
std::process::exit(exit_code);
7172
}
7273

7374
Ok(())
@@ -116,7 +117,7 @@ fn main() {
116117

117118
fn exit_with_error(error: ForemanError) -> ! {
118119
eprintln!("{}", error);
119-
process::exit(1);
120+
std::process::exit(1);
120121
}
121122

122123
#[derive(Debug, StructOpt)]

src/process/mod.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//Orignal source from https://github.com/LPGhatguy/aftman/blob/d3f8d1fac4c89d9163f8f3a0c97fa33b91294fea/src/process/mod.rs
2+
3+
#[cfg(windows)]
4+
mod windows;
5+
6+
#[cfg(windows)]
7+
pub use windows::run;
8+
9+
#[cfg(unix)]
10+
mod unix;
11+
12+
#[cfg(unix)]
13+
pub use unix::run;

src/process/unix.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//Original source from https://github.com/LPGhatguy/aftman/blob/d3f8d1fac4c89d9163f8f3a0c97fa33b91294fea/src/process/unix.rs
2+
3+
//! On Unix, we use tokio to spawn processes so that we can listen for signals
4+
//! and wait for process completion at the same time.
5+
6+
use std::io::{Error, ErrorKind};
7+
use std::path::Path;
8+
use std::thread;
9+
10+
use signal_hook::consts::signal::{SIGABRT, SIGINT, SIGQUIT, SIGTERM};
11+
use signal_hook::iterator::Signals;
12+
use tokio::process::Command;
13+
use tokio::sync::oneshot;
14+
15+
pub fn run(exe_path: &Path, args: Vec<String>) -> Result<i32, Error> {
16+
let (kill_tx, kill_rx) = oneshot::channel();
17+
18+
// Spawn a thread dedicated to listening for signals and relaying them to
19+
// our async runtime.
20+
let (signal_thread, signal_handle) = {
21+
let mut signals = Signals::new(&[SIGABRT, SIGINT, SIGQUIT, SIGTERM]).unwrap();
22+
let signal_handle = signals.handle();
23+
24+
let thread = thread::spawn(move || {
25+
if let Some(signal) = signals.into_iter().next() {
26+
kill_tx.send(signal).ok();
27+
}
28+
});
29+
30+
(thread, signal_handle)
31+
};
32+
33+
let runtime = tokio::runtime::Builder::new_current_thread()
34+
.enable_io()
35+
.build()
36+
.map_err(|_| Error::new(ErrorKind::Other, "could not create tokio runtime"))?;
37+
38+
let _guard = runtime.enter();
39+
40+
let mut child = Command::new(exe_path).args(args).spawn().map_err(|_| {
41+
Error::new(
42+
ErrorKind::Other,
43+
format!("could not spawn {}", exe_path.display()),
44+
)
45+
})?;
46+
47+
let code = runtime.block_on(async move {
48+
tokio::select! {
49+
// If the child exits cleanly, we can return its exit code directly.
50+
// I wish everything were this tidy.
51+
status = child.wait() => {
52+
let code = status.ok().and_then(|s| s.code()).unwrap_or(1);
53+
signal_handle.close();
54+
signal_thread.join().unwrap();
55+
56+
code
57+
}
58+
59+
// If we received a signal while the process was running, murder it
60+
// and exit immediately with the correct error code.
61+
code = kill_rx => {
62+
child.kill().await.ok();
63+
signal_handle.close();
64+
signal_thread.join().unwrap();
65+
std::process::exit(128 + code.unwrap_or(0));
66+
}
67+
}
68+
});
69+
70+
Ok(code)
71+
}

src/process/windows.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//Original source from https://github.com/LPGhatguy/aftman/blob/d3f8d1fac4c89d9163f8f3a0c97fa33b91294fea/src/process/windows.rs
2+
3+
//! On Windows, we use command_group to spawn processes in a job group that will
4+
//! be automatically cleaned up when this process exits.
5+
6+
use std::io::{Error, ErrorKind};
7+
use std::path::Path;
8+
use std::process::Command;
9+
10+
use command_group::CommandGroup;
11+
12+
pub fn run(exe_path: &Path, args: Vec<String>) -> Result<i32, Error> {
13+
// On Windows, using a job group here will cause the subprocess to terminate
14+
// automatically when Aftman is terminated.
15+
let mut child = Command::new(exe_path)
16+
.args(args)
17+
.group_spawn()
18+
.map_err(|_| {
19+
Error::new(
20+
ErrorKind::Other,
21+
format!("Could not spawn {}", exe_path.display()),
22+
)
23+
})?;
24+
let status = child.wait()?;
25+
Ok(status.code().unwrap_or(1))
26+
}

0 commit comments

Comments
 (0)