fix(tui): bound the git and Wayland clipboard calls that could hang the UI

CW-6950 step 3 (audit item U02-12 and its clipboard sibling):

- `@git` and `@diff` mentions run their git calls through the bounded runner
  (`run_bounded_git_with_timeout`), including the review-config probe and
  `git config --get-regexp` filter read in dependencies.rs (10 s). A timeout
  fails closed. A stalled git now reports "git command timed out" or "git
  could not run safely" instead of "not a git repository", and the
  review-config probe caches only a definitive answer, so one transient hang
  no longer leaves every later review command on "upgrade Git".
- `wl-paste` and `wl-copy` are spawned with a 2 s wait, a 16 MiB read cap and
  closed stdin, and are killed and reaped on timeout instead of blocking the
  UI thread.

Verification: see the previous commit (same build and test run).

Refs CW-6950, #6561

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-15 03:23:47 -07:00
1 parent 0e18ed5429
commit 27fb061108
4 files changed
+173 -62

No files matched your search

+54 -27
View File
@@ -1122,6 +1122,8 @@ impl Git {
"--ignore-submodules=dirty",
];
const REVIEW_CONFIG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Construct a read-only review command with content conversion disabled.
/// Review callers also pass [`Self::REVIEW_DIFF_ARGS`] for diffs.
/// Configured filters otherwise execute even when those flags are present.
@@ -1152,23 +1154,44 @@ impl Git {
// Probe once for this process's cached executable, before any read.
// A random value prevents repository config from spoofing support.
static REVIEW_CONFIG_SUPPORTED: OnceLock<bool> = OnceLock::new();
if !*REVIEW_CONFIG_SUPPORTED.get_or_init(|| {
let Some(mut probe) = Self::command() else {
return false;
};
let value = uuid::Uuid::new_v4().to_string();
probe
.current_dir(workspace)
.stdin(std::process::Stdio::null())
.env_remove("GIT_CONFIG")
.env_remove("GIT_CONFIG_PARAMETERS")
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "codewhale.reviewConfigCapability")
.env("GIT_CONFIG_VALUE_0", &value)
.args(["config", "--get", "codewhale.reviewConfigCapability"]);
matches!(probe.output(), Ok(output)
if output.status.success() && output.stdout == format!("{value}\n").as_bytes())
}) {
let supported = match REVIEW_CONFIG_SUPPORTED.get() {
Some(supported) => *supported,
None => {
let supported = match Self::command() {
None => false,
Some(mut probe) => {
let value = uuid::Uuid::new_v4().to_string();
probe
.current_dir(workspace)
.env_remove("GIT_CONFIG")
.env_remove("GIT_CONFIG_PARAMETERS")
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "codewhale.reviewConfigCapability")
.env("GIT_CONFIG_VALUE_0", &value)
.args(["config", "--get", "codewhale.reviewConfigCapability"]);
match crate::snapshot::repo::run_bounded_git_with_timeout(
&mut probe,
"config",
Self::REVIEW_CONFIG_TIMEOUT,
) {
Ok(output) => {
output.status.success()
&& output.stdout == format!("{value}\n").as_bytes()
}
Err(error) if error.kind() == std::io::ErrorKind::TimedOut => {
let context = format!(
"Cannot safely inspect Git review configuration: {error}"
);
return Err(anyhow::Error::new(error).context(context));
}
Err(_) => false,
}
}
};
*REVIEW_CONFIG_SUPPORTED.get_or_init(|| supported)
}
};
if !supported {
anyhow::bail!(
"Cannot safely inspect Git review configuration: Git runtime configuration overrides are unavailable; upgrade Git (2.31 or newer)"
);
@@ -1202,16 +1225,20 @@ impl Git {
) -> anyhow::Result<Vec<(String, &'static str)>> {
use anyhow::{Context, bail};
let output = Self::review_base(workspace)?
.args([
"config",
"--null",
"--name-only",
"--get-regexp",
r"^filter\..*\.(clean|process|required)$",
])
.output()
.context("Failed to inspect Git review filters")?;
let mut command = Self::review_base(workspace)?;
command.args([
"config",
"--null",
"--name-only",
"--get-regexp",
r"^filter\..*\.(clean|process|required)$",
]);
let output = crate::snapshot::repo::run_bounded_git_with_timeout(
&mut command,
"config",
Self::REVIEW_CONFIG_TIMEOUT,
)
.context("Failed to inspect Git review filters")?;
let no_filters =
output.status.code() == Some(1) && output.stdout.is_empty() && output.stderr.is_empty();
if (!output.status.success() && !no_filters)
+1 -1
View File
@@ -2415,7 +2415,7 @@ fn run_bounded_git(cmd: &mut std::process::Command, subcommand: &str) -> io::Res
run_bounded_git_with_timeout(cmd, subcommand, GIT_COMMAND_TIMEOUT)
}
fn run_bounded_git_with_timeout(
pub(crate) fn run_bounded_git_with_timeout(
cmd: &mut std::process::Command,
subcommand: &str,
timeout: Duration,
+72 -14
View File
@@ -53,6 +53,10 @@ const PRIMARY_MAX_BYTES: usize = 1024 * 1024;
all(target_os = "linux", not(target_env = "ohos"))
))]
const MAX_CLIPBOARD_HTML_BYTES: usize = 1024 * 1024;
#[cfg(any(all(test, unix), all(target_os = "linux", not(target_env = "ohos"))))]
const WAYLAND_CLIPBOARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
#[cfg(any(all(test, unix), all(target_os = "linux", not(target_env = "ohos"))))]
const WL_PASTE_MAX_BYTES: u64 = 16 * 1024 * 1024;
/// Convert rich clipboard content without loading its linked resources. Keep
/// the plain representation as a lossless fallback for empty/oversized HTML.
@@ -768,20 +772,67 @@ fn read_text_with_wlpaste() -> Result<String> {
read_text_with_wlpaste_using_argv("wl-paste")
}
#[cfg(any(all(test, unix), all(target_os = "linux", not(target_env = "ohos"))))]
fn wait_wayland_clipboard_child(
mut child: std::process::Child,
program: &str,
) -> Result<std::process::ExitStatus> {
use wait_timeout::ChildExt;
let waited = child.wait_timeout(WAYLAND_CLIPBOARD_TIMEOUT);
if let Ok(Some(status)) = waited {
return Ok(status);
}
let _ = child.kill();
let _ = std::thread::Builder::new()
.name("clipboard-wait".to_string())
.spawn(move || {
let _ = child.wait();
});
match waited {
Err(e) => bail!("Failed to wait on {program}: {e}"),
_ => bail!(
"{program} timed out after {}s",
WAYLAND_CLIPBOARD_TIMEOUT.as_secs()
),
}
}
#[cfg(any(all(test, unix), all(target_os = "linux", not(target_env = "ohos"))))]
fn read_text_with_wlpaste_using_argv(program: &str) -> Result<String> {
let output = Command::new(program)
use std::io::Read;
let mut child = Command::new(program)
.arg("--no-newline")
.arg("--type")
.arg("text/plain")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to run {program}: {e}"))?;
if !output.status.success() {
bail!("{program} exited with {}", output.status);
let stdout = child.stdout.take();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
let _ = std::thread::Builder::new()
.name("wl-paste-output".to_string())
.spawn(move || {
let mut bytes = Vec::new();
let read = match stdout {
Some(stdout) => stdout.take(WL_PASTE_MAX_BYTES + 1).read_to_end(&mut bytes),
None => Ok(0),
};
let _ = tx.send(read.map(|_| bytes));
});
let status = wait_wayland_clipboard_child(child, program)?;
let bytes = rx
.recv_timeout(WAYLAND_CLIPBOARD_TIMEOUT)
.map_err(|_| anyhow::anyhow!("{program} output did not close"))?
.map_err(|e| anyhow::anyhow!("Failed to read {program} output: {e}"))?;
if bytes.len() as u64 > WL_PASTE_MAX_BYTES {
bail!("{program} returned more than {WL_PASTE_MAX_BYTES} bytes");
}
String::from_utf8(output.stdout).context("wl-paste returned non-UTF-8 text")
if !status.success() {
bail!("{program} exited with {status}");
}
String::from_utf8(bytes).context("wl-paste returned non-UTF-8 text")
}
#[cfg(all(target_os = "linux", not(target_env = "ohos"), not(test)))]
@@ -792,15 +843,22 @@ fn write_text_with_wlcopy_using_argv(program: &str, text: &str) -> Result<()> {
.stderr(Stdio::null())
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to run {program}: {e}"))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(text.as_bytes())
.map_err(|e| anyhow::anyhow!("Failed to write to {program}: {e}"))?;
}
// stdin is dropped here, closing the pipe so wl-copy flushes.
let status = child
.wait()
.map_err(|e| anyhow::anyhow!("Failed to wait on {program}: {e}"))?;
let stdin = child.stdin.take();
let text = text.to_owned();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
let _ = std::thread::Builder::new()
.name("wl-copy-input".to_string())
.spawn(move || {
let written = match stdin {
Some(mut stdin) => stdin.write_all(text.as_bytes()),
None => Ok(()),
};
let _ = tx.send(written);
});
let status = wait_wayland_clipboard_child(child, program)?;
rx.recv_timeout(WAYLAND_CLIPBOARD_TIMEOUT)
.map_err(|_| anyhow::anyhow!("{program} input did not close"))?
.map_err(|e| anyhow::anyhow!("Failed to write to {program}: {e}"))?;
if !status.success() {
bail!("{program} exited with {status}");
}
+46 -20
View File
@@ -9,15 +9,24 @@
//! Two deliberate boundaries:
//!
//! * **Read-only and bounded.** Only `git status` and `git diff` run, always
//! with an explicit byte budget. A repository with a huge working-tree diff
//! truncates with a visible marker rather than flooding the turn.
//! with an explicit byte budget and time limit. A repository with a huge
//! working-tree diff truncates with a visible marker rather than flooding
//! the turn, and a wedged git reports a timeout instead of hanging it.
//! * **Honest when unavailable.** No git binary, or a directory that is not a
//! repository, produces an explicit `<git-unavailable>` block. A mention
//! never silently contributes nothing.
use std::io;
use std::path::Path;
use std::time::Duration;
use crate::dependencies::{ExternalTool, Git};
use crate::snapshot::repo::run_bounded_git_with_timeout;
const GIT_MENTION_TIMEOUT: Duration = Duration::from_secs(10);
const GIT_COMMAND_FAILED: &str = "git command failed";
const GIT_COMMAND_TIMED_OUT: &str = "git command timed out";
const GIT_COMMAND_NOT_RUN: &str = "git could not run safely";
/// Byte ceiling for the inlined `@diff` payload. Documented here because the
/// context inspector reports the budget alongside actual size.
@@ -154,8 +163,12 @@ pub fn resolve_git_mention(kind: GitMentionKind, cwd: &Path) -> GitMentionPayloa
if !Git::available() {
return GitMentionPayload::unavailable(kind, "git not found on PATH");
}
if !is_git_repository(cwd) {
return GitMentionPayload::unavailable(kind, "not a git repository");
match git_output(&["rev-parse", "--is-inside-work-tree"], cwd) {
Ok(out) if out.trim() == "true" => {}
Ok(_) | Err(GIT_COMMAND_FAILED) => {
return GitMentionPayload::unavailable(kind, "not a git repository");
}
Err(reason) => return GitMentionPayload::unavailable(kind, reason),
}
let raw = match kind {
@@ -165,8 +178,9 @@ pub fn resolve_git_mention(kind: GitMentionKind, cwd: &Path) -> GitMentionPayloa
cwd,
),
};
let Some(raw) = raw else {
return GitMentionPayload::unavailable(kind, "git command failed");
let raw = match raw {
Ok(raw) => raw,
Err(reason) => return GitMentionPayload::unavailable(kind, reason),
};
if raw.trim().is_empty() {
@@ -206,27 +220,39 @@ pub fn resolve_git_mention(kind: GitMentionKind, cwd: &Path) -> GitMentionPayloa
/// `git status` plus the branch line, so the model does not have to infer the
/// branch from a porcelain listing.
fn git_status_payload(cwd: &Path) -> Option<String> {
let status = git_output(&["status", "--short", "--branch"], cwd)?;
Some(status)
fn git_status_payload(cwd: &Path) -> Result<String, &'static str> {
git_output(&["status", "--short", "--branch"], cwd)
}
/// True when `cwd` is inside a git work tree.
fn is_git_repository(cwd: &Path) -> bool {
git_output(&["rev-parse", "--is-inside-work-tree"], cwd).is_some_and(|out| out.trim() == "true")
}
/// Run git and return stdout, or `None` when the binary is missing or the
/// command exits non-zero.
/// Run git and return stdout, or the `<git-unavailable>` reason when the
/// binary is missing, the command exits non-zero, or it outlives
/// `GIT_MENTION_TIMEOUT` and is killed.
///
/// Runs under [`Git::review_command`], so repository-configured filters,
/// fsmonitor and hooks do not execute while reading the working tree.
fn git_output(args: &[&str], cwd: &Path) -> Option<String> {
let output = Git::review_command(cwd).ok()?.args(args).output().ok()?;
fn git_output(args: &[&str], cwd: &Path) -> Result<String, &'static str> {
let mut command = Git::review_command(cwd).map_err(|err| {
if err
.chain()
.filter_map(|cause| cause.downcast_ref::<io::Error>())
.any(|cause| cause.kind() == io::ErrorKind::TimedOut)
{
GIT_COMMAND_TIMED_OUT
} else {
GIT_COMMAND_NOT_RUN
}
})?;
command.args(args);
let subcommand = args.first().copied().unwrap_or("git");
let output = run_bounded_git_with_timeout(&mut command, subcommand, GIT_MENTION_TIMEOUT)
.map_err(|err| match err.kind() {
io::ErrorKind::TimedOut => GIT_COMMAND_TIMED_OUT,
_ => GIT_COMMAND_NOT_RUN,
})?;
if !output.status.success() {
return None;
return Err(GIT_COMMAND_FAILED);
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
/// Cut `text` to at most `budget` bytes without splitting a UTF-8 scalar.