Skip to content

Commit c66be8c

Browse files
authored
stdbuf: use exec instead of forking (#9495)
1 parent 3efdb50 commit c66be8c

File tree

2 files changed

+89
-51
lines changed

2 files changed

+89
-51
lines changed

src/uu/stdbuf/src/stdbuf.rs

Lines changed: 19 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77

88
use clap::{Arg, ArgAction, ArgMatches, Command};
99
use std::ffi::OsString;
10+
#[cfg(unix)]
11+
use std::os::unix::process::CommandExt;
1012
use std::path::PathBuf;
1113
use std::process;
1214
use tempfile::TempDir;
1315
use tempfile::tempdir;
1416
use thiserror::Error;
15-
use uucore::error::{FromIo, UResult, USimpleError, UUsageError};
17+
use uucore::error::{UResult, USimpleError, UUsageError};
1618
use uucore::format_usage;
1719
use uucore::parser::parse_size::parse_size_u64;
1820
use uucore::translate;
@@ -208,55 +210,22 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
208210
set_command_env(&mut command, "_STDBUF_E", &options.stderr);
209211
command.args(command_params);
210212

211-
let mut process = match command.spawn() {
212-
Ok(p) => p,
213-
Err(e) => {
214-
return match e.kind() {
215-
std::io::ErrorKind::PermissionDenied => Err(USimpleError::new(
216-
126,
217-
translate!("stdbuf-error-permission-denied"),
218-
)),
219-
std::io::ErrorKind::NotFound => Err(USimpleError::new(
220-
127,
221-
translate!("stdbuf-error-no-such-file"),
222-
)),
223-
_ => Err(USimpleError::new(
224-
1,
225-
translate!("stdbuf-error-failed-to-execute", "error" => e),
226-
)),
227-
};
228-
}
229-
};
230-
231-
let status = process.wait().map_err_context(String::new)?;
232-
match status.code() {
233-
Some(i) => {
234-
if i == 0 {
235-
Ok(())
236-
} else {
237-
Err(i.into())
238-
}
239-
}
240-
None => {
241-
#[cfg(unix)]
242-
{
243-
use std::os::unix::process::ExitStatusExt;
244-
let signal_msg = status
245-
.signal()
246-
.map_or_else(|| "unknown".to_string(), |s| s.to_string());
247-
Err(USimpleError::new(
248-
1,
249-
translate!("stdbuf-error-killed-by-signal", "signal" => signal_msg),
250-
))
251-
}
252-
#[cfg(not(unix))]
253-
{
254-
Err(USimpleError::new(
255-
1,
256-
"process terminated abnormally".to_string(),
257-
))
258-
}
259-
}
213+
// Replace the current process with the target program (no fork) using exec.
214+
let e = command.exec();
215+
// exec() only returns if there was an error
216+
match e.kind() {
217+
std::io::ErrorKind::PermissionDenied => Err(USimpleError::new(
218+
126,
219+
translate!("stdbuf-error-permission-denied"),
220+
)),
221+
std::io::ErrorKind::NotFound => Err(USimpleError::new(
222+
127,
223+
translate!("stdbuf-error-no-such-file"),
224+
)),
225+
_ => Err(USimpleError::new(
226+
1,
227+
translate!("stdbuf-error-failed-to-execute", "error" => e),
228+
)),
260229
}
261230
}
262231

tests/by-util/test_stdbuf.rs

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//
33
// For the full copyright and license information, please view the LICENSE
44
// file that was distributed with this source code.
5-
// spell-checker:ignore dyld dylib setvbuf
5+
// spell-checker:ignore cmdline dyld dylib PDEATHSIG setvbuf
66
#[cfg(target_os = "linux")]
77
use uutests::at_and_ucmd;
88
use uutests::new_ucmd;
@@ -276,3 +276,72 @@ fn test_stdbuf_non_utf8_paths() {
276276
.succeeds()
277277
.stdout_is("test content for stdbuf\n");
278278
}
279+
280+
#[test]
281+
#[cfg(target_os = "linux")]
282+
fn test_stdbuf_no_fork_regression() {
283+
// Regression test for issue #9066: https://github.com/uutils/coreutils/issues/9066
284+
// The original stdbuf implementation used fork+spawn which broke signal handling
285+
// and PR_SET_PDEATHSIG. This test verifies that stdbuf uses exec() instead.
286+
// With fork: stdbuf process would remain visible in process list
287+
// With exec: stdbuf process is replaced by target command (GNU compatible)
288+
289+
use std::process::{Command, Stdio};
290+
use std::thread;
291+
use std::time::Duration;
292+
293+
let scene = TestScenario::new(util_name!());
294+
295+
// Start stdbuf with a long-running command
296+
let mut child = Command::new(&scene.bin_path)
297+
.args(["stdbuf", "-o0", "sleep", "3"])
298+
.stdout(Stdio::null())
299+
.stderr(Stdio::null())
300+
.spawn()
301+
.expect("Failed to start stdbuf");
302+
303+
let child_pid = child.id();
304+
305+
// Poll until exec happens or timeout
306+
let cmdline_path = format!("/proc/{child_pid}/cmdline");
307+
let timeout = Duration::from_secs(2);
308+
let poll_interval = Duration::from_millis(10);
309+
let start_time = std::time::Instant::now();
310+
311+
let command_name = loop {
312+
if start_time.elapsed() > timeout {
313+
child.kill().ok();
314+
panic!("TIMEOUT: Process {child_pid} did not respond within {timeout:?}");
315+
}
316+
317+
if let Ok(cmdline) = std::fs::read_to_string(&cmdline_path) {
318+
let cmd_parts: Vec<&str> = cmdline.split('\0').collect();
319+
let name = cmd_parts.first().map_or("", |v| v);
320+
321+
// Wait for exec to complete (process name changes from original binary to target)
322+
// Handle both multicall binary (coreutils) and individual utilities (stdbuf)
323+
if !name.contains("coreutils") && !name.contains("stdbuf") && !name.is_empty() {
324+
break name.to_string();
325+
}
326+
}
327+
328+
thread::sleep(poll_interval);
329+
};
330+
331+
// The loop already waited for exec (no longer original binary), so this should always pass
332+
// But keep the assertion as a safety check and clear documentation
333+
assert!(
334+
!command_name.contains("coreutils") && !command_name.contains("stdbuf"),
335+
"REGRESSION: Process {child_pid} is still original binary (coreutils or stdbuf) - fork() used instead of exec()"
336+
);
337+
338+
// Ensure we're running the expected target command
339+
assert!(
340+
command_name.contains("sleep"),
341+
"Expected 'sleep' command at PID {child_pid}, got: {command_name}"
342+
);
343+
344+
// Cleanup
345+
child.kill().ok();
346+
child.wait().ok();
347+
}

0 commit comments

Comments
 (0)