mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
07a930138f
commit
9152ebd289
@@ -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![
|
||||
|
||||
Reference in New Issue
Block a user