From 8256494aff8d606f306deffc0d414b6a4e29f820 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:19:49 +0000 Subject: [PATCH] Bound command pipe drain and routing interval Co-authored-by: phieri <12006381+phieri@users.noreply.github.com> --- README.md | 5 +++-- man/bt-intercom.1 | 2 +- src/cli.rs | 2 +- src/process.rs | 23 +++++++++++++++++++++++ src/runtime_config.rs | 9 +++++---- 5 files changed, 33 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 681de9a..091e487 100644 --- a/README.md +++ b/README.md @@ -196,8 +196,9 @@ If `XDG_CONFIG_HOME` is unset or not an absolute path, they are saved under `~/.config/bt-intercom/headsets`. Later, run `bt-intercom run --connect` to restore them. `--connect` retries disconnected headsets every 30 seconds; omit it if another Bluetooth manager keeps them connected. Routing is polled every -two seconds by default; change that with `--interval SECONDS`. Ctrl-C or SIGTERM -closes links created by this process. Existing PipeWire links are not modified. +two seconds by default; change that with `--interval SECONDS` (minimum: 1 second). +Ctrl-C or SIGTERM closes links created by this process. Existing PipeWire links +are not modified. Shutdown also cancels and joins background workers, including idle headset button readers. `--sco-limit N` sets the maximum SCO/eSCO capacity budget per controller (default: 1). In `sco-a2dp` transport, it limits simultaneous talkers diff --git a/man/bt-intercom.1 b/man/bt-intercom.1 index 21c327d..a19d9be 100644 --- a/man/bt-intercom.1 +++ b/man/bt-intercom.1 @@ -50,7 +50,7 @@ the selected group, and Space to toggle headset membership. Set the scan duration from 1 through 300 seconds (default: 15). .TP .BI run " --interval SECONDS" -Set the routing update interval (default: 2 seconds). +Set the routing update interval (default: 2 seconds; minimum: 1 second). .TP .BI run " --sco-limit N" Set the maximum simultaneous SCO/eSCO links per Bluetooth controller (default: diff --git a/src/cli.rs b/src/cli.rs index e42328c..7877fef 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -73,7 +73,7 @@ pub(crate) enum CliCommand { Run { #[arg(value_parser = parse_address)] addresses: Vec
, - /// Routing update interval in seconds. + /// Routing update interval in seconds (minimum: 1). #[arg(long, default_value_t = 2.0, value_parser = parse_interval)] interval: f64, /// Reconnect disconnected headsets every 30 seconds. diff --git a/src/process.rs b/src/process.rs index dbccdbf..b38e86d 100644 --- a/src/process.rs +++ b/src/process.rs @@ -140,6 +140,15 @@ pub(crate) fn command_cancellable( .inspect_err(|error| { log::debug!("{name} stopped after {:?}: {error}", started.elapsed()) })?; + while !output.is_finished() || !error.is_finished() { + if started.elapsed() >= timeout { + kill_process_group(&mut child); + let _ = output.join(); + let _ = error.join(); + return Err(format!("{name} timed out while draining command output")); + } + thread::sleep(CHILD_POLL_INTERVAL); + } let stdout = output .join() .map_err(|_| "stdout reader panicked".to_string())? @@ -254,4 +263,18 @@ mod tests { ); assert!(start.elapsed() < Duration::from_secs(1)); } + + #[test] + fn exited_child_cannot_leave_descendants_holding_output_pipes() { + let start = Instant::now(); + assert!( + command( + &["sh", "-c", "sleep 30 & exit 0"], + Duration::from_millis(100) + ) + .unwrap_err() + .contains("timed out while draining command output") + ); + assert!(start.elapsed() < Duration::from_secs(1)); + } } diff --git a/src/runtime_config.rs b/src/runtime_config.rs index 5375a86..9c7d9e5 100644 --- a/src/runtime_config.rs +++ b/src/runtime_config.rs @@ -20,8 +20,8 @@ pub(crate) fn parse_interval(value: &str) -> Result { if !interval.is_finite() || interval <= 0.0 || interval >= u64::MAX as f64 { return Err("--interval must be a positive finite number".into()); } - if Duration::from_secs_f64(interval).is_zero() { - return Err("--interval must be at least one nanosecond".into()); + if interval < 1.0 { + return Err("--interval must be at least 1 second".into()); } Ok(interval) } @@ -369,7 +369,8 @@ mod tests { } #[test] - fn rejects_intervals_that_round_to_zero() { - assert!(parse_interval("0.00000000001").is_err()); + fn routing_interval_must_be_at_least_one_second() { + assert!(parse_interval("0.999").is_err()); + assert_eq!(parse_interval("1").unwrap(), 1.0); } }