Terminate Windows non-TTY processes on interrupt (#35655)

## Why

Windows non-TTY exec sessions reported interrupts as unsupported, so sending
Ctrl-C through `write_stdin` did not stop the running process.

## What changed

- Route interrupt requests for Windows non-TTY processes through their existing
  termination callback, including pipe-backed processes.
- Track whether driver-backed Windows sessions use a TTY so PTY interrupts keep
  their existing behavior.
- Consume the terminator after a successful interrupt to avoid invoking it again
  when the process handle is dropped.

## Testing

Added coverage for local and remote exec-server sessions, unified exec, pipe
fallbacks, and legacy Windows sandbox processes.

GitOrigin-RevId: 34504d01f091ef57bb961e98ad5a8d9f1acee4ee
This commit is contained in:
iceweasel-oai authored and copyberry committed 2026-07-27 18:37:11 +00:00
1 parent 9f82ffed1b
commit 6b23635a7e
10 files changed
+151 -49

No files matched your search

+13 -17
View File
@@ -2199,9 +2199,14 @@ async fn assert_write_stdin_ctrl_c_interrupts_non_tty_session(
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[cfg_attr(not(windows), ignore = "Windows-only unified exec interrupt test")] async fn write_stdin_ctrl_c_terminates_non_tty_session_on_windows() -> Result<()> {
async fn write_stdin_ctrl_c_reports_unsupported_interrupt_to_model_on_windows() -> Result<()> { if core_test_support::test_target_os() != core_test_support::TestTargetOs::Windows {
skip_if_no_network!(Ok(())); return Ok(());
}
skip_if_wine_exec!(
Ok(()),
"Wine exits Windows non-TTY shell processes immediately"
);
skip_if_sandbox!(Ok(())); skip_if_sandbox!(Ok(()));
let server = start_mock_server().await; let server = start_mock_server().await;
@@ -2218,8 +2223,7 @@ async fn write_stdin_ctrl_c_reports_unsupported_interrupt_to_model_on_windows()
let interrupt_call_id = "uexec-windows-interrupt"; let interrupt_call_id = "uexec-windows-interrupt";
let start_args = serde_json::json!({ let start_args = serde_json::json!({
"shell": "cmd", "cmd": "Start-Sleep -Seconds 30",
"cmd": "echo READY && ping -n 30 127.0.0.1 >NUL",
"yield_time_ms": 250, "yield_time_ms": 250,
"tty": false, "tty": false,
}); });
@@ -2277,23 +2281,15 @@ async fn write_stdin_ctrl_c_reports_unsupported_interrupt_to_model_on_windows()
Some("1000"), Some("1000"),
"exec_command should leave a running non-TTY session" "exec_command should leave a running non-TTY session"
); );
assert!(
start_output.output.contains("READY"),
"start output should include command readiness marker, got {:?}",
start_output.output
);
let interrupt_output = request_log let interrupt_output = request_log
.function_call_output_text(interrupt_call_id) .function_call_output_text(interrupt_call_id)
.expect("missing interrupt output for write_stdin"); .expect("missing interrupt output for write_stdin");
let interrupt_output = parse_unified_exec_output(&interrupt_output)?;
assert!( assert!(
interrupt_output.contains("write_stdin failed"), interrupt_output.process_id.is_none(),
"model-visible write_stdin output should report failure, got {interrupt_output:?}" "interrupted process should be cleared from the session map"
);
assert!(
interrupt_output.contains("process interrupt is not supported by this process backend"),
"model-visible write_stdin output should explain unsupported interrupt, got {interrupt_output:?}"
); );
assert_eq!(interrupt_output.exit_code, Some(1));
Ok(()) Ok(())
} }
@@ -1764,6 +1764,8 @@ mod tests {
terminator: None, terminator: None,
writer_handle: None, writer_handle: None,
resizer: None, resizer: None,
#[cfg(windows)]
tty: false,
}) })
.session .session
} }
+9 -17
View File
@@ -932,7 +932,7 @@ async fn assert_exec_process_signal_interrupts_process(use_remote: bool) -> Resu
Ok(()) Ok(())
} }
async fn assert_exec_process_signal_reports_unsupported_on_windows(use_remote: bool) -> Result<()> { async fn assert_exec_process_signal_terminates_on_windows(use_remote: bool) -> Result<()> {
let context = create_process_context(use_remote).await?; let context = create_process_context(use_remote).await?;
let session = context let session = context
.backend .backend
@@ -956,21 +956,13 @@ async fn assert_exec_process_signal_reports_unsupported_on_windows(use_remote: b
}) })
.await?; .await?;
let err = match session.process.signal(ProcessSignal::Interrupt).await { let StartedExecProcess { process, .. } = session;
Ok(()) => anyhow::bail!("Windows non-TTY signal should report unsupported"), let wake_rx = process.subscribe_wake();
Err(err) => err, process.signal(ProcessSignal::Interrupt).await?;
}; let (_output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?;
let message = err.to_string();
assert!(
message.contains("failed to signal process"),
"unexpected signal error: {message}"
);
assert!(
message.contains("process interrupt is not supported by this process backend"),
"unexpected signal error: {message}"
);
session.process.terminate().await?; assert_eq!(exit_code, Some(1));
assert!(closed);
Ok(()) Ok(())
} }
@@ -1268,8 +1260,8 @@ async fn exec_process_signal_interrupts_process(use_remote: bool) -> Result<()>
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// Serialize tests that launch a real exec-server process through the full CLI. // Serialize tests that launch a real exec-server process through the full CLI.
#[serial_test::serial(remote_exec_server)] #[serial_test::serial(remote_exec_server)]
async fn exec_process_signal_reports_unsupported_on_windows(use_remote: bool) -> Result<()> { async fn exec_process_signal_terminates_on_windows(use_remote: bool) -> Result<()> {
assert_exec_process_signal_reports_unsupported_on_windows(use_remote).await assert_exec_process_signal_terminates_on_windows(use_remote).await
} }
#[test_case(false ; "local")] #[test_case(false ; "local")]
+6 -1
View File
@@ -48,7 +48,12 @@ impl ChildTerminator for PipeChildTerminator {
crate::process_group::interrupt_process_group(self.process_group_id) crate::process_group::interrupt_process_group(self.process_group_id)
} }
#[cfg(not(unix))] #[cfg(windows)]
{
self.kill()
}
#[cfg(not(any(unix, windows)))]
{ {
Err(crate::process::unsupported_signal(signal)) Err(crate::process::unsupported_signal(signal))
} }
+2 -2
View File
@@ -1,7 +1,7 @@
use super::*; use super::*;
#[test] #[test]
fn process_fallback_terminates_root() -> anyhow::Result<()> { fn process_fallback_interrupt_terminates_root() -> anyhow::Result<()> {
let mut child = std::process::Command::new("ping.exe") let mut child = std::process::Command::new("ping.exe")
.args(["-n", "60", "127.0.0.1"]) .args(["-n", "60", "127.0.0.1"])
.stdin(Stdio::null()) .stdin(Stdio::null())
@@ -12,7 +12,7 @@ fn process_fallback_terminates_root() -> anyhow::Result<()> {
windows: WindowsChildTerminator::Process(child.id()), windows: WindowsChildTerminator::Process(child.id()),
}; };
terminator.kill()?; terminator.signal(ProcessSignal::Interrupt)?;
assert!(!child.wait()?.success()); assert!(!child.wait()?.success());
Ok(()) Ok(())
+26 -2
View File
@@ -234,7 +234,12 @@ impl ProcessHandle {
return Ok(()); return Ok(());
}; };
killer.signal(signal) let result = killer.signal(signal);
#[cfg(windows)]
if result.is_ok() {
killer_opt.take();
}
result
} }
/// Attempts to kill the child and abort helper tasks. /// Attempts to kill the child and abort helper tasks.
@@ -273,10 +278,17 @@ impl Drop for ProcessHandle {
/// Adapts a closure into a `ChildTerminator` implementation. /// Adapts a closure into a `ChildTerminator` implementation.
struct ClosureTerminator { struct ClosureTerminator {
inner: Option<Box<dyn FnMut() + Send + Sync>>, inner: Option<Box<dyn FnMut() + Send + Sync>>,
#[cfg(windows)]
interrupt_terminates: bool,
} }
impl ChildTerminator for ClosureTerminator { impl ChildTerminator for ClosureTerminator {
fn signal(&mut self, signal: ProcessSignal) -> io::Result<()> { fn signal(&mut self, signal: ProcessSignal) -> io::Result<()> {
#[cfg(windows)]
if self.interrupt_terminates {
return self.kill();
}
Err(unsupported_signal(signal)) Err(unsupported_signal(signal))
} }
@@ -356,6 +368,9 @@ pub struct ProcessDriver {
pub terminator: Option<Box<dyn FnMut() + Send + Sync>>, pub terminator: Option<Box<dyn FnMut() + Send + Sync>>,
pub writer_handle: Option<JoinHandle<()>>, pub writer_handle: Option<JoinHandle<()>>,
pub resizer: Option<ResizeFn>, pub resizer: Option<ResizeFn>,
/// Whether this Windows process is attached to a pseudo-console.
#[cfg(windows)]
pub tty: bool,
} }
/// Build a `SpawnedProcess` from a driver that supplies stdin/output/exit channels. /// Build a `SpawnedProcess` from a driver that supplies stdin/output/exit channels.
@@ -368,8 +383,13 @@ pub fn spawn_from_driver(driver: ProcessDriver) -> SpawnedProcess {
terminator, terminator,
writer_handle, writer_handle,
resizer, resizer,
#[cfg(windows)]
tty,
} = driver; } = driver;
#[cfg(windows)]
let interrupt_terminates = terminator.is_some() && !tty;
let (stdout_tx, stdout_rx) = mpsc::channel::<Vec<u8>>(256); let (stdout_tx, stdout_rx) = mpsc::channel::<Vec<u8>>(256);
let (stderr_tx, stderr_rx) = mpsc::channel::<Vec<u8>>(256); let (stderr_tx, stderr_rx) = mpsc::channel::<Vec<u8>>(256);
let (exit_seen_tx, exit_seen_rx) = watch::channel(false); let (exit_seen_tx, exit_seen_rx) = watch::channel(false);
@@ -433,7 +453,11 @@ pub fn spawn_from_driver(driver: ProcessDriver) -> SpawnedProcess {
let handle = ProcessHandle::new( let handle = ProcessHandle::new(
writer_tx, writer_tx,
Box::new(ClosureTerminator { inner: terminator }), Box::new(ClosureTerminator {
inner: terminator,
#[cfg(windows)]
interrupt_terminates,
}),
reader_handle, reader_handle,
stderr_reader_handle stderr_reader_handle
.map(|handle| handle.abort_handle()) .map(|handle| handle.abort_handle())
+50 -1
View File
@@ -4,6 +4,7 @@ use std::path::Path;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use crate::ProcessDriver; use crate::ProcessDriver;
use crate::ProcessSignal;
use crate::SpawnedProcess; use crate::SpawnedProcess;
use crate::TerminalSize; use crate::TerminalSize;
use crate::combine_output_receivers; use crate::combine_output_receivers;
@@ -614,7 +615,14 @@ async fn driver_backed_process_can_expose_split_stdout_and_stderr() -> anyhow::R
terminator: None, terminator: None,
writer_handle: None, writer_handle: None,
resizer: None, resizer: None,
#[cfg(windows)]
tty: false,
}); });
let error = spawned
.session
.signal(ProcessSignal::Interrupt)
.expect_err("interrupting a driver without a terminator should remain unsupported");
assert_eq!(error.kind(), std::io::ErrorKind::Unsupported);
let SpawnedProcess { let SpawnedProcess {
session: _session, session: _session,
@@ -650,6 +658,37 @@ async fn driver_backed_process_can_expose_split_stdout_and_stderr() -> anyhow::R
Ok(()) Ok(())
} }
#[cfg(windows)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn driver_backed_interrupt_terminates_once() {
let terminations = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let callback_terminations = std::sync::Arc::clone(&terminations);
let (writer_tx, _writer_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(1);
let (_stdout_tx, stdout_rx) = tokio::sync::broadcast::channel::<Vec<u8>>(1);
let (_exit_tx, exit_rx) = tokio::sync::oneshot::channel::<i32>();
let spawned = spawn_from_driver(ProcessDriver {
writer_tx,
stdout_rx,
stderr_rx: None,
exit_rx,
terminator: Some(Box::new(move || {
callback_terminations.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
})),
writer_handle: None,
resizer: None,
#[cfg(windows)]
tty: false,
});
spawned
.session
.signal(ProcessSignal::Interrupt)
.expect("interrupt should terminate the driver-backed process");
drop(spawned.session);
assert_eq!(terminations.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn driver_backed_process_can_resize_via_resizer_hook() -> anyhow::Result<()> { async fn driver_backed_process_can_resize_via_resizer_hook() -> anyhow::Result<()> {
let (writer_tx, _writer_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(1); let (writer_tx, _writer_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(1);
@@ -663,7 +702,7 @@ async fn driver_backed_process_can_resize_via_resizer_hook() -> anyhow::Result<(
stdout_rx: stdout_driver_rx, stdout_rx: stdout_driver_rx,
stderr_rx: None, stderr_rx: None,
exit_rx, exit_rx,
terminator: None, terminator: Some(Box::new(|| {})),
writer_handle: None, writer_handle: None,
resizer: Some(Box::new(move |size| { resizer: Some(Box::new(move |size| {
if let Ok(mut guard) = size_tx.lock() if let Ok(mut guard) = size_tx.lock()
@@ -673,8 +712,16 @@ async fn driver_backed_process_can_resize_via_resizer_hook() -> anyhow::Result<(
} }
Ok(()) Ok(())
})), })),
#[cfg(windows)]
tty: true,
}); });
let error = spawned
.session
.signal(ProcessSignal::Interrupt)
.expect_err("interrupting a PTY-backed driver should remain unsupported");
assert_eq!(error.kind(), std::io::ErrorKind::Unsupported);
spawned.session.resize(TerminalSize { spawned.session.resize(TerminalSize {
rows: 40, rows: 40,
cols: 120, cols: 120,
@@ -711,6 +758,8 @@ async fn driver_backed_process_drains_output_that_arrives_after_exit_signal() ->
terminator: None, terminator: None,
writer_handle: None, writer_handle: None,
resizer: None, resizer: None,
#[cfg(windows)]
tty: false,
}); });
let SpawnedProcess { let SpawnedProcess {
@@ -232,6 +232,7 @@ pub(crate) async fn spawn_windows_sandbox_session_elevated_for_permission_profil
} else { } else {
None None
}, },
tty,
}, },
stdin_open, stdin_open,
)) ))
@@ -470,6 +470,7 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy(
Box::new(move |size| resize_conpty_handle(&hpc, size)) Box::new(move |size| resize_conpty_handle(&hpc, size))
as Box<dyn FnMut(TerminalSize) -> Result<()> + Send> as Box<dyn FnMut(TerminalSize) -> Result<()> + Send>
}), }),
tty,
}; };
Ok(finish_driver_spawn(driver, stdin_open)) Ok(finish_driver_spawn(driver, stdin_open))
@@ -12,6 +12,7 @@ use base64::engine::general_purpose::STANDARD as BASE64;
use codex_protocol::models::PermissionProfile; use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_pty::ProcessDriver; use codex_utils_pty::ProcessDriver;
use codex_utils_pty::ProcessSignal;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
@@ -345,7 +346,7 @@ fn legacy_non_tty_cmd_rejects_deny_read_overrides() {
} }
#[test] #[test]
fn legacy_non_tty_powershell_emits_output() { fn legacy_non_tty_powershell_interrupt_terminates_process() {
let Some(pwsh) = pwsh_path() else { let Some(pwsh) = pwsh_path() else {
return; return;
}; };
@@ -364,11 +365,11 @@ fn legacy_non_tty_powershell_emits_output() {
pwsh.display().to_string(), pwsh.display().to_string(),
"-NoProfile".to_string(), "-NoProfile".to_string(),
"-Command".to_string(), "-Command".to_string(),
"Write-Output LEGACY-NONTTY-DIRECT".to_string(), "Write-Output LEGACY-NONTTY-DIRECT; [System.Threading.ManualResetEvent]::new($false).WaitOne()".to_string(),
], ],
cwd.as_path(), cwd.as_path(),
HashMap::new(), HashMap::new(),
Some(5_000), /*timeout_ms*/ None,
&[], &[],
&[], &[],
/*tty*/ false, /*tty*/ false,
@@ -378,12 +379,41 @@ fn legacy_non_tty_powershell_emits_output() {
.await .await
.expect("spawn legacy non-tty powershell session"); .expect("spawn legacy non-tty powershell session");
println!("pwsh spawn returned"); println!("pwsh spawn returned");
let (stdout, exit_code) = let codex_utils_pty::SpawnedProcess {
collect_stdout_and_exit(spawned, codex_home.path(), Duration::from_secs(10)).await; session,
println!("pwsh collect returned exit_code={exit_code}"); mut stdout_rx,
let stdout = String::from_utf8_lossy(&stdout); stderr_rx: _stderr_rx,
assert_eq!(exit_code, 0, "stdout={stdout:?}"); exit_rx,
assert!(stdout.contains("LEGACY-NONTTY-DIRECT"), "stdout={stdout:?}"); } = spawned;
timeout(Duration::from_secs(5), async {
let mut stdout = Vec::new();
while let Some(chunk) = stdout_rx.recv().await {
stdout.extend(chunk);
if String::from_utf8_lossy(&stdout).contains("LEGACY-NONTTY-DIRECT") {
return;
}
}
panic!(
"PowerShell exited before emitting its readiness marker\n{}",
sandbox_log(codex_home.path())
);
})
.await
.unwrap_or_else(|_| {
panic!(
"timed out waiting for PowerShell readiness marker\n{}",
sandbox_log(codex_home.path())
)
});
session
.signal(ProcessSignal::Interrupt)
.expect("interrupt should terminate the restricted-token process job");
let exit_code = timeout(Duration::from_secs(5), exit_rx)
.await
.unwrap_or_else(|_| panic!("timed out waiting for exit\n{}", sandbox_log(codex_home.path())))
.expect("interrupted process should report an exit code");
assert_eq!(exit_code, 1);
}); });
} }
@@ -405,6 +435,7 @@ fn finish_driver_spawn_keeps_stdin_open_when_requested() {
terminator: None, terminator: None,
writer_handle: None, writer_handle: None,
resizer: None, resizer: None,
tty: false,
}, },
/*stdin_open*/ true, /*stdin_open*/ true,
); );
@@ -437,6 +468,7 @@ fn finish_driver_spawn_closes_stdin_when_not_requested() {
terminator: None, terminator: None,
writer_handle: None, writer_handle: None,
resizer: None, resizer: None,
tty: false,
}, },
/*stdin_open*/ false, /*stdin_open*/ false,
); );