1941
1942
const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
1943
const COMMAND_OUTPUT_LIMIT: usize = 50_000;
1944
+/// How long to keep waiting for the output drains once the command itself has
1945
+/// exited. Only a process that outlived the shell and still holds a pipe can
1946
+/// use this up, so it is short.
1947
+const OUTPUT_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
1948
1949
/// Spawn `command_str` through the platform shell with piped stdout/stderr.
1950
/// Shared by the foreground and background paths of `run_command`.
1951
+///
1952
+/// stdin is `/dev/null`, never inherited. In ACP mode sigit's stdin is the
1953
+/// JSON-RPC pipe from the editor, and a child holding that fd can read the
1954
+/// client's next request out of it: the agent never sees the request, the
1955
+/// editor waits forever for a reply, and the session is wedged past any
1956
+/// timeout this function's callers apply. Null also means a command that wants
1957
+/// input gets EOF and fails instead of blocking on input nobody can type.
1958
+///
1959
+/// On Unix the child also leads its own process group, so a timeout can kill
1960
+/// the whole tree. `sh -c` may fork rather than exec (dash does), and a
1961
+/// surviving grandchild keeps the pipe write ends open.
1962
fn spawn_shell(command_str: &str, cwd_path: &Path) -> std::io::Result<std::process::Child> {
1963
#[cfg(unix)]
1964
let (shell, flag) = ("sh", "-c");
1965
#[cfg(windows)]
1966
let (shell, flag) = ("cmd", "/C");
1967
1953
- Command::new(shell)
1968
+ let mut command = Command::new(shell);
1969
+ command
1970
.arg(flag)
1971
.arg(command_str)
1972
.current_dir(cwd_path)
1973
+ .stdin(std::process::Stdio::null())
1974
.stdout(std::process::Stdio::piped())
1958
- .stderr(std::process::Stdio::piped())
1959
- .spawn()
1975
+ .stderr(std::process::Stdio::piped());
1976
+ #[cfg(unix)]
1977
+ std::os::unix::process::CommandExt::process_group(&mut command, 0);
1978
+ command.spawn()
1979
+}
1980
+
1981
+/// SIGKILL a shell spawned by [`spawn_shell`] and everything it started, then
1982
+/// reap it so the timeout path doesn't leave a zombie behind.
1983
+fn kill_shell_tree(child: &mut std::process::Child) {
1984
+ #[cfg(unix)]
1985
+ {
1986
+ // Negative pid is the process group; the child is its leader. Fall
1987
+ // back to the child alone if the group is already gone.
1988
+ if unsafe { libc::kill(-(child.id() as i32), libc::SIGKILL) } != 0 {
1989
+ let _ = child.kill();
1990
+ }
1991
+ }
1992
+ #[cfg(windows)]
1993
+ let _ = child.kill();
1994
+ let _ = child.wait();
1995
}
1996
1997
/// Trailer identifying siGit Code as the co-author of commits it creates.
2005
pub const COMMIT_CO_AUTHOR_TRAILER: &str = "Co-Authored-By: siGit Code <noreply@sigit.si>";
2006
2007
/// Run `git <args>` in `cwd`, returning trimmed stdout on success.
2008
+///
2009
+/// stdin is null for the same reason as [`spawn_shell`]: git can ask for input
2010
+/// (a credential, a signing passphrase), and inheriting the ACP pipe would let
2011
+/// it read the editor's next request instead.
2012
fn git_stdout(cwd: &Path, args: &[&str]) -> Option<String> {
2013
let output = Command::new("git")
2014
.args(args)
2015
.current_dir(cwd)
2016
+ .stdin(std::process::Stdio::null())
2017
.output()
2018
.ok()?;
2019
output
2048
let amend = Command::new("git")
2049
.args(["commit", "--amend", "--no-edit", "--trailer"])
2050
.arg(COMMIT_CO_AUTHOR_TRAILER)
2051
+ .stdin(std::process::Stdio::null())
2052
.current_dir(cwd)
2053
.output()
2054
.ok()?;
2127
Err(err) => return format!("Error: failed to spawn command: {err}"),
2128
};
2129
2130
+ // Drain both pipes on their own threads. Waiting first and reading after
2131
+ // deadlocks the moment a command writes more than the pipe buffer holds
2132
+ // (64 KiB on Linux, less on macOS): the child blocks on the full pipe, we
2133
+ // block on the child, and the only way out is the timeout. A build or a
2134
+ // verbose test run crosses that on its own.
2135
+ let (chunks_tx, chunks_rx) = std::sync::mpsc::channel::<(u8, Vec<u8>)>();
2136
+ let drain = |tag: u8, pipe: Option<Box<dyn std::io::Read + Send>>| {
2137
+ let tx = chunks_tx.clone();
2138
+ std::thread::spawn(move || {
2139
+ let mut buf = Vec::new();
2140
+ if let Some(mut pipe) = pipe {
2141
+ let _ = pipe.read_to_end(&mut buf);
2142
+ }
2143
+ let _ = tx.send((tag, buf));
2144
+ });
2145
+ };
2146
+ drain(0, child.stdout.take().map(|pipe| Box::new(pipe) as _));
2147
+ drain(1, child.stderr.take().map(|pipe| Box::new(pipe) as _));
2148
+ drop(chunks_tx);
2149
+
2150
let start = std::time::Instant::now();
2090
- loop {
2151
+ let status = loop {
2152
match child.try_wait() {
2092
- Ok(Some(_status)) => break,
2153
+ Ok(Some(status)) => break status,
2154
Ok(None) => {
2155
if start.elapsed() >= COMMAND_TIMEOUT {
2095
- let _ = child.kill();
2156
+ kill_shell_tree(&mut child);
2157
return format!(
2158
"Error: command timed out after {} seconds and was killed.",
2159
COMMAND_TIMEOUT.as_secs()
2163
}
2164
Err(err) => return format!("Error: failed to wait on command: {err}"),
2165
}
2105
- }
2106
-
2107
- let output = match child.wait_with_output() {
2108
- Ok(o) => o,
2109
- Err(err) => return format!("Error: failed to read command output: {err}"),
2166
};
2167
2112
- let exit_code = output.status.code().unwrap_or(-1);
2168
+ // Collect what the drains read, bounded. The shell has exited, but a
2169
+ // grandchild that outlived it can still hold a pipe open, and joining
2170
+ // those threads would wait on it forever.
2171
+ let mut stdout_bytes = Vec::new();
2172
+ let mut stderr_bytes = Vec::new();
2173
+ let deadline = std::time::Instant::now() + OUTPUT_GRACE;
2174
+ for _ in 0..2 {
2175
+ let remaining = deadline.saturating_duration_since(std::time::Instant::now());
2176
+ match chunks_rx.recv_timeout(remaining) {
2177
+ Ok((0, bytes)) => stdout_bytes = bytes,
2178
+ Ok((_, bytes)) => stderr_bytes = bytes,
2179
+ Err(_) => break,
2180
+ }
2181
+ }
2182
+
2183
+ let exit_code = status.code().unwrap_or(-1);
2184
let mut combined = String::new();
2114
- combined.push_str(&String::from_utf8_lossy(&output.stdout));
2115
- combined.push_str(&String::from_utf8_lossy(&output.stderr));
2185
+ combined.push_str(&String::from_utf8_lossy(&stdout_bytes));
2186
+ combined.push_str(&String::from_utf8_lossy(&stderr_bytes));
2187
2188
// A new commit appeared under this command: make sure it carries the
2189
// siGit co-author trailer (see `ensure_commit_co_author`).
2207
combined
2208
};
2209
2139
- if output.status.success() {
2210
+ if status.success() {
2211
if truncated.is_empty() {
2212
format!("Command succeeded (exit code {exit_code}) with no output.")
2213
} else {
3156
);
3157
}
3158
3159
+ /// The wait loop used to read the pipes only after the child exited, so a
3160
+ /// command that outgrew the pipe buffer blocked on the write, we blocked on
3161
+ /// the child, and the whole thing sat there until the 120-second timeout.
3162
+ #[test]
3163
+ fn run_command_survives_more_output_than_a_pipe_buffer_holds() {
3164
+ let dir = std::env::temp_dir();
3165
+ // ~400 KB, well past any platform's pipe buffer. It also crosses
3166
+ // COMMAND_OUTPUT_LIMIT, so the result is truncated; the point of the
3167
+ // test is that the command finishes at all.
3168
+ let line = "x".repeat(199);
3169
+ let command = format!("for i in $(seq 1 2000); do echo {line}; done");
3170
+ let args = json!({"command": command, "cwd": dir.to_str().unwrap()}).to_string();
3171
+
3172
+ let started = std::time::Instant::now();
3173
+ let result = exec_run_command(&args);
3174
+
3175
+ assert!(
3176
+ started.elapsed() < std::time::Duration::from_secs(30),
3177
+ "run_command took {:?}; it deadlocked on the pipe",
3178
+ started.elapsed()
3179
+ );
3180
+ assert!(
3181
+ result.contains("Exit code 0"),
3182
+ "unexpected result: {result}"
3183
+ );
3184
+ assert!(
3185
+ result.contains("output truncated"),
3186
+ "expected the 50000-byte cap to bite: {result}"
3187
+ );
3188
+ }
3189
+
3190
+ /// A command that wants input gets EOF instead of blocking forever on a
3191
+ /// pipe nobody is going to write to (in ACP mode, the editor's own).
3192
+ #[test]
3193
+ fn run_command_gives_a_command_that_reads_stdin_an_empty_one() {
3194
+ let dir = std::env::temp_dir();
3195
+ let args = json!({"command": "cat", "cwd": dir.to_str().unwrap()}).to_string();
3196
+
3197
+ let started = std::time::Instant::now();
3198
+ let result = exec_run_command(&args);
3199
+
3200
+ assert!(
3201
+ started.elapsed() < std::time::Duration::from_secs(30),
3202
+ "run_command blocked on stdin for {:?}",
3203
+ started.elapsed()
3204
+ );
3205
+ assert!(result.contains("no output"), "unexpected result: {result}");
3206
+ }
3207
+
3208
#[test]
3209
fn run_command_appends_co_author_trailer_to_new_commits() {
3210
let dir = init_test_repo("coauthor_append");