Compare commits

...

4 Commits

Author SHA1 Message Date
Eric Traut
32b91240e0 Merge branch 'main' into etraut/windows-flake 2026-04-13 10:04:01 -07:00
Eric Traut
db59ffdfb1 Merge branch 'main' into etraut/windows-flake 2026-04-13 09:26:06 -07:00
Eric Traut
aafa120db5 Explain shell timeout race handling 2026-04-13 00:18:30 -07:00
Eric Traut
f7cb13fe84 Fix shell timeout race on Windows 2026-04-12 23:48:07 -07:00
3 changed files with 62 additions and 9 deletions

View File

@@ -1888,10 +1888,10 @@ fn realtime_tool_ok_command() -> Vec<String> {
#[cfg(windows)]
{
vec![
"powershell.exe".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
"[Console]::Write('realtime-tool-ok')".to_string(),
"cmd.exe".to_string(),
"/D".to_string(),
"/C".to_string(),
"echo|set /p dummy=realtime-tool-ok".to_string(),
]
}

View File

@@ -1242,15 +1242,23 @@ async fn consume_output(
};
tokio::pin!(expiration_wait);
let (exit_status, timed_out) = tokio::select! {
biased;
_ = &mut expiration_wait => {
// The expiration future can win the race after the child has already
// exited but before `child.wait()` is polled. Preserve the real exit
// status in that case instead of reporting a synthetic timeout.
if let Some(exit_status) = child.try_wait()? {
(exit_status, false)
} else {
kill_child_process_group(&mut child)?;
child.start_kill()?;
(synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + TIMEOUT_CODE), true)
}
}
status_result = child.wait() => {
let exit_status = status_result?;
(exit_status, false)
}
_ = &mut expiration_wait => {
kill_child_process_group(&mut child)?;
child.start_kill()?;
(synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + TIMEOUT_CODE), true)
}
_ = tokio::signal::ctrl_c() => {
kill_child_process_group(&mut child)?;
child.start_kill()?;

View File

@@ -291,6 +291,51 @@ async fn exec_full_buffer_capture_ignores_expiration() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn exec_expiration_observes_already_exited_child_before_timing_out() -> Result<()> {
#[cfg(windows)]
let command = vec![
"cmd.exe".to_string(),
"/D".to_string(),
"/C".to_string(),
"echo|set /p dummy=hello".to_string(),
];
#[cfg(not(windows))]
let command = vec!["printf".to_string(), "hello".to_string()];
let cancel_token = CancellationToken::new();
cancel_token.cancel();
let output = exec(
ExecParams {
command,
cwd: codex_utils_absolute_path::AbsolutePathBuf::current_dir()?,
expiration: ExecExpiration::Cancellation(cancel_token),
capture_policy: ExecCapturePolicy::ShellTool,
env: std::env::vars().collect(),
network: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
justification: None,
arg0: None,
},
SandboxType::None,
&SandboxPolicy::DangerFullAccess,
&FileSystemSandboxPolicy::unrestricted(),
/*windows_sandbox_filesystem_overrides*/ None,
NetworkSandboxPolicy::Enabled,
/*stdout_stream*/ None,
Some(Box::new(|| std::thread::sleep(Duration::from_secs(1)))),
)
.await?;
assert_eq!(output.stdout.from_utf8_lossy().text, "hello");
assert_eq!(output.exit_status.code(), Some(0));
assert!(!output.timed_out);
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn exec_full_buffer_capture_keeps_io_drain_timeout_when_descendant_holds_pipe_open()