Terminate Windows process trees with job objects (#34624)

## Why

Terminating a Windows execution session must also stop child processes, while a
normal root-process exit must continue to allow background descendants to run.

## What changed

- Assign Windows pipe, ConPTY, and sandbox processes to job objects and terminate
  the job when a session is cancelled, times out, or is explicitly stopped.
- Preserve descendants when the root process exits normally.
- Attach ConPTY and sandbox processes to their jobs atomically at creation; keep
  root-process termination as a fallback where job setup is unavailable.

## Testing

Added Windows coverage for descendant termination and preservation across pipe,
ConPTY, capture, cancellation, and legacy sandbox execution paths.

GitOrigin-RevId: 8f831f2fc4caaa7b79ce842a3ed7192bd02dd3b4
This commit is contained in:
iceweasel-oai authored and copyberry committed 2026-07-21 22:07:09 +00:00
1 parent ded4eacdbd
commit 9b33613db6
16 files changed
+946 -241

No files matched your search

@@ -3,6 +3,7 @@ use crate::conpty::ConptyInstance;
use crate::conpty::spawn_conpty_process_as_user;
use crate::desktop::LaunchDesktop;
use crate::logging::log_failure;
use crate::logging::log_note;
use crate::logging::log_success;
use crate::process::ConsoleMode;
use crate::process::StderrMode;
@@ -19,6 +20,7 @@ use crate::spawn_prep::prepare_legacy_spawn_context;
use anyhow::Result;
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_pty::JobObject;
use codex_utils_pty::ProcessDriver;
use codex_utils_pty::SpawnedProcess;
use codex_utils_pty::TerminalSize;
@@ -48,6 +50,7 @@ const WAIT_TIMEOUT: u32 = 0x0000_0102;
struct LegacyProcessHandles {
process: PROCESS_INFORMATION,
job: Arc<JobObject>,
output_join: std::thread::JoinHandle<()>,
writer_handle: tokio::task::JoinHandle<()>,
hpc: Option<HANDLE>,
@@ -70,7 +73,7 @@ fn spawn_legacy_process(
writer_rx: mpsc::Receiver<Vec<u8>>,
logs_base_dir: Option<&Path>,
) -> Result<LegacyProcessHandles> {
let (pi, output_join, writer_handle, hpc, conpty_owner, desktop) = if tty {
let (pi, job, output_join, writer_handle, hpc, conpty_owner, desktop) = if tty {
let (pi, mut conpty) = spawn_conpty_process_as_user(
h_token,
command,
@@ -79,6 +82,9 @@ fn spawn_legacy_process(
use_private_desktop,
logs_base_dir,
)?;
let job = conpty
.job()
.ok_or_else(|| anyhow::anyhow!("spawned ConPTY is missing its process job"))?;
let hpc = conpty.raw_handle();
let output_join = spawn_output_reader(conpty.take_output_read(), stdout_tx);
let writer_handle = spawn_input_writer(
@@ -86,7 +92,7 @@ fn spawn_legacy_process(
writer_rx,
/*normalize_newlines*/ true,
);
(pi, output_join, writer_handle, hpc, Some(conpty), None)
(pi, job, output_join, writer_handle, hpc, Some(conpty), None)
} else {
let pipe_handles = spawn_process_with_pipes(
h_token,
@@ -122,6 +128,7 @@ fn spawn_legacy_process(
);
(
pipe_handles.process,
pipe_handles.job(),
output_join,
writer_handle,
None,
@@ -131,6 +138,7 @@ fn spawn_legacy_process(
};
Ok(LegacyProcessHandles {
process: pi,
job,
output_join,
writer_handle,
hpc,
@@ -177,6 +185,31 @@ fn spawn_input_writer(
})
}
fn terminate_job_or_process(
job: &JobObject,
process_handle: &Arc<StdMutex<Option<HANDLE>>>,
logs_base_dir: Option<&Path>,
) {
if let Err(job_err) = job.terminate() {
log_note(
&format!("legacy spawn failed to terminate process tree: {job_err}"),
logs_base_dir,
);
if let Ok(guard) = process_handle.lock()
&& let Some(handle) = guard.as_ref()
&& unsafe { TerminateProcess(*handle, 1) } == 0
{
log_note(
&format!(
"legacy spawn failed to terminate root process: {}",
unsafe { GetLastError() }
),
logs_base_dir,
);
}
}
}
fn write_all_handle(handle: HANDLE, mut bytes: &[u8]) -> Result<()> {
while !bytes.is_empty() {
let mut written = 0u32;
@@ -348,6 +381,7 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy(
let LegacyProcessHandles {
process: pi,
job,
output_join,
writer_handle,
hpc,
@@ -379,20 +413,21 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy(
let process_handle = Arc::new(StdMutex::new(Some(pi.hProcess)));
let wait_handle = Arc::clone(&process_handle);
let job_for_wait = Arc::clone(&job);
let command_for_wait = command.clone();
let hpc_for_wait = hpc_handle.clone();
let wait_logs_base_dir = common.logs_base_dir.clone();
std::thread::spawn(move || {
let _desktop = desktop;
let timeout = timeout_ms.map(|ms| ms as u32).unwrap_or(INFINITE);
let wait_res = unsafe { WaitForSingleObject(pi.hProcess, timeout) };
if wait_res == WAIT_TIMEOUT {
unsafe {
if let Ok(guard) = wait_handle.lock()
&& let Some(handle) = guard.as_ref()
{
let _ = TerminateProcess(*handle, 1);
}
}
terminate_job_or_process(&job_for_wait, &wait_handle, wait_logs_base_dir.as_deref());
} else if let Err(err) = job_for_wait.preserve_descendants() {
log_note(
&format!("legacy spawn failed to preserve descendants after root exit: {err}"),
wait_logs_base_dir.as_deref(),
);
}
if let Some(hpc) = hpc_for_wait
&& let Ok(mut guard) = hpc.lock()
@@ -410,21 +445,17 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy(
wait_handle,
pi.hThread,
output_join,
common.logs_base_dir.as_deref(),
wait_logs_base_dir.as_deref(),
command_for_wait,
);
});
let terminator = {
let job = Arc::clone(&job);
let process_handle = Arc::clone(&process_handle);
let logs_base_dir = common.logs_base_dir;
Some(Box::new(move || {
if let Ok(guard) = process_handle.lock()
&& let Some(handle) = guard.as_ref()
{
unsafe {
let _ = TerminateProcess(*handle, 1);
}
}
terminate_job_or_process(&job, &process_handle, logs_base_dir.as_deref());
}) as Box<dyn FnMut() + Send + Sync>)
};
@@ -6,6 +6,8 @@ use crate::ipc_framed::Message;
use crate::ipc_framed::decode_bytes;
use crate::ipc_framed::read_frame;
use crate::run_windows_sandbox_capture;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_pty::ProcessDriver;
@@ -15,12 +17,14 @@ use std::fs;
use std::fs::OpenOptions;
use std::io::Seek;
use std::io::SeekFrom;
use std::os::windows::io::AsRawHandle;
use std::os::windows::io::FromRawHandle;
use std::os::windows::io::OwnedHandle;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::MutexGuard;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use std::time::Duration;
@@ -31,6 +35,12 @@ use tokio::sync::broadcast;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::time::timeout;
use windows_sys::Win32::Foundation::WAIT_FAILED;
use windows_sys::Win32::Foundation::WAIT_OBJECT_0;
use windows_sys::Win32::Foundation::WAIT_TIMEOUT;
use windows_sys::Win32::System::Threading::OpenProcess;
use windows_sys::Win32::System::Threading::PROCESS_SYNCHRONIZE;
use windows_sys::Win32::System::Threading::WaitForSingleObject;
static TEST_HOME_COUNTER: AtomicU64 = AtomicU64::new(0);
static LEGACY_PROCESS_TEST_LOCK: Mutex<()> = Mutex::new(());
@@ -83,6 +93,66 @@ fn workspace_roots_for(root: &Path) -> Vec<AbsolutePathBuf> {
vec![AbsolutePathBuf::from_absolute_path(root).expect("absolute workspace root")]
}
fn powershell_literal(path: &Path) -> String {
path.to_string_lossy().replace('\'', "''")
}
fn start_powershell_child(
pwsh: &Path,
stdio_dir: &Path,
child_command: &str,
parent_tail: &str,
) -> String {
let encoded = BASE64.encode(
child_command
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect::<Vec<_>>(),
);
format!(
"Start-Process -WindowStyle Hidden -FilePath '{}' -ArgumentList '-NoProfile','-EncodedCommand','{encoded}' -RedirectStandardOutput '{}' -RedirectStandardError '{}'; {parent_tail}",
powershell_literal(pwsh),
powershell_literal(&stdio_dir.join("descendant.stdout")),
powershell_literal(&stdio_dir.join("descendant.stderr")),
)
}
fn wait_for_path(path: &Path, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if path.exists() {
return true;
}
std::thread::sleep(Duration::from_millis(25));
}
path.exists()
}
fn open_process_for_wait(pid: u32) -> std::io::Result<OwnedHandle> {
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, pid) };
if handle == 0 {
return Err(std::io::Error::last_os_error());
}
Ok(unsafe { OwnedHandle::from_raw_handle(handle as _) })
}
fn wait_for_process_exit(process: &OwnedHandle, timeout: Duration) -> std::io::Result<()> {
let timeout_ms = u32::try_from(timeout.as_millis())
.map_err(|_| std::io::Error::other("process wait timeout exceeds u32"))?;
let result = unsafe { WaitForSingleObject(process.as_raw_handle() as _, timeout_ms) };
match result {
WAIT_OBJECT_0 => Ok(()),
WAIT_TIMEOUT => Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"timed out waiting for process to exit",
)),
WAIT_FAILED => Err(std::io::Error::last_os_error()),
result => Err(std::io::Error::other(format!(
"unexpected process wait result: {result}"
))),
}
}
fn wait_for_frame_count(frames_path: &Path, expected_frames: usize) -> Vec<Message> {
let deadline = Instant::now() + Duration::from_secs(2);
loop {
@@ -416,7 +486,7 @@ fn runner_resizer_sends_resize_frame() {
}
#[test]
fn legacy_capture_powershell_emits_output() {
fn legacy_capture_emits_output_and_preserves_descendant_after_normal_exit() {
let Some(pwsh) = pwsh_path() else {
return;
};
@@ -424,6 +494,23 @@ fn legacy_capture_powershell_emits_output() {
let cwd = sandbox_cwd();
let codex_home = sandbox_home("legacy-capture-pwsh");
println!("capture pwsh codex_home={}", codex_home.path().display());
let ready_marker = codex_home.path().join("descendant-started");
let release_marker = codex_home.path().join("release-descendant");
let survival_marker = codex_home.path().join("descendant-survived");
let descendant_command = format!(
"$deadline=(Get-Date).AddSeconds(30); Set-Content -LiteralPath '{}' -Value $PID; while (-not (Test-Path -LiteralPath '{}')) {{ if ((Get-Date) -ge $deadline) {{ exit 3 }}; Start-Sleep -Milliseconds 25 }}; Set-Content -LiteralPath '{}' -Value survived",
powershell_literal(&ready_marker),
powershell_literal(&release_marker),
powershell_literal(&survival_marker),
);
let parent_tail = format!(
"while (-not (Test-Path -LiteralPath '{}')) {{ Start-Sleep -Milliseconds 25 }}",
powershell_literal(&ready_marker),
);
let parent_command = format!(
"Write-Output LEGACY-CAPTURE-DIRECT; {}",
start_powershell_child(&pwsh, codex_home.path(), &descendant_command, &parent_tail,),
);
let permission_profile = PermissionProfile::workspace_write();
let result = run_windows_sandbox_capture(
&permission_profile,
@@ -433,7 +520,7 @@ fn legacy_capture_powershell_emits_output() {
pwsh.display().to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
"Write-Output LEGACY-CAPTURE-DIRECT".to_string(),
parent_command,
],
cwd.as_path(),
HashMap::new(),
@@ -442,6 +529,15 @@ fn legacy_capture_powershell_emits_output() {
/*use_private_desktop*/ true,
)
.expect("run legacy capture powershell");
let descendant_pid = fs::read_to_string(&ready_marker)
.expect("read descendant pid")
.trim()
.parse()
.expect("parse descendant pid");
let descendant_process = open_process_for_wait(descendant_pid);
fs::write(&release_marker, "release").expect("release descendant after root exit");
let descendant_process = descendant_process.expect("open descendant after normal capture exit");
println!("capture pwsh exit_code={}", result.exit_code);
println!("capture pwsh timed_out={}", result.timed_out);
let stdout = String::from_utf8_lossy(&result.stdout);
@@ -452,6 +548,12 @@ fn legacy_capture_powershell_emits_output() {
stdout.contains("LEGACY-CAPTURE-DIRECT"),
"stdout={stdout:?}"
);
assert!(
wait_for_path(&survival_marker, Duration::from_secs(10)),
"sandbox descendant did not survive normal capture exit"
);
wait_for_process_exit(&descendant_process, Duration::from_secs(10))
.expect("sandbox descendant did not exit after release");
}
#[test]
@@ -565,7 +667,7 @@ fn legacy_workspace_write_delete_is_limited_to_writable_roots() {
}
#[test]
fn legacy_capture_cancellation_is_not_reported_as_timeout() {
fn legacy_capture_cancellation_terminates_descendants_without_timeout() {
let Some(pwsh) = pwsh_path() else {
eprintln!("skipping cancellation regression test: PowerShell 7 is not installed");
return;
@@ -573,18 +675,40 @@ fn legacy_capture_cancellation_is_not_reported_as_timeout() {
let _guard = legacy_process_test_guard();
let cwd = sandbox_cwd();
let codex_home = sandbox_home("legacy-capture-cancel");
let permission_profile = PermissionProfile::workspace_write();
let cancelled = Arc::new(AtomicBool::new(false));
let cancelled_for_token = Arc::clone(&cancelled);
let cancellation =
WindowsSandboxCancellationToken::new(move || cancelled_for_token.load(Ordering::SeqCst));
let cancelled_for_thread = Arc::clone(&cancelled);
let cancel_thread = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(200));
cancelled_for_thread.store(true, Ordering::SeqCst);
let descendant_marker = codex_home.path().join("descendant-survived");
let ready_marker = codex_home.path().join("descendant-started");
let descendant_command = format!(
"Set-Content -LiteralPath '{}' -Value $PID; Start-Sleep -Seconds 1; Set-Content -LiteralPath '{}' -Value survived",
powershell_literal(&ready_marker),
powershell_literal(&descendant_marker),
);
let parent_command = start_powershell_child(
&pwsh,
codex_home.path(),
&descendant_command,
"Start-Sleep -Seconds 30",
);
let descendant_process = Arc::new(Mutex::new(None));
let descendant_process_for_cancellation = Arc::clone(&descendant_process);
let cancellation = WindowsSandboxCancellationToken::new(move || {
let Ok(pid) = fs::read_to_string(&ready_marker).and_then(|pid| {
pid.trim()
.parse()
.map_err(|err| std::io::Error::other(format!("invalid descendant pid: {err}")))
}) else {
return false;
};
let Ok(process) = open_process_for_wait(pid) else {
return false;
};
*descendant_process_for_cancellation
.lock()
.expect("descendant process lock poisoned") = Some(process);
true
});
let started_at = Instant::now();
let permission_profile = PermissionProfile::workspace_write();
let result = run_windows_sandbox_capture(
&permission_profile,
workspace_roots_for(cwd.as_path()).as_slice(),
@@ -593,7 +717,7 @@ fn legacy_capture_cancellation_is_not_reported_as_timeout() {
pwsh.display().to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
"Start-Sleep -Seconds 30".to_string(),
parent_command,
],
cwd.as_path(),
HashMap::new(),
@@ -602,7 +726,6 @@ fn legacy_capture_cancellation_is_not_reported_as_timeout() {
/*use_private_desktop*/ true,
)
.expect("run legacy capture powershell with cancellation");
cancel_thread.join().expect("cancel thread should finish");
assert!(
started_at.elapsed() < Duration::from_secs(10),
@@ -613,6 +736,127 @@ fn legacy_capture_cancellation_is_not_reported_as_timeout() {
"cancellation should not be reported as a timeout"
);
assert_ne!(result.exit_code, 0);
let descendant_process = descendant_process
.lock()
.expect("descendant process lock poisoned")
.take()
.expect("cancellation did not capture descendant process");
wait_for_process_exit(&descendant_process, Duration::from_secs(10))
.expect("sandbox descendant did not exit after cancellation");
assert!(
!descendant_marker.exists(),
"sandbox descendant survived cancellation"
);
}
#[derive(Clone, Copy, Debug)]
enum LegacyTtyDescendantLifecycle {
Terminate,
Preserve,
}
async fn assert_legacy_tty_descendant_lifecycle(
pwsh: &Path,
lifecycle: LegacyTtyDescendantLifecycle,
) {
let cwd = sandbox_cwd();
let codex_home = sandbox_home(match lifecycle {
LegacyTtyDescendantLifecycle::Terminate => "legacy-tty-descendant-terminate",
LegacyTtyDescendantLifecycle::Preserve => "legacy-tty-descendant-preserve",
});
let ready_marker = codex_home.path().join("descendant-started");
let release_marker = codex_home.path().join("release-descendant");
let survival_marker = codex_home.path().join("descendant-survived");
let child_tail = match lifecycle {
LegacyTtyDescendantLifecycle::Terminate => "Start-Sleep -Seconds 30".to_string(),
LegacyTtyDescendantLifecycle::Preserve => format!(
"$deadline=(Get-Date).AddSeconds(30); while (-not (Test-Path -LiteralPath '{}')) {{ if ((Get-Date) -ge $deadline) {{ exit 3 }}; Start-Sleep -Milliseconds 25 }}; Set-Content -LiteralPath '{}' -Value survived",
powershell_literal(&release_marker),
powershell_literal(&survival_marker),
),
};
let child_command = format!(
"Set-Content -LiteralPath '{}' -Value $PID; {child_tail}",
powershell_literal(&ready_marker),
);
let parent_tail = match lifecycle {
LegacyTtyDescendantLifecycle::Terminate => "Start-Sleep -Seconds 30".to_string(),
LegacyTtyDescendantLifecycle::Preserve => format!(
"while (-not (Test-Path -LiteralPath '{}')) {{ Start-Sleep -Milliseconds 25 }}",
powershell_literal(&ready_marker),
),
};
let parent_command =
start_powershell_child(pwsh, codex_home.path(), &child_command, &parent_tail);
let permission_profile = PermissionProfile::workspace_write();
let spawned = spawn_windows_sandbox_session_legacy(
&permission_profile,
workspace_roots_for(cwd.as_path()).as_slice(),
codex_home.path(),
vec![
pwsh.display().to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
parent_command,
],
cwd.as_path(),
HashMap::new(),
Some(30_000),
&[],
&[],
/*tty*/ true,
/*stdin_open*/ false,
/*use_private_desktop*/ true,
)
.await
.expect("spawn legacy sandbox ConPTY lifecycle test");
assert!(
wait_for_path(&ready_marker, Duration::from_secs(10)),
"{lifecycle:?} descendant did not start"
);
let descendant_pid = fs::read_to_string(&ready_marker)
.expect("read descendant pid")
.trim()
.parse()
.expect("parse descendant pid");
let descendant_process = open_process_for_wait(descendant_pid);
if matches!(lifecycle, LegacyTtyDescendantLifecycle::Terminate) {
spawned.session.request_terminate();
}
let (_, exit_code) =
collect_stdout_and_exit(spawned, codex_home.path(), Duration::from_secs(15)).await;
if matches!(lifecycle, LegacyTtyDescendantLifecycle::Preserve) {
fs::write(&release_marker, "release").expect("release preserved descendant");
}
let descendant_process = descendant_process.expect("open sandbox ConPTY descendant");
match lifecycle {
LegacyTtyDescendantLifecycle::Terminate => assert_ne!(exit_code, 0),
LegacyTtyDescendantLifecycle::Preserve => {
assert_eq!(exit_code, 0);
assert!(
wait_for_path(&survival_marker, Duration::from_secs(10)),
"sandbox ConPTY descendant did not survive normal exit"
);
}
}
wait_for_process_exit(&descendant_process, Duration::from_secs(10))
.expect("sandbox ConPTY descendant did not exit");
}
#[test]
fn legacy_tty_job_terminates_and_preserves_descendants() {
let Some(pwsh) = pwsh_path() else {
eprintln!("skipping sandbox ConPTY lifecycle test: PowerShell 7 is not installed");
return;
};
let _guard = legacy_process_test_guard();
current_thread_runtime().block_on(async move {
assert_legacy_tty_descendant_lifecycle(&pwsh, LegacyTtyDescendantLifecycle::Terminate)
.await;
assert_legacy_tty_descendant_lifecycle(&pwsh, LegacyTtyDescendantLifecycle::Preserve).await;
});
}
#[test]