bread-utils extracts genuinely duplicated logic found across breadbox, breadclip, breadmon, breadcrumbs, bos-settings, and breadhelp: - hypr: Hyprland socket1 request/response client (breadbox's get_active_workspace + breadclip's position.rs hyprctl_json were near-identical), socket2 path resolution (breadmon), and a version-tolerant `fullscreen` field parser (Hyprland has shipped both bool and int representations across versions). - singleton: correct flock-based single-instance toggle, replacing the TOCTOU-prone read-pid/check-proc/kill/write-pid pattern duplicated verbatim between breadbox and breadclip (breadclip's own comment says "matches breadbox pattern"). - proc: breadcrumbs' timeout-guarded subprocess runner, promoted verbatim as the one implementation in the ecosystem that already got this right. - atomic + xdg: atomic (temp-then-rename) file writes with an optional .bak-before-overwrite variant, and XDG path helpers that never fall back to a literal "~/..." string (the exact breadclip-core and breadpad-shared bug: PathBuf never expands `~`). - tomlcfg (feature "toml"): the load_doc/save_doc TOML-editing discipline bos-settings and breadhelp both implemented byte-for-byte identically in the same fix pass that introduced it. - gtk_popup (feature "gtk"): layer-shell overlay window setup, visible-row navigation, and click-outside-close, deduplicated from breadbox and breadclip (~150 duplicated lines, per both apps' own "same as breadbox" comments). bread-onnx extracts the embedding pipeline (tokenize -> tensor build -> mean-pool -> L2-normalize) duplicated near-verbatim between breadarr and breadsearch, a shared execution-provider session builder with loud EP- registration logging, and a model download+integrity helper. Defaults AMD iGPU acceleration to ort::ep::MIGraphX (not ROCm) per this machine's own breadsearch-gpu-backends lesson: ROCMExecutionProvider silently no-ops to CPU on distro ROCm onnxruntime builds compiled with --use_migraphx. Both crates build and pass their own test suites standalone. Consumer migrations follow in subsequent commits.
174 lines
5.4 KiB
Rust
174 lines
5.4 KiB
Rust
//! Timeout-guarded subprocess execution.
|
|
//!
|
|
//! Promoted verbatim from `breadcrumbs/src/util.rs` (the one implementation
|
|
//! in the ecosystem that already got this right — see the audit note in
|
|
//! `bread-utils`'s crate root). Several other repos shell out to
|
|
//! Wayland/Hyprland tools (`hyprctl`, `grim`, `wl-paste`, ...) via bare
|
|
//! `std::process::Command` with no timeout at all, so a hung child can wedge
|
|
//! the whole caller indefinitely. `run`/`run_with_stdin` below kill the
|
|
//! child and return a failed [`Output`] once `timeout` elapses instead.
|
|
|
|
use std::io::{Read, Write};
|
|
use std::process::{Command, Stdio};
|
|
use std::thread;
|
|
use std::time::{Duration, Instant};
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Output {
|
|
pub success: bool,
|
|
pub stdout: String,
|
|
pub stderr: String,
|
|
}
|
|
|
|
impl Output {
|
|
pub fn failed() -> Output {
|
|
Output {
|
|
success: false,
|
|
stdout: String::new(),
|
|
stderr: String::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run a command with a hard timeout. The child is killed if it overruns so
|
|
/// a hung subprocess can never wedge the caller.
|
|
pub fn run(prog: &str, args: &[&str], timeout: Duration) -> Output {
|
|
run_with_stdin(prog, args, None, timeout)
|
|
}
|
|
|
|
/// Like [`run`], but feeds `stdin` to the child's standard input. Useful for
|
|
/// handing secrets (e.g. Wi-Fi PSKs, API tokens) to a CLI without exposing
|
|
/// them in argv, where any local user could read them via `ps`.
|
|
pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output {
|
|
let stdin_cfg = if stdin.is_some() {
|
|
Stdio::piped()
|
|
} else {
|
|
Stdio::null()
|
|
};
|
|
let mut child = match Command::new(prog)
|
|
.args(args)
|
|
// Pin the C locale so message text callers parse (hyprctl JSON keys,
|
|
// status output, ...) is stable regardless of the user's LANG.
|
|
.env("LC_ALL", "C")
|
|
.env("LANG", "C")
|
|
.stdin(stdin_cfg)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
{
|
|
Ok(c) => c,
|
|
Err(_) => return Output::failed(),
|
|
};
|
|
|
|
let mut stdout_pipe = child.stdout.take();
|
|
let mut stderr_pipe = child.stderr.take();
|
|
|
|
let out_handle = thread::spawn(move || {
|
|
let mut buf = String::new();
|
|
if let Some(ref mut p) = stdout_pipe {
|
|
let _ = p.read_to_string(&mut buf);
|
|
}
|
|
buf
|
|
});
|
|
let err_handle = thread::spawn(move || {
|
|
let mut buf = String::new();
|
|
if let Some(ref mut p) = stderr_pipe {
|
|
let _ = p.read_to_string(&mut buf);
|
|
}
|
|
buf
|
|
});
|
|
|
|
// Feed stdin only after the reader threads are draining stdout/stderr, so
|
|
// a child that writes more than a pipe buffer before consuming stdin
|
|
// can't deadlock against our blocking write.
|
|
if let Some(data) = stdin {
|
|
if let Some(mut sink) = child.stdin.take() {
|
|
let _ = sink.write_all(data.as_bytes());
|
|
// Drop closes the pipe so the child's read sees EOF.
|
|
}
|
|
}
|
|
|
|
let start = Instant::now();
|
|
let status = loop {
|
|
match child.try_wait() {
|
|
Ok(Some(s)) => break Some(s),
|
|
Ok(None) => {
|
|
if start.elapsed() >= timeout {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
break None;
|
|
}
|
|
thread::sleep(Duration::from_millis(50));
|
|
}
|
|
Err(_) => break None,
|
|
}
|
|
};
|
|
|
|
let stdout = out_handle.join().unwrap_or_default();
|
|
let stderr = err_handle.join().unwrap_or_default();
|
|
|
|
Output {
|
|
success: status.map(|s| s.success()).unwrap_or(false),
|
|
stdout,
|
|
stderr,
|
|
}
|
|
}
|
|
|
|
pub fn run_ok(prog: &str, args: &[&str], timeout: Duration) -> bool {
|
|
run(prog, args, timeout).success
|
|
}
|
|
|
|
/// Run a command and parse its stdout as JSON on success. Convenience for the
|
|
/// very common `hyprctl -j <subcommand>` / `<tool> --json` pattern.
|
|
pub fn run_json(prog: &str, args: &[&str], timeout: Duration) -> Option<serde_json::Value> {
|
|
let out = run(prog, args, timeout);
|
|
if !out.success {
|
|
return None;
|
|
}
|
|
serde_json::from_str(&out.stdout).ok()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn run_captures_stdout() {
|
|
let out = run("printf", &["hello"], Duration::from_secs(2));
|
|
assert!(out.success);
|
|
assert_eq!(out.stdout, "hello");
|
|
}
|
|
|
|
#[test]
|
|
fn run_reports_failure_for_nonzero_exit() {
|
|
let out = run("sh", &["-c", "exit 3"], Duration::from_secs(2));
|
|
assert!(!out.success);
|
|
}
|
|
|
|
#[test]
|
|
fn run_kills_hung_child_after_timeout() {
|
|
let start = Instant::now();
|
|
let out = run("sleep", &["30"], Duration::from_millis(200));
|
|
assert!(!out.success);
|
|
assert!(start.elapsed() < Duration::from_secs(5), "child was not killed promptly");
|
|
}
|
|
|
|
#[test]
|
|
fn run_with_stdin_feeds_child_input() {
|
|
let out = run_with_stdin("cat", &[], Some("secret-data"), Duration::from_secs(2));
|
|
assert!(out.success);
|
|
assert_eq!(out.stdout, "secret-data");
|
|
}
|
|
|
|
#[test]
|
|
fn run_json_parses_stdout() {
|
|
let out = run_json("printf", &["{\"a\":1}"], Duration::from_secs(2));
|
|
assert_eq!(out.unwrap()["a"], 1);
|
|
}
|
|
|
|
#[test]
|
|
fn run_json_returns_none_on_failure() {
|
|
let out = run_json("sh", &["-c", "exit 1"], Duration::from_secs(2));
|
|
assert!(out.is_none());
|
|
}
|
|
}
|