From aa87c5fbc44918cf28356b1cfd397e8ce995cf80 Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Tue, 27 Oct 2026 02:41:10 -0400 Subject: [PATCH 1/3] Let the Rust descriptor test wait for exiting threads A joined thread can stay listed in /proc/self/task briefly after it exits, so an exact count taken at once intermittently saw one extra thread (main CI failed this way after the Rust SDK merge). Poll for up to two seconds for the counts to return to the baseline; a real leak still fails. --- rust/tests/descriptors.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/rust/tests/descriptors.rs b/rust/tests/descriptors.rs index f69855c..9a5df65 100644 --- a/rust/tests/descriptors.rs +++ b/rust/tests/descriptors.rs @@ -30,6 +30,20 @@ fn count(directory: &str) -> usize { std::fs::read_dir(directory).unwrap().count() } +/// The count of `directory` once it is at most `limit`, or after two seconds. +/// A joined thread can stay listed in `/proc/self/task` briefly after it +/// exits, so an exact count taken at once can see one more; a leak persists. +fn settled(directory: &str, limit: usize) -> usize { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let current = count(directory); + if current <= limit || Instant::now() >= deadline { + return current; + } + std::thread::sleep(Duration::from_millis(10)); + } +} + #[test] fn invocations_release_their_pipes_and_threads() { assert!(invoke("ok").ok()); // warm up lazily opened files @@ -38,15 +52,17 @@ fn invocations_release_their_pipes_and_threads() { let result = invoke("ok"); assert!(result.ok(), "{}", result.message()); } - assert!(count("/proc/self/fd") <= descriptors, "descriptors grew from {descriptors} to {}", count("/proc/self/fd")); - assert!(count("/proc/self/task") <= threads, "threads grew from {threads} to {}", count("/proc/self/task")); + let grown = settled("/proc/self/fd", descriptors); + assert!(grown <= descriptors, "descriptors grew from {descriptors} to {grown}"); + let grown = settled("/proc/self/task", threads); + assert!(grown <= threads, "threads grew from {threads} to {grown}"); // A descendant outside the process group keeps holding stderr after the // invocation returns; the host must still stop its threads and close its // pipes rather than wait for the descendant. let start = Instant::now(); let result = invoke("escaped-descendant"); let pid: i32 = String::from_utf8_lossy(&result.stderr).parse().expect("descendant pid"); - let (after_descriptors, after_threads) = (count("/proc/self/fd"), count("/proc/self/task")); + let (after_descriptors, after_threads) = (settled("/proc/self/fd", descriptors), settled("/proc/self/task", threads)); // SAFETY: the pid names the stand-in's own descendant. unsafe { kill(pid, 9); From 34cd27c32b09305991c033386404d83b199d47b3 Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Tue, 27 Oct 2026 18:08:17 -0400 Subject: [PATCH 2/3] Time the escaped-descendant call before waiting for counts --- rust/tests/descriptors.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/rust/tests/descriptors.rs b/rust/tests/descriptors.rs index 9a5df65..4bb59ce 100644 --- a/rust/tests/descriptors.rs +++ b/rust/tests/descriptors.rs @@ -61,18 +61,14 @@ fn invocations_release_their_pipes_and_threads() { // pipes rather than wait for the descendant. let start = Instant::now(); let result = invoke("escaped-descendant"); + let elapsed = start.elapsed(); let pid: i32 = String::from_utf8_lossy(&result.stderr).parse().expect("descendant pid"); let (after_descriptors, after_threads) = (settled("/proc/self/fd", descriptors), settled("/proc/self/task", threads)); // SAFETY: the pid names the stand-in's own descendant. unsafe { kill(pid, 9); } - assert!( - result.kind == ResultKind::CarrierLoss && start.elapsed() < Duration::from_secs(5), - "{:?} after {:?}", - result.kind, - start.elapsed() - ); + assert!(result.kind == ResultKind::CarrierLoss && elapsed < Duration::from_secs(5), "{:?} after {elapsed:?}", result.kind); assert!(after_descriptors <= descriptors, "escaped descendant: descriptors grew from {descriptors} to {after_descriptors}"); assert!(after_threads <= threads, "escaped descendant: threads grew from {threads} to {after_threads}"); } From 4ad71ff9894442a6aa136bc72b16b9e94c055cd3 Mon Sep 17 00:00:00 2001 From: Josh Winters Date: Tue, 27 Oct 2026 18:13:09 -0400 Subject: [PATCH 3/3] Keep the Rust descriptor counts exact; wait only for threads --- rust/tests/descriptors.rs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/rust/tests/descriptors.rs b/rust/tests/descriptors.rs index 4bb59ce..3f8daa4 100644 --- a/rust/tests/descriptors.rs +++ b/rust/tests/descriptors.rs @@ -1,5 +1,5 @@ -//! Runs alone in its own test process so descriptor and thread counts are -//! exact. +//! Runs alone in its own test process so descriptor counts are exact and +//! thread counts settle to the baseline. #![cfg(target_os = "linux")] @@ -30,13 +30,14 @@ fn count(directory: &str) -> usize { std::fs::read_dir(directory).unwrap().count() } -/// The count of `directory` once it is at most `limit`, or after two seconds. -/// A joined thread can stay listed in `/proc/self/task` briefly after it -/// exits, so an exact count taken at once can see one more; a leak persists. -fn settled(directory: &str, limit: usize) -> usize { +/// The thread count once it is at most `limit`, or after two seconds. A +/// joined thread can stay listed in `/proc/self/task` briefly after it exits, +/// so an exact count taken at once can see one more; a leak persists. +/// Descriptors need no wait: a joined thread has already closed its pipe. +fn settled_threads(limit: usize) -> usize { let deadline = Instant::now() + Duration::from_secs(2); loop { - let current = count(directory); + let current = count("/proc/self/task"); if current <= limit || Instant::now() >= deadline { return current; } @@ -52,9 +53,9 @@ fn invocations_release_their_pipes_and_threads() { let result = invoke("ok"); assert!(result.ok(), "{}", result.message()); } - let grown = settled("/proc/self/fd", descriptors); + let grown = count("/proc/self/fd"); assert!(grown <= descriptors, "descriptors grew from {descriptors} to {grown}"); - let grown = settled("/proc/self/task", threads); + let grown = settled_threads(threads); assert!(grown <= threads, "threads grew from {threads} to {grown}"); // A descendant outside the process group keeps holding stderr after the // invocation returns; the host must still stop its threads and close its @@ -63,7 +64,7 @@ fn invocations_release_their_pipes_and_threads() { let result = invoke("escaped-descendant"); let elapsed = start.elapsed(); let pid: i32 = String::from_utf8_lossy(&result.stderr).parse().expect("descendant pid"); - let (after_descriptors, after_threads) = (settled("/proc/self/fd", descriptors), settled("/proc/self/task", threads)); + let (after_descriptors, after_threads) = (count("/proc/self/fd"), settled_threads(threads)); // SAFETY: the pid names the stand-in's own descendant. unsafe { kill(pid, 9);