Files
codex/codex-rs/hooks/src/engine/command_runner.rs
T
jif 634a998d8a Preserve output from hooks that exit before reading stdin (#35194)
## Why

A hook can exit successfully without consuming its JSON input. If it closes
stdin while Codex is still writing, the resulting broken pipe should not hide
the hook's exit status and output.

## What changed

Ignore broken-pipe errors when writing hook stdin and continue waiting for the
process result. Other stdin write errors remain failures.

## Testing

Add a cross-platform regression test that sends a large input to a
fast-exiting hook and verifies that its successful status and stdout are
preserved.

GitOrigin-RevId: e1b6e7c6ac5e39232e49b45245ea0df9d34d1fe6
2026-07-24 14:37:11 +00:00

216 lines
6.3 KiB
Rust

use std::io::ErrorKind;
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use std::time::Instant;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::time::timeout;
use tracing::Span;
use super::CommandShell;
use super::ConfiguredHandler;
use super::dispatcher::hook_event_name_label;
use super::dispatcher::hook_execution_mode_label;
use super::dispatcher::hook_handler_type_label;
use super::dispatcher::hook_scope_label;
use super::dispatcher::hook_source_label;
use super::dispatcher::scope_for_event;
use codex_protocol::protocol::HookExecutionMode;
use codex_protocol::protocol::HookHandlerType;
#[derive(Debug)]
pub(crate) struct CommandRunResult {
pub started_at: i64,
pub completed_at: i64,
pub duration_ms: i64,
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
pub error: Option<String>,
}
#[tracing::instrument(
name = "codex.hooks.command",
level = "trace",
skip_all,
fields(
hook.event_name = hook_event_name_label(handler.event_name),
hook.handler_type = hook_handler_type_label(HookHandlerType::Command),
hook.execution_mode = hook_execution_mode_label(HookExecutionMode::Sync),
hook.scope = hook_scope_label(scope_for_event(handler.event_name)),
hook.source = hook_source_label(handler.source),
hook.display_order = handler.display_order,
hook.configured_order = configured_order,
hook.timeout_sec = handler.timeout_sec,
hook.command_outcome = tracing::field::Empty,
)
)]
pub(crate) async fn run_command(
shell: &CommandShell,
handler: &ConfiguredHandler,
configured_order: usize,
input_json: &str,
cwd: &Path,
) -> CommandRunResult {
let started_at = chrono::Utc::now().timestamp();
let started = Instant::now();
let mut command = build_command(shell, handler);
command
.current_dir(cwd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = match command.spawn() {
Ok(child) => child,
Err(err) => {
return finish_command_run(
started_at,
started,
CommandRunCompletion {
exit_code: None,
stdout: String::new(),
stderr: String::new(),
error: Some(err.to_string()),
outcome: "spawn_error",
},
);
}
};
if let Some(mut stdin) = child.stdin.take()
&& let Err(err) = stdin.write_all(input_json.as_bytes()).await
&& err.kind() != ErrorKind::BrokenPipe
{
let _ = child.kill().await;
return finish_command_run(
started_at,
started,
CommandRunCompletion {
exit_code: None,
stdout: String::new(),
stderr: String::new(),
error: Some(format!("failed to write hook stdin: {err}")),
outcome: "stdin_error",
},
);
}
let timeout_duration = Duration::from_secs(handler.timeout_sec);
match timeout(timeout_duration, child.wait_with_output()).await {
Ok(Ok(output)) => finish_command_run(
started_at,
started,
CommandRunCompletion {
exit_code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
error: None,
outcome: "completed",
},
),
Ok(Err(err)) => finish_command_run(
started_at,
started,
CommandRunCompletion {
exit_code: None,
stdout: String::new(),
stderr: String::new(),
error: Some(err.to_string()),
outcome: "wait_error",
},
),
Err(_) => finish_command_run(
started_at,
started,
CommandRunCompletion {
exit_code: None,
stdout: String::new(),
stderr: String::new(),
error: Some(format!("hook timed out after {}s", handler.timeout_sec)),
outcome: "timeout",
},
),
}
}
struct CommandRunCompletion {
exit_code: Option<i32>,
stdout: String,
stderr: String,
error: Option<String>,
outcome: &'static str,
}
fn finish_command_run(
started_at: i64,
started: Instant,
completion: CommandRunCompletion,
) -> CommandRunResult {
Span::current().record("hook.command_outcome", completion.outcome);
CommandRunResult {
started_at,
completed_at: chrono::Utc::now().timestamp(),
duration_ms: started.elapsed().as_millis().try_into().unwrap_or(i64::MAX),
exit_code: completion.exit_code,
stdout: completion.stdout,
stderr: completion.stderr,
error: completion.error,
}
}
fn build_command(shell: &CommandShell, handler: &ConfiguredHandler) -> Command {
let mut command = if shell.program.is_empty() {
default_shell_command()
} else {
Command::new(&shell.program)
};
if shell.program.is_empty() {
#[cfg(windows)]
command.raw_arg(format!(r#""{}""#, handler.command));
#[cfg(not(windows))]
command.arg(&handler.command);
} else {
command.args(&shell.args);
#[cfg(windows)]
if shell.args.iter().any(|arg| arg.eq_ignore_ascii_case("/c")) {
command.raw_arg(format!(r#""{}""#, handler.command));
} else {
command.arg(&handler.command);
}
#[cfg(not(windows))]
command.arg(&handler.command);
}
command.envs(&handler.env);
command
}
fn default_shell_command() -> Command {
#[cfg(windows)]
{
let comspec = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string());
let mut command = Command::new(comspec);
command.arg("/C");
command
}
#[cfg(not(windows))]
{
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
let mut command = Command::new(shell);
command.arg("-lc");
command
}
}
#[cfg(test)]
#[path = "command_runner_tests.rs"]
mod tests;