Skip to content

Commit f619926

Browse files
: stdio redirection
Differential Revision: D80366985
1 parent ab3235d commit f619926

File tree

3 files changed

+143
-0
lines changed

3 files changed

+143
-0
lines changed

hyperactor/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ unicode-ident = "1.0.12"
6464

6565
[dev-dependencies]
6666
maplit = "1.0"
67+
tempfile = "3.15"
6768
timed_test = { version = "0.0.0", path = "../timed_test" }
6869
tracing-subscriber = { version = "0.3.19", features = ["chrono", "env-filter", "json", "local-time", "parking_lot", "registry"] }
6970
tracing-test = { version = "0.2.3", features = ["no-env-filter"] }

hyperactor/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ pub mod proc;
8686
pub mod reference;
8787
mod signal_handler;
8888
pub mod simnet;
89+
mod stdio_redirect;
8990
pub mod supervision;
9091
pub mod sync;
9192
/// Test utilities

hyperactor/src/stdio_redirect.rs

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
use std::os::fd::BorrowedFd;
10+
use std::fs::OpenOptions;
11+
use std::os::unix::io::AsRawFd;
12+
use nix::unistd::write;
13+
use nix::errno::Errno;
14+
use nix::libc::{STDOUT_FILENO, STDERR_FILENO};
15+
use anyhow::Context;
16+
17+
/// Checks if stdout is broken (e.g., due to parent process death).
18+
///
19+
/// Attempts a minimal write to stdout to detect if the pipe is
20+
/// broken. Returns true if stdout is unavailable (broken pipe or bad
21+
/// file descriptor).
22+
pub(crate) fn is_stdout_broken() -> bool {
23+
// SAFETY: `STDOUT_FILENO` (`1`) is a valid file descriptor by
24+
// definition. `BorrowedFd::borrow_raw` is safe here because we're
25+
// only using it for the duration of this function call and not
26+
// storing it.
27+
let fd = unsafe { BorrowedFd::borrow_raw(STDOUT_FILENO) };
28+
matches!(write(fd, b"\0"), Err(Errno::EPIPE | Errno::EBADF))
29+
}
30+
31+
/// Redirects stdout and stderr to the specified file.
32+
///
33+
/// The file is opened in append mode and created if it doesn't exist.
34+
/// This permanently modifies the process's stdio streams.
35+
pub(crate) fn redirect_stdio_to_file(path: &str) -> anyhow::Result<()> {
36+
let file = OpenOptions::new()
37+
.create(true)
38+
.append(true)
39+
.open(path)
40+
.with_context(|| format!("failed to open log file: {}", path))?;
41+
let raw_fd = file.as_raw_fd();
42+
// SAFETY: `raw_fd` is a valid file descriptor obtained from
43+
// `as_raw_fd()` on an open file. `STDOUT_FILENO` (`1`) and
44+
// `STDERR_FILENO` (`2`) are always valid file descriptor numbers.
45+
// `dup2` is safe to call with these valid file descriptors.
46+
unsafe {
47+
if nix::libc::dup2(raw_fd, STDOUT_FILENO) == -1 {
48+
anyhow::bail!("failed to redirect stdout: {}", std::io::Error::last_os_error());
49+
}
50+
if nix::libc::dup2(raw_fd, STDERR_FILENO) == -1 {
51+
anyhow::bail!("failed to redirect stderr: {}", std::io::Error::last_os_error());
52+
}
53+
}
54+
std::mem::forget(file);
55+
Ok(())
56+
}
57+
58+
/// Redirects stdout and stderr to a user-specific log file in /tmp.
59+
///
60+
/// Creates a log file at `/tmp/{user}/process-{pid}.log` and
61+
/// redirects stdio to it. The user directory is created if it doesn't
62+
/// exist.
63+
pub(crate) fn redirect_stdio_to_user_pid_file() -> anyhow::Result<()> {
64+
let user = std::env::var("USER")
65+
.unwrap_or_else(|_| "unknown".to_string());
66+
let pid = std::process::id();
67+
let log_dir = format!("/tmp/{}", user);
68+
std::fs::create_dir_all(&log_dir)?;
69+
let path = format!("{}/process-{}.log", log_dir, pid);
70+
redirect_stdio_to_file(&path)?;
71+
Ok(())
72+
}
73+
74+
/// Redirects stdio to a log file if stdout is broken.
75+
pub(crate) fn handle_broken_pipes() {
76+
if is_stdout_broken() {
77+
if let Ok(_) = redirect_stdio_to_user_pid_file() {
78+
tracing::info!("stdio for {} redirected due to broken pipe", std::process::id());
79+
}
80+
}
81+
}
82+
83+
#[cfg(test)]
84+
mod tests {
85+
use super::*;
86+
use tempfile::TempDir;
87+
use nix::libc::{STDOUT_FILENO, STDERR_FILENO};
88+
89+
struct StdioGuard {
90+
saved_stdout: i32,
91+
saved_stderr: i32,
92+
}
93+
94+
impl StdioGuard {
95+
fn new() -> Self {
96+
// SAFETY: `STDOUT_FILENO` (`1`) and `STDERR_FILENO` (`2`)
97+
// are always valid file descriptor numbers. `dup()` is
98+
// safe to call on these standard descriptors and will
99+
// return new file descriptors pointing to the same files.
100+
unsafe {
101+
let saved_stdout = nix::libc::dup(STDOUT_FILENO);
102+
let saved_stderr = nix::libc::dup(STDERR_FILENO);
103+
Self { saved_stdout, saved_stderr }
104+
}
105+
}
106+
}
107+
108+
impl Drop for StdioGuard {
109+
fn drop(&mut self) {
110+
// SAFETY: `saved_stdout` and `saved_stderr` are valid
111+
// file descriptors returned by `dup()` in `new()`.
112+
// `STDOUT_FILENO` and `STDERR_FILENO` are always valid
113+
// target descriptors. `dup2()` and `close()` are safe to
114+
// call with these valid fds.
115+
unsafe {
116+
nix::libc::dup2(self.saved_stdout, STDOUT_FILENO);
117+
nix::libc::dup2(self.saved_stderr, STDERR_FILENO);
118+
nix::libc::close(self.saved_stdout);
119+
nix::libc::close(self.saved_stderr);
120+
}
121+
}
122+
}
123+
124+
#[test]
125+
fn test_is_stdout_broken_with_working_stdout() {
126+
assert!(!is_stdout_broken());
127+
}
128+
129+
#[test]
130+
fn test_redirect_stdio_to_file_creates_file() {
131+
let _guard = StdioGuard::new();
132+
133+
let temp_dir = TempDir::new().unwrap();
134+
let log_path = temp_dir.path().join("test.log");
135+
let path_str = log_path.to_str().unwrap();
136+
137+
assert!(redirect_stdio_to_file(path_str).is_ok());
138+
assert!(log_path.exists());
139+
}
140+
141+
}

0 commit comments

Comments
 (0)