Skip to content

Commit f55daeb

Browse files
committed
0.7.0
1 parent 4916145 commit f55daeb

8 files changed

Lines changed: 184 additions & 77 deletions

File tree

.travis.yml

Lines changed: 0 additions & 19 deletions
This file was deleted.

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,34 @@
1+
## 0.7.0
2+
3+
### Fixed
4+
* **`close()` no longer retries on `EINTR`** — Previously, the internal `close_retry` helper
5+
looped on `EINTR`, which is unsafe on all modern Unixes:
6+
- **Linux**: `close()` always releases the fd *before* returning `EINTR`.
7+
Retrying can close an unrelated fd opened by another thread between attempts.
8+
- **FreeBSD / macOS / other BSDs**: The fd state after `close()` + `EINTR` is
9+
*unspecified* per POSIX 2008+ (Austin Group defect 529), so retrying is equally dangerous.
10+
- The function has been renamed from `close_retry` to `close_once` and now calls `close()`
11+
exactly once, treating both `EINTR` and `EBADF` as success — the same approach used by
12+
Rust's `std::fs::File::drop()`, Go's runtime, and glibc internals.
13+
- **Note**: `open()` and `dup2()` in `redirect_stdio()` still correctly retry on `EINTR`,
14+
as those calls do *not* release resources on interruption.
15+
16+
### Improved
17+
* **`daemon()` return value documentation** — Made it prominent that `daemon()` only ever
18+
returns `Ok(Fork::Child)` or `Err(...)` to the caller; `Ok(Fork::Parent(_))` is never
19+
returned because both parent processes call `_exit(0)` internally. Added recommended
20+
`if let` and `match` usage patterns to the doc comment. Updated `#[must_use]` message
21+
to reflect this guarantee.
22+
23+
### Code Quality
24+
* Added `test_daemon_never_returns_parent` integration test confirming `Fork::Parent` is unreachable
25+
* Renamed `test_close_retry_ok_and_ebadf` to `test_close_once_ok_and_ebadf`
26+
* Replaced fragile `tty` command string-matching in `test_daemon_no_controlling_terminal` with
27+
portable `open("/dev/tty")` check (works reliably across Linux, macOS, and BSDs)
28+
* Replaced fixed 100ms sleep in `test_getppid_after_parent_exits` with a retry loop (up to 1s),
29+
preventing flaky failures on slow CI systems
30+
* Updated tests README to reflect new and renamed tests
31+
132
## 0.6.0
233

334
### Breaking Changes

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "fork"
3-
version = "0.6.0"
3+
version = "0.7.0"
44
authors = ["Nicolas Embriz <nbari@tequila.io>"]
55
description = "Library for creating a new process detached from the controlling terminal (daemon)"
66
documentation = "https://docs.rs/fork/latest/fork/"

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Add `fork` to your `Cargo.toml`:
3030

3131
```toml
3232
[dependencies]
33-
fork = "0.6.0"
33+
fork = "0.7.0"
3434
```
3535

3636
Or use cargo-add:

src/lib.rs

Lines changed: 69 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -148,27 +148,28 @@ pub enum Fork {
148148
Child,
149149
}
150150

151-
/// Close a file descriptor, retrying on `EINTR` and treating `EBADF` as success.
151+
/// Close a file descriptor without retrying on `EINTR`, treating `EBADF` as success.
152+
///
153+
/// On Linux, `close()` always releases the fd before returning `EINTR`, so retrying
154+
/// would risk closing an unrelated fd opened by another thread. On FreeBSD, macOS,
155+
/// and other Unixes the fd state after `EINTR` is unspecified (POSIX 2008+, Austin
156+
/// Group defect 529), making retry equally unsafe. The safe portable behavior is to
157+
/// call `close()` exactly once and treat `EINTR` as success — the same approach used
158+
/// by Rust's stdlib, Go's runtime, and glibc internals.
152159
#[inline]
153-
fn close_retry(fd: libc::c_int) -> io::Result<()> {
154-
loop {
155-
let res = unsafe { libc::close(fd) };
156-
if res == 0 {
157-
return Ok(());
158-
}
159-
160-
let err = io::Error::last_os_error();
161-
162-
if err.kind() == io::ErrorKind::Interrupted {
163-
continue;
164-
}
160+
fn close_once(fd: libc::c_int) -> io::Result<()> {
161+
let res = unsafe { libc::close(fd) };
162+
if res == 0 {
163+
return Ok(());
164+
}
165165

166-
if err.raw_os_error() == Some(libc::EBADF) {
167-
return Ok(());
168-
}
166+
let err = io::Error::last_os_error();
169167

170-
return Err(err);
168+
if err.kind() == io::ErrorKind::Interrupted || err.raw_os_error() == Some(libc::EBADF) {
169+
return Ok(());
171170
}
171+
172+
Err(err)
172173
}
173174

174175
impl Fork {
@@ -307,7 +308,7 @@ pub fn chdir() -> io::Result<()> {
307308
/// ```
308309
pub fn close_fd() -> io::Result<()> {
309310
for fd in 0..=2 {
310-
close_retry(fd)?;
311+
close_once(fd)?;
311312
}
312313

313314
Ok(())
@@ -377,7 +378,7 @@ pub fn redirect_stdio() -> io::Result<()> {
377378
// Only close null_fd if it's > 2 (not one of the stdio fds we're duplicating to)
378379
// If null_fd was 0, 1, or 2, we're in the process of duping it, so don't close
379380
if null_fd > 2 {
380-
let _ = close_retry(null_fd);
381+
let _ = close_once(null_fd);
381382
}
382383
return Err(err);
383384
}
@@ -388,7 +389,7 @@ pub fn redirect_stdio() -> io::Result<()> {
388389
// Close the extra fd if it's > 2
389390
// (if null_fd was 0, 1, or 2, it's now dup'd to all three, so don't close)
390391
if null_fd > 2 {
391-
close_retry(null_fd)?;
392+
close_once(null_fd)?;
392393
}
393394

394395
Ok(())
@@ -710,12 +711,51 @@ pub fn getppid() -> libc::pid_t {
710711
/// * `nochdir = false`, changes the current working directory to the root (`/`).
711712
/// * `noclose = false`, redirects stdin, stdout, and stderr to `/dev/null`
712713
///
714+
/// # Return Value
715+
///
716+
/// This function only ever returns in the **daemon (grandchild) process**:
717+
///
718+
/// - `Ok(Fork::Child)` — You are the daemon. The original process and the
719+
/// intermediate child have already exited via `_exit(0)`.
720+
/// - `Err(...)` — A system call failed before the daemon could be created.
721+
///
722+
/// **`Ok(Fork::Parent(_))` is never returned** because both parent processes
723+
/// call `_exit(0)` internally. You do not need to match on it:
724+
///
725+
/// ```no_run
726+
/// use fork::{daemon, Fork};
727+
///
728+
/// // Recommended: use `if let` — no dead Parent arm needed
729+
/// if let Ok(Fork::Child) = daemon(false, false) {
730+
/// // Only the daemon reaches here
731+
/// loop {
732+
/// // daemon work…
733+
/// std::thread::sleep(std::time::Duration::from_secs(60));
734+
/// }
735+
/// }
736+
/// ```
737+
///
738+
/// If you prefer `match` for explicit error handling, mark the parent arm
739+
/// unreachable:
740+
///
741+
/// ```no_run
742+
/// use fork::{daemon, Fork};
743+
///
744+
/// match daemon(false, false) {
745+
/// Ok(Fork::Child) => {
746+
/// // daemon work…
747+
/// }
748+
/// Ok(Fork::Parent(_)) => unreachable!("daemon() exits both parent processes"),
749+
/// Err(err) => eprintln!("daemon failed: {err}"),
750+
/// }
751+
/// ```
752+
///
713753
/// # Implementation (double-fork)
714754
///
715-
/// 1. **First fork** - Parent exits immediately.
716-
/// 2. **Session setup** - Child calls `setsid()`, optionally `chdir("/")`, and optionally redirects stdio.
717-
/// 3. **Second (double) fork** - Session-leader child exits immediately.
718-
/// 4. **Daemon continues** - Grandchild (daemon) runs with no controlling terminal.
755+
/// 1. **First fork** Parent calls `_exit(0)` immediately.
756+
/// 2. **Session setup** Child calls `setsid()`, optionally `chdir("/")`, and optionally redirects stdio.
757+
/// 3. **Second (double) fork** Session-leader child calls `_exit(0)` immediately.
758+
/// 4. **Daemon continues** Grandchild (daemon) runs with no controlling terminal.
719759
///
720760
/// # Behavior Change in v0.4.0
721761
///
@@ -750,7 +790,7 @@ pub fn getppid() -> libc::pid_t {
750790
/// .expect("failed to execute process");
751791
///}
752792
///```
753-
#[must_use = "daemon result must be checked to determine if this is the daemon process"]
793+
#[must_use = "daemon() only returns Ok(Fork::Child) in the daemon process; check the result"]
754794
pub fn daemon(nochdir: bool, noclose: bool) -> io::Result<Fork> {
755795
// 1. First fork: detach from original parent; parent exits immediately
756796
match fork()? {
@@ -1119,19 +1159,19 @@ mod tests {
11191159
}
11201160

11211161
#[test]
1122-
fn test_close_retry_ok_and_ebadf() {
1162+
fn test_close_once_ok_and_ebadf() {
11231163
// Create a pipe to obtain valid fds
11241164
let mut fds = [0; 2];
11251165
assert_eq!(unsafe { libc::pipe(&raw mut fds[0]) }, 0);
11261166

1127-
// Close write end via close_retry (should succeed)
1128-
close_retry(fds[1]).expect("close_retry should close valid fd");
1167+
// Close write end via close_once (should succeed)
1168+
close_once(fds[1]).expect("close_once should close valid fd");
11291169

11301170
// Wrap read end in File to close it once; drop immediately
11311171
let read_fd = fds[0];
11321172
unsafe { std::fs::File::from_raw_fd(read_fd) };
11331173

11341174
// Second close should be treated as success (EBADF path)
1135-
close_retry(read_fd).expect("EBADF should be treated as success");
1175+
close_once(read_fd).expect("EBADF should be treated as success");
11361176
}
11371177
}

tests/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Tests include:
3333
- **test_daemon_process_group** - Verifies daemon process group structure (double-fork pattern)
3434
- **test_daemon_with_command_execution** - Tests command execution in daemon context
3535
- **test_daemon_no_controlling_terminal** - Verifies daemon has no controlling terminal
36+
- **test_daemon_never_returns_parent** - Verifies daemon() only returns `Fork::Child`, never `Fork::Parent`
3637

3738
### `fork_tests.rs` - Fork Functionality Tests
3839

@@ -72,7 +73,7 @@ Tests include:
7273
- **test_redirect_stdio_error_handling** - Propagates errors from failed redirection
7374
- **test_fd_reuse_corruption_scenario** - Demonstrates corruption risk when closing stdio
7475
- **test_close_fd_allows_fd_reuse** - Shows fd reuse when stdio is closed (expected panic)
75-
- **test_close_retry_ok_and_ebadf** - Unit-level check that repeated closes handle EINTR/EBADF gracefully
76+
- **test_close_once_ok_and_ebadf** - Unit-level check that close handles EBADF gracefully
7677

7778
### `waitpid_tests.rs` - Waitpid Comprehensive Tests
7879

@@ -280,7 +281,7 @@ Integration tests provide coverage for:
280281
tests/
281282
├── common/
282283
│ └── mod.rs # Shared utilities (51 lines)
283-
├── daemon_tests.rs # Daemon tests (271 lines, 5 tests)
284+
├── daemon_tests.rs # Daemon tests (271 lines, 6 tests)
284285
├── fork_tests.rs # Fork tests (301 lines, 7 tests)
285286
├── integration_tests.rs # Advanced tests (284 lines, 7 tests)
286287
├── stdio_redirect_tests.rs # Stdio safety tests (313 lines, 7 tests)

tests/daemon_tests.rs

Lines changed: 65 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -225,10 +225,10 @@ fn test_daemon_with_command_execution() {
225225
fn test_daemon_no_controlling_terminal() {
226226
// Tests that daemon has no controlling terminal
227227
// Expected behavior:
228-
// 1. Daemon process is created
229-
// 2. Daemon calls 'tty' command to check for terminal
230-
// 3. tty command should return "not a tty" or similar error
231-
// 4. This confirms daemon is properly detached from terminal
228+
// 1. Daemon process is created via double-fork + setsid()
229+
// 2. Daemon tries to open /dev/tty (the POSIX controlling terminal device)
230+
// 3. open() should fail because the daemon has no controlling terminal
231+
// 4. This confirms daemon is properly detached
232232
// 5. Critical for background service behavior
233233
let test_dir = setup_test_dir(get_unique_test_dir("daemon_no_tty"));
234234
let tty_file = test_dir.join("tty.info");
@@ -238,9 +238,9 @@ fn test_daemon_no_controlling_terminal() {
238238
assert!(wait_for_file(&tty_file, 500), "TTY info file should exist");
239239

240240
let content = fs::read_to_string(&tty_file).expect("Failed to read tty file");
241-
// When daemon has no controlling terminal, tty command should fail or return "not a tty"
242-
assert!(
243-
content.contains("not a tty") || content.contains("No such"),
241+
assert_eq!(
242+
content.trim(),
243+
"no_ctty",
244244
"Daemon should have no controlling terminal, got: {}",
245245
content
246246
);
@@ -250,21 +250,69 @@ fn test_daemon_no_controlling_terminal() {
250250
}
251251
Fork::Child => {
252252
if let Ok(Fork::Child) = daemon(false, true) {
253-
// Check if we have a controlling terminal
254-
let output = Command::new("tty")
255-
.output()
256-
.expect("Failed to run tty command");
257-
258-
let tty_output = if output.stdout.is_empty() {
259-
String::from_utf8_lossy(&output.stderr).to_string()
253+
// The POSIX way to check for a controlling terminal:
254+
// opening /dev/tty fails when the process has none.
255+
let fd =
256+
unsafe { libc::open(c"/dev/tty".as_ptr(), libc::O_RDONLY | libc::O_NOCTTY) };
257+
let result = if fd == -1 {
258+
"no_ctty"
260259
} else {
261-
String::from_utf8_lossy(&output.stdout).to_string()
260+
unsafe { libc::close(fd) };
261+
"has_ctty"
262262
};
263263

264-
fs::write(&tty_file, tty_output).expect("Failed to write tty file");
265-
264+
fs::write(&tty_file, result).expect("Failed to write tty file");
266265
exit(0);
267266
}
268267
}
269268
}
270269
}
270+
271+
#[test]
272+
fn test_daemon_never_returns_parent() {
273+
// Tests that daemon() never returns Ok(Fork::Parent(_))
274+
// Expected behavior:
275+
// 1. daemon() performs double-fork internally
276+
// 2. Both parent processes call _exit(0) and never return
277+
// 3. Only Ok(Fork::Child) is ever returned to the caller
278+
// 4. The daemon writes "child" to a marker file to confirm
279+
// 5. If Fork::Parent were ever returned, "parent" would be written instead
280+
let test_dir = setup_test_dir(get_unique_test_dir("daemon_never_returns_parent"));
281+
let marker_file = test_dir.join("result.marker");
282+
283+
match fork().expect("Failed to fork") {
284+
Fork::Parent(_) => {
285+
assert!(
286+
wait_for_file(&marker_file, 500),
287+
"Result marker file should exist"
288+
);
289+
290+
let content = fs::read_to_string(&marker_file).expect("Failed to read marker file");
291+
assert_eq!(
292+
content.trim(),
293+
"child",
294+
"daemon() should only return Fork::Child, never Fork::Parent"
295+
);
296+
297+
// Cleanup
298+
let _ = fs::remove_dir_all(&test_dir);
299+
}
300+
Fork::Child => {
301+
match daemon(false, true) {
302+
Ok(Fork::Child) => {
303+
fs::write(&marker_file, "child").expect("Failed to write marker");
304+
exit(0);
305+
}
306+
Ok(Fork::Parent(_)) => {
307+
// This arm should be unreachable
308+
fs::write(&marker_file, "parent").expect("Failed to write marker");
309+
exit(1);
310+
}
311+
Err(_) => {
312+
fs::write(&marker_file, "error").expect("Failed to write marker");
313+
exit(2);
314+
}
315+
}
316+
}
317+
}
318+
}

tests/pid_tests.rs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -270,14 +270,20 @@ fn test_getppid_after_parent_exits() {
270270
exit(0);
271271
}
272272
Ok(Fork::Child) => {
273-
// Give parent time to exit
274-
std::thread::sleep(std::time::Duration::from_millis(100));
275-
276-
// Grandchild's parent should now be init (PID 1)
277-
let current_parent = getppid();
278-
assert_eq!(
279-
current_parent, 1,
280-
"Orphaned grandchild should be reparented to init (PID 1)"
273+
// Poll until reparented to init (PID 1) with a timeout
274+
let mut reparented = false;
275+
for _ in 0..50 {
276+
if getppid() == 1 {
277+
reparented = true;
278+
break;
279+
}
280+
std::thread::sleep(std::time::Duration::from_millis(20));
281+
}
282+
283+
assert!(
284+
reparented,
285+
"Orphaned grandchild should be reparented to init (PID 1), got ppid={}",
286+
getppid()
281287
);
282288

283289
// Verify we're not the original child

0 commit comments

Comments
 (0)