mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
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:
1 parent
0e18ed5429
commit
27fb061108
4 files changed
+173
-62
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user