mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
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]
|
||||
|
||||
Reference in new issue
Block a user