Harden Windows sandbox helper cleanup (#40808)

## Why

Windows filesystem operations can complete while their helper process remains
alive, and elevated sandbox processes must not survive a disconnected control
pipe.

## What changed

- Read newline-delimited filesystem helper responses without imposing an
  operation deadline, then bound the time spent reaping the helper.
- Drain helper stderr concurrently, retain a bounded diagnostic on failure, and
  kill helpers that do not exit after responding.
- Terminate the elevated runner's job or process when its input pipe closes or
  fails.

## Testing

Added coverage for delayed responses, noisy stderr, stuck helpers, duplicated
file handles, and termination of elevated descendants after a control-pipe
disconnect.

GitOrigin-RevId: c0d3d5d76e13318de17638df4db71fcfb9a7397f
This commit is contained in:
zm-oai authored and copyberry committed 2026-08-26 06:02:56 +00:00
1 parent 04907ab95a
commit 21c58c90f2
5 files changed
+475 -14

No files matched your search

@@ -10,6 +10,9 @@
#![allow(unsafe_op_in_unsafe_fn)]
mod cwd_junction;
#[cfg(test)]
#[path = "win/input_loop_tests.rs"]
mod input_loop_tests;
use anyhow::Context;
use anyhow::Result;
@@ -442,8 +445,10 @@ fn spawn_input_loop(
loop {
let msg = match read_frame(&mut reader) {
Ok(Some(v)) => v,
Ok(None) => break,
Err(_) => break,
Ok(None) | Err(_) => {
terminate_job_or_process(&job, process, log_dir.as_deref());
break;
}
};
match msg.message {
Message::Stdin { payload } => {
@@ -0,0 +1,85 @@
use std::os::windows::io::AsRawHandle;
use std::os::windows::io::FromRawHandle;
use std::os::windows::io::OwnedHandle;
use std::process::Stdio;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::Duration;
use anyhow::Context;
use codex_utils_pty::JobObject;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
use tokio::process::Command;
use windows_sys::Win32::Foundation::WAIT_OBJECT_0;
use windows_sys::Win32::System::Threading::OpenProcess;
use windows_sys::Win32::System::Threading::PROCESS_SYNCHRONIZE;
use windows_sys::Win32::System::Threading::WaitForSingleObject;
use super::spawn_input_loop;
#[tokio::test]
async fn disconnected_control_pipe_terminates_elevated_helper_and_descendants() -> anyhow::Result<()>
{
let mut command = Command::new("python");
command
.args([
"-u",
"-c",
"import subprocess,sys; child=subprocess.Popen([sys.executable,'-c','import time; time.sleep(60)']); print(child.pid,flush=True); child.wait()",
])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let job = Arc::new(JobObject::create_without_breakaway()?);
let mut helper = job.spawn_contained(&mut command)?;
let stdout = helper.stdout.take().context("helper stdout")?;
let mut stdout = BufReader::new(stdout);
let mut descendant_pid = String::new();
tokio::time::timeout(
Duration::from_secs(/*secs*/ 10),
stdout.read_line(&mut descendant_pid),
)
.await??;
let descendant_pid = descendant_pid.trim().parse()?;
let descendant = unsafe {
OpenProcess(
PROCESS_SYNCHRONIZE,
/*bInheritHandle*/ 0,
descendant_pid,
)
};
anyhow::ensure!(descendant != 0, "failed to open sandbox descendant");
let descendant = unsafe { OwnedHandle::from_raw_handle(descendant as _) };
let process = helper.raw_handle().context("helper process handle")? as _;
let input_loop = spawn_input_loop(
tempfile::tempfile()?,
/*stdin_handle*/ None,
Arc::new(Mutex::new(None)),
Arc::clone(&job),
process,
/*log_dir*/ None,
);
input_loop
.join()
.expect("elevated runner input loop should stop after pipe disconnect");
let status = tokio::time::timeout(Duration::from_secs(/*secs*/ 2), helper.wait())
.await
.context("elevated helper survived control-pipe disconnect")??;
anyhow::ensure!(!status.success(), "elevated helper unexpectedly succeeded");
let descendant_status = unsafe {
WaitForSingleObject(
descendant.as_raw_handle() as _,
/*dwMilliseconds*/ 2_000,
)
};
anyhow::ensure!(
descendant_status == WAIT_OBJECT_0,
"elevated descendant survived control-pipe disconnect"
);
Ok(())
}