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
+44 -9
View File
@@ -56,6 +56,7 @@ const SIGKILL_CODE: i32 = 9;
const TIMEOUT_CODE: i32 = 64;
const EXIT_CODE_SIGNAL_BASE: i32 = 128; // conventional shell: 128 + signal
const EXEC_TIMEOUT_EXIT_CODE: i32 = 124; // conventional timeout exit code
const CANCELLATION_TERMINATION_GRACE_PERIOD: Duration = Duration::from_millis(50);
// I/O buffer sizing
const READ_CHUNK_SIZE: usize = 8192; // bytes per read
@@ -1358,15 +1359,49 @@ async fn consume_output(
(exit_status, false)
}
outcome = &mut expiration_wait => {
kill_child_process_group(&mut child)?;
child.start_kill()?;
let timed_out = matches!(outcome, Some(ExecExpirationOutcome::TimedOut));
let exit_status = if timed_out {
synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + TIMEOUT_CODE)
} else {
synthetic_exit_status_for_code(/*code*/ 1)
};
(exit_status, timed_out)
match outcome {
Some(ExecExpirationOutcome::TimedOut) => {
kill_child_process_group(&mut child)?;
child.start_kill()?;
(
synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + TIMEOUT_CODE),
true,
)
}
Some(ExecExpirationOutcome::Cancelled) => {
// Let TERM-aware processes run cleanup briefly, then kill any
// remaining members of the original process group.
let process_group_id = child.id();
let should_escalate = if let Some(process_group_id) = process_group_id {
codex_utils_pty::process_group::terminate_process_group(process_group_id)?
} else {
false
};
match tokio::time::timeout(
CANCELLATION_TERMINATION_GRACE_PERIOD,
child.wait(),
)
.await
{
Ok(status) => {
status?;
if should_escalate
&& let Some(process_group_id) = process_group_id
{
codex_utils_pty::process_group::kill_process_group(
process_group_id,
)?;
}
}
Err(_) => {
kill_child_process_group(&mut child)?;
child.start_kill()?;
}
}
(synthetic_exit_status_for_code(/*code*/ 1), false)
}
None => unreachable!("expiration wait only resolves while expiration is active"),
}
}
_ = tokio::signal::ctrl_c() => {
kill_child_process_group(&mut child)?;