fix(linux-sandbox): preserve shell cleanup on interruption (#22729)

## Why
Interrupted `shell_command` calls can race with the outer tool-dispatch
cancellation path. When that happens, the runtime future may be dropped
before the spawned process gets a chance to run `SIGTERM` cleanup. For
bwrapd-backed Linux sandbox commands, that can leave synthetic
protected-path mount bookkeeping such as `.git/.codex` registrations
under `/tmp` behind after a TUI interruption.

The relevant cancellation points are the outer dispatch race in
[`core/src/tools/parallel.rs`](https://github.com/openai/codex/blob/bd184ba84703cc924921ed883f0cf17d3dba60ff/codex-rs/core/src/tools/parallel.rs#L91-L132)
and the process shutdown logic in
[`core/src/exec.rs`](https://github.com/openai/codex/blob/bd184ba84703cc924921ed883f0cf17d3dba60ff/codex-rs/core/src/exec.rs#L1367-L1393).

## What changed
- Keep `shell_command` dispatch alive long enough for the runtime to
finish cancellation cleanup instead of immediately returning the
synthetic aborted response.
- Fold shell-turn cancellation into the existing `ExecExpiration` path
in
[`core/src/tools/runtimes/shell.rs`](https://github.com/openai/codex/blob/bd184ba84703cc924921ed883f0cf17d3dba60ff/codex-rs/core/src/tools/runtimes/shell.rs#L267-L274),
so cancellation and timeout behavior stay centralized.
- On cancellation, send `SIGTERM` first, wait briefly for cleanup to
run, then hard-kill any remaining descendants in the original process
group.
- Treat `ESRCH` as an already-gone process-group cleanup case in
`codex-utils-pty`, which keeps best-effort teardown from surfacing a
stale-process race as an error.

## Verification
- `cargo test -p codex-core cancellation`
- Added regression coverage for:
  - `shell_tool_cancellation_waits_for_runtime_cleanup`
  - `process_exec_tool_call_cancellation_allows_sigterm_cleanup`
This commit is contained in:
viyatb-oai
2026-05-27 12:59:11 -07:00
committed by GitHub
Unverified
parent 07a930138f
commit 9152ebd289
10 changed files with 474 additions and 47 deletions
+109
View File
@@ -1128,6 +1128,115 @@ async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> {
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn process_exec_tool_call_cancellation_allows_sigterm_cleanup() -> Result<()> {
let temp_dir = tempfile::TempDir::new()?;
let ready_marker = temp_dir.path().join("ready");
let cleanup_marker = temp_dir.path().join("cleanup");
let descendant_pid_marker = temp_dir.path().join("descendant-pid");
// The parent handles TERM and records cleanup, while a TERM-ignoring child
// proves cancellation still escalates any survivors in the process group.
let command = vec![
"/bin/sh".to_string(),
"-c".to_string(),
r#"(trap '' TERM; sleep 60) &
printf '%s' "$!" > "$DESCENDANT_PID_MARKER"
trap 'printf cleaned > "$CLEANUP_MARKER"; exit 0' TERM
printf ready > "$READY_MARKER"
while :; do sleep 1; done"#
.to_string(),
];
let cwd = codex_utils_absolute_path::AbsolutePathBuf::current_dir()?;
let mut env: HashMap<String, String> = std::env::vars().collect();
env.insert(
"READY_MARKER".to_string(),
ready_marker.to_string_lossy().into_owned(),
);
env.insert(
"CLEANUP_MARKER".to_string(),
cleanup_marker.to_string_lossy().into_owned(),
);
env.insert(
"DESCENDANT_PID_MARKER".to_string(),
descendant_pid_marker.to_string_lossy().into_owned(),
);
let cancel_token = CancellationToken::new();
let cancel_tx = cancel_token.clone();
tokio::spawn(async move {
for _ in 0..50 {
if ready_marker.exists() {
cancel_tx.cancel();
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
cancel_tx.cancel();
});
let params = ExecParams {
command,
cwd: cwd.clone(),
expiration: ExecExpiration::DefaultTimeout.with_cancellation(cancel_token),
capture_policy: ExecCapturePolicy::ShellTool,
env,
network: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
justification: None,
arg0: None,
};
let result = timeout(
Duration::from_secs(5),
process_exec_tool_call(
params,
&PermissionProfile::Disabled,
&cwd,
&None,
/*use_legacy_landlock*/ false,
/*stdout_stream*/ None,
),
)
.await
.expect("cancellation should stop the process promptly");
let output = result.expect("cancellation should return a non-timeout exec result");
assert!(!output.timed_out);
assert_eq!(
std::fs::read_to_string(cleanup_marker)?,
"cleaned",
"SIGTERM cleanup trap should run before cancellation falls back to a hard kill"
);
let descendant_pid = std::fs::read_to_string(descendant_pid_marker)?
.parse::<i32>()
.map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("failed to parse descendant pid: {error}"),
)
})?;
let mut killed = false;
for _ in 0..20 {
if unsafe { libc::kill(descendant_pid, 0) } == -1
&& let Some(libc::ESRCH) = std::io::Error::last_os_error().raw_os_error()
{
killed = true;
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
if !killed {
unsafe {
libc::kill(descendant_pid, libc::SIGKILL);
}
}
assert!(
killed,
"TERM-ignoring descendant process with pid {descendant_pid} is still alive"
);
Ok(())
}
#[cfg(unix)]
fn long_running_command() -> Vec<String> {
vec![