Skip to content

Commit 81acbcb

Browse files
committed
new lint: zombie_processes
1 parent 797d50d commit 81acbcb

14 files changed

+491
-3
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -5876,6 +5876,7 @@ Released 2018-09-13
58765876
[`zero_repeat_side_effects`]: https://rust-lang.github.io/rust-clippy/master/index.html#zero_repeat_side_effects
58775877
[`zero_sized_map_values`]: https://rust-lang.github.io/rust-clippy/master/index.html#zero_sized_map_values
58785878
[`zero_width_space`]: https://rust-lang.github.io/rust-clippy/master/index.html#zero_width_space
5879+
[`zombie_processes`]: https://rust-lang.github.io/rust-clippy/master/index.html#zombie_processes
58795880
[`zst_offset`]: https://rust-lang.github.io/rust-clippy/master/index.html#zst_offset
58805881
<!-- end autogenerated links to lint list -->
58815882
<!-- begin autogenerated links to configuration documentation -->

clippy_dev/src/serve.rs

+2-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ pub fn run(port: u16, lint: Option<&String>) -> ! {
2525
}
2626
if let Some(url) = url.take() {
2727
thread::spawn(move || {
28-
Command::new("python3")
28+
let mut child = Command::new("python3")
2929
.arg("-m")
3030
.arg("http.server")
3131
.arg(port.to_string())
@@ -36,6 +36,7 @@ pub fn run(port: u16, lint: Option<&String>) -> ! {
3636
thread::sleep(Duration::from_millis(500));
3737
// Launch browser after first export.py has completed and http.server is up
3838
let _result = opener::open(url);
39+
child.wait().unwrap();
3940
});
4041
}
4142
thread::sleep(Duration::from_millis(1000));

clippy_lints/src/declared_lints.rs

+1
Original file line numberDiff line numberDiff line change
@@ -758,4 +758,5 @@ pub(crate) static LINTS: &[&crate::LintInfo] = &[
758758
crate::zero_div_zero::ZERO_DIVIDED_BY_ZERO_INFO,
759759
crate::zero_repeat_side_effects::ZERO_REPEAT_SIDE_EFFECTS_INFO,
760760
crate::zero_sized_map_values::ZERO_SIZED_MAP_VALUES_INFO,
761+
crate::zombie_processes::ZOMBIE_PROCESSES_INFO,
761762
];

clippy_lints/src/lib.rs

+2
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,7 @@ mod write;
381381
mod zero_div_zero;
382382
mod zero_repeat_side_effects;
383383
mod zero_sized_map_values;
384+
mod zombie_processes;
384385
// end lints modules, do not remove this comment, it’s used in `update_lints`
385386

386387
use clippy_config::{get_configuration_metadata, Conf};
@@ -1131,6 +1132,7 @@ pub fn register_lints(store: &mut rustc_lint::LintStore, conf: &'static Conf) {
11311132
store.register_late_pass(|_| Box::new(zero_repeat_side_effects::ZeroRepeatSideEffects));
11321133
store.register_late_pass(|_| Box::new(manual_unwrap_or_default::ManualUnwrapOrDefault));
11331134
store.register_late_pass(|_| Box::new(integer_division_remainder_used::IntegerDivisionRemainderUsed));
1135+
store.register_late_pass(|_| Box::new(zombie_processes::ZombieProcesses));
11341136
// add lints here, do not remove this comment, it's used in `new_lint`
11351137
}
11361138

clippy_lints/src/zombie_processes.rs

+264
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
use clippy_utils::diagnostics::span_lint_and_then;
2+
use clippy_utils::visitors::for_each_local_use_after_expr;
3+
use clippy_utils::{fn_def_id, get_enclosing_block, match_any_def_paths, match_def_path, paths};
4+
use rustc_ast::Mutability;
5+
use rustc_errors::Applicability;
6+
use rustc_hir::intravisit::{walk_expr, Visitor};
7+
use rustc_hir::{Expr, ExprKind, HirId, Local, Node, PatKind, Stmt, StmtKind};
8+
use rustc_lint::{LateContext, LateLintPass};
9+
use rustc_session::declare_lint_pass;
10+
use rustc_span::sym;
11+
use std::ops::ControlFlow;
12+
13+
declare_clippy_lint! {
14+
/// ### What it does
15+
/// Looks for code that spawns a process but never calls `wait()` on the child.
16+
///
17+
/// ### Why is this bad?
18+
/// As explained in the [standard library documentation](https://doc.rust-lang.org/stable/std/process/struct.Child.html#warning),
19+
/// calling `wait()` is necessary on Unix platforms to properly release all OS resources associated with the process.
20+
/// Not doing so will effectively leak process IDs and/or other limited global resources,
21+
/// which can eventually lead to resource exhaustion, so it's recommended to call `wait()` in long-running applications.
22+
/// Such processes are called "zombie processes".
23+
///
24+
/// ### Example
25+
/// ```rust
26+
/// use std::process::Command;
27+
///
28+
/// let _child = Command::new("ls").spawn().expect("failed to execute child");
29+
/// ```
30+
/// Use instead:
31+
/// ```rust
32+
/// use std::process::Command;
33+
///
34+
/// let mut child = Command::new("ls").spawn().expect("failed to execute child");
35+
/// child.wait().expect("failed to wait on child");
36+
/// ```
37+
#[clippy::version = "1.74.0"]
38+
pub ZOMBIE_PROCESSES,
39+
suspicious,
40+
"not waiting on a spawned child process"
41+
}
42+
declare_lint_pass!(ZombieProcesses => [ZOMBIE_PROCESSES]);
43+
44+
impl<'tcx> LateLintPass<'tcx> for ZombieProcesses {
45+
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) {
46+
if let ExprKind::Call(..) | ExprKind::MethodCall(..) = expr.kind
47+
&& let Some(child_adt) = cx.typeck_results().expr_ty(expr).ty_adt_def()
48+
&& match_def_path(cx, child_adt.did(), &paths::CHILD)
49+
{
50+
match cx.tcx.parent_hir_node(expr.hir_id) {
51+
Node::Local(local) if let PatKind::Binding(_, local_id, ..) = local.pat.kind => {
52+
// If the `Child` is assigned to a variable, we want to check if the code never calls `.wait()`
53+
// on the variable, and lint if not.
54+
// This is difficult to do because expressions can be arbitrarily complex
55+
// and the variable can "escape" in various ways, e.g. you can take a `&mut` to the variable
56+
// and call `.wait()` through that, or pass it to another function...
57+
// So instead we do the inverse, checking if all uses are either:
58+
// - a field access (`child.{stderr,stdin,stdout}`)
59+
// - calling `id` or `kill`
60+
// - no use at all (e.g. `let _x = child;`)
61+
// - taking a shared reference (`&`), `wait()` can't go through that
62+
// None of these are sufficient to prevent zombie processes
63+
// Doing it like this means more FNs, but FNs are better than FPs.
64+
let has_no_wait = for_each_local_use_after_expr(cx, local_id, expr.hir_id, |expr| {
65+
match cx.tcx.parent_hir_node(expr.hir_id) {
66+
Node::Stmt(Stmt {
67+
kind: StmtKind::Semi(_),
68+
..
69+
}) => ControlFlow::Continue(()),
70+
Node::Expr(expr) if let ExprKind::Field(..) = expr.kind => ControlFlow::Continue(()),
71+
Node::Expr(expr) if let ExprKind::AddrOf(_, Mutability::Not, _) = expr.kind => {
72+
ControlFlow::Continue(())
73+
},
74+
Node::Expr(expr)
75+
if let Some(fn_did) = fn_def_id(cx, expr)
76+
&& match_any_def_paths(cx, fn_did, &[&paths::CHILD_ID, &paths::CHILD_KILL])
77+
.is_some() =>
78+
{
79+
ControlFlow::Continue(())
80+
},
81+
82+
// Conservatively assume that all other kinds of nodes call `.wait()` somehow.
83+
_ => ControlFlow::Break(()),
84+
}
85+
})
86+
.is_continue();
87+
88+
// If it does have a `wait()` call, we're done. Don't lint.
89+
if !has_no_wait {
90+
return;
91+
}
92+
93+
// Don't emit a suggestion since the binding is used later
94+
check(cx, expr, local.hir_id, false);
95+
},
96+
Node::Local(&Local { pat, hir_id, .. }) if let PatKind::Wild = pat.kind => {
97+
// `let _ = child;`, also dropped immediately without `wait()`ing
98+
check(cx, expr, hir_id, true);
99+
},
100+
Node::Stmt(&Stmt {
101+
kind: StmtKind::Semi(_),
102+
hir_id,
103+
..
104+
}) => {
105+
// Immediately dropped. E.g. `std::process::Command::new("echo").spawn().unwrap();`
106+
check(cx, expr, hir_id, true);
107+
},
108+
_ => {},
109+
}
110+
}
111+
}
112+
}
113+
114+
/// This function has shared logic between the different kinds of nodes that can trigger the lint.
115+
///
116+
/// In particular, `let <binding> = <expr that spawns child>;` requires some custom additional logic
117+
/// such as checking that the binding is not used in certain ways, which isn't necessary for
118+
/// `let _ = <expr that spawns child>;`.
119+
///
120+
/// This checks if the program doesn't unconditionally exit after the spawn expression and that it
121+
/// isn't the last statement of the program.
122+
fn check<'tcx>(cx: &LateContext<'tcx>, spawn_expr: &'tcx Expr<'tcx>, node_id: HirId, emit_suggestion: bool) {
123+
let Some(block) = get_enclosing_block(cx, spawn_expr.hir_id) else {
124+
return;
125+
};
126+
127+
let mut vis = ExitPointFinder {
128+
cx,
129+
state: ExitPointState::LookingForSpawnExpr(spawn_expr.hir_id),
130+
};
131+
vis.visit_block(block);
132+
133+
// Visitor found an unconditional `exit()` call, so don't lint.
134+
if let ExitPointState::ExitFound = vis.state {
135+
return;
136+
}
137+
138+
// This might be the last effective node of the program (main function).
139+
// There's no need to lint in that case either, as this is basically equivalent to calling `exit()`
140+
if is_last_node_in_main(cx, node_id) {
141+
return;
142+
}
143+
144+
span_lint_and_then(
145+
cx,
146+
ZOMBIE_PROCESSES,
147+
spawn_expr.span,
148+
"spawned process is never `wait()`ed on",
149+
|diag| {
150+
if emit_suggestion {
151+
diag.span_suggestion(
152+
spawn_expr.span.shrink_to_hi(),
153+
"try",
154+
".wait()",
155+
Applicability::MaybeIncorrect,
156+
);
157+
} else {
158+
diag.note("consider calling `.wait()`");
159+
}
160+
161+
diag.note("not doing so might leave behind zombie processes")
162+
.note("see https://doc.rust-lang.org/stable/std/process/struct.Child.html#warning");
163+
},
164+
);
165+
}
166+
167+
/// The hir id id may either correspond to a `Local` or `Stmt`, depending on how we got here.
168+
/// This function gets the enclosing function, checks if it's `main` and if so,
169+
/// check if the last statement modulo blocks is the given id.
170+
fn is_last_node_in_main(cx: &LateContext<'_>, id: HirId) -> bool {
171+
let hir = cx.tcx.hir();
172+
let body_owner = hir.enclosing_body_owner(id);
173+
let enclosing_body = hir.body(hir.body_owned_by(body_owner));
174+
175+
if let Some((main_def_id, _)) = cx.tcx.entry_fn(())
176+
&& main_def_id == body_owner.to_def_id()
177+
&& let ExprKind::Block(block, _) = &enclosing_body.value.peel_blocks().kind
178+
&& let [.., stmt] = block.stmts
179+
{
180+
matches!(stmt.kind, StmtKind::Let(local) if local.hir_id == id)
181+
|| matches!(stmt.kind, StmtKind::Semi(..) if stmt.hir_id == id)
182+
} else {
183+
false
184+
}
185+
}
186+
187+
/// Checks if the given expression exits the process.
188+
fn is_exit_expression(cx: &LateContext<'_>, expr: &Expr<'_>) -> bool {
189+
fn_def_id(cx, expr).is_some_and(|fn_did| {
190+
cx.tcx.is_diagnostic_item(sym::process_exit, fn_did) || match_def_path(cx, fn_did, &paths::ABORT)
191+
})
192+
}
193+
194+
#[derive(Debug)]
195+
enum ExitPointState {
196+
/// Still walking up to the expression that initiated the visitor.
197+
LookingForSpawnExpr(HirId),
198+
/// We're inside of a control flow construct (e.g. `if`, `match`, `loop`)
199+
/// Within this, we shouldn't accept any `exit()` calls in here, but we can leave all of these
200+
/// constructs later and still continue looking for an `exit()` call afterwards. Example:
201+
/// ```ignore
202+
/// Command::new("").spawn().unwrap();
203+
///
204+
/// if true { // depth=1
205+
/// if true { // depth=2
206+
/// match () { // depth=3
207+
/// () => loop { // depth=4
208+
///
209+
/// std::process::exit();
210+
/// ^^^^^^^^^^^^^^^^^^^^^ conditional exit call, ignored
211+
///
212+
/// } // depth=3
213+
/// } // depth=2
214+
/// } // depth=1
215+
/// } // depth=0
216+
///
217+
/// std::process::exit();
218+
/// ^^^^^^^^^^^^^^^^^^^^^ this exit call is accepted because we're now unconditionally calling it
219+
/// ```
220+
/// We can only get into this state from `NoExit`.
221+
InControlFlow { depth: u32 },
222+
/// No exit call found yet, but looking for one.
223+
NoExit,
224+
/// Found an expression that exits the process.
225+
ExitFound,
226+
}
227+
228+
fn expr_enters_control_flow(expr: &Expr<'_>) -> bool {
229+
matches!(expr.kind, ExprKind::If(..) | ExprKind::Match(..) | ExprKind::Loop(..))
230+
}
231+
232+
struct ExitPointFinder<'a, 'tcx> {
233+
state: ExitPointState,
234+
cx: &'a LateContext<'tcx>,
235+
}
236+
237+
impl<'a, 'tcx> Visitor<'tcx> for ExitPointFinder<'a, 'tcx> {
238+
fn visit_expr(&mut self, expr: &'tcx Expr<'tcx>) {
239+
match self.state {
240+
ExitPointState::LookingForSpawnExpr(id) if expr.hir_id == id => {
241+
self.state = ExitPointState::NoExit;
242+
walk_expr(self, expr);
243+
},
244+
ExitPointState::NoExit if expr_enters_control_flow(expr) => {
245+
self.state = ExitPointState::InControlFlow { depth: 1 };
246+
walk_expr(self, expr);
247+
if let ExitPointState::InControlFlow { .. } = self.state {
248+
self.state = ExitPointState::NoExit;
249+
}
250+
},
251+
ExitPointState::NoExit if is_exit_expression(self.cx, expr) => self.state = ExitPointState::ExitFound,
252+
ExitPointState::InControlFlow { ref mut depth } if expr_enters_control_flow(expr) => {
253+
*depth += 1;
254+
walk_expr(self, expr);
255+
match self.state {
256+
ExitPointState::InControlFlow { depth: 1 } => self.state = ExitPointState::NoExit,
257+
ExitPointState::InControlFlow { ref mut depth } => *depth -= 1,
258+
_ => {},
259+
}
260+
},
261+
_ => {},
262+
}
263+
}
264+
}

clippy_utils/src/paths.rs

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//! Whenever possible, please consider diagnostic items over hardcoded paths.
55
//! See <https://github.com/rust-lang/rust-clippy/issues/5393> for more information.
66
7+
pub const ABORT: [&str; 3] = ["std", "process", "abort"];
78
pub const APPLICABILITY: [&str; 2] = ["rustc_lint_defs", "Applicability"];
89
pub const APPLICABILITY_VALUES: [[&str; 3]; 4] = [
910
["rustc_lint_defs", "Applicability", "Unspecified"],
@@ -26,6 +27,9 @@ pub const CORE_RESULT_OK_METHOD: [&str; 4] = ["core", "result", "Result", "ok"];
2627
pub const CSTRING_AS_C_STR: [&str; 5] = ["alloc", "ffi", "c_str", "CString", "as_c_str"];
2728
pub const EARLY_CONTEXT: [&str; 2] = ["rustc_lint", "EarlyContext"];
2829
pub const EARLY_LINT_PASS: [&str; 3] = ["rustc_lint", "passes", "EarlyLintPass"];
30+
pub const CHILD: [&str; 3] = ["std", "process", "Child"];
31+
pub const CHILD_ID: [&str; 4] = ["std", "process", "Child", "id"];
32+
pub const CHILD_KILL: [&str; 4] = ["std", "process", "Child", "kill"];
2933
pub const F32_EPSILON: [&str; 4] = ["core", "f32", "<impl f32>", "EPSILON"];
3034
pub const F64_EPSILON: [&str; 4] = ["core", "f64", "<impl f64>", "EPSILON"];
3135
pub const FILE_OPTIONS: [&str; 4] = ["std", "fs", "File", "options"];

tests/ui/suspicious_command_arg_space.fixed

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
#![allow(clippy::zombie_processes)]
12
fn main() {
23
// Things it should warn about:
34
std::process::Command::new("echo").args(["-n", "hello"]).spawn().unwrap();

tests/ui/suspicious_command_arg_space.rs

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
#![allow(clippy::zombie_processes)]
12
fn main() {
23
// Things it should warn about:
34
std::process::Command::new("echo").arg("-n hello").spawn().unwrap();

tests/ui/suspicious_command_arg_space.stderr

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
error: single argument that looks like it should be multiple arguments
2-
--> tests/ui/suspicious_command_arg_space.rs:3:44
2+
--> tests/ui/suspicious_command_arg_space.rs:4:44
33
|
44
LL | std::process::Command::new("echo").arg("-n hello").spawn().unwrap();
55
| ^^^^^^^^^^
@@ -12,7 +12,7 @@ LL | std::process::Command::new("echo").args(["-n", "hello"]).spawn().unwrap
1212
| ~~~~ ~~~~~~~~~~~~~~~
1313

1414
error: single argument that looks like it should be multiple arguments
15-
--> tests/ui/suspicious_command_arg_space.rs:6:43
15+
--> tests/ui/suspicious_command_arg_space.rs:7:43
1616
|
1717
LL | std::process::Command::new("cat").arg("--number file").spawn().unwrap();
1818
| ^^^^^^^^^^^^^^^

0 commit comments

Comments
 (0)