diff --git a/codex-rs/core/tests/remote_env_windows/BUILD.bazel b/codex-rs/core/tests/remote_env_windows/BUILD.bazel index a620965c89..a4ab18dc3e 100644 --- a/codex-rs/core/tests/remote_env_windows/BUILD.bazel +++ b/codex-rs/core/tests/remote_env_windows/BUILD.bazel @@ -2,10 +2,15 @@ load("//bazel/rules/testing:wine.bzl", "wine_rust_test") wine_rust_test( name = "smoke-test", - timeout = "short", - srcs = ["remote_env_windows_test.rs"], + timeout = "moderate", + srcs = [ + "non_native_cwd_tests.rs", + "remote_env_windows_test.rs", + "wine_exec_server_harness.rs", + ], crate_name = "remote_env_windows_test", crate_root = "remote_env_windows_test.rs", + include_powershell = True, windows_binaries = { "wine-windows-exec-server": "//codex-rs/exec-server/testing:windows-exec-server", }, @@ -16,9 +21,12 @@ wine_rust_test( "//codex-rs/features", "//codex-rs/protocol", "//codex-rs/utils/cargo-bin", + "//codex-rs/utils/path-uri", + "//codex-rs/utils/pty", "@crates//:anyhow", "@crates//:pretty_assertions", "@crates//:serde_json", + "@crates//:tempfile", "@crates//:tokio", ], ) diff --git a/codex-rs/core/tests/remote_env_windows/README.md b/codex-rs/core/tests/remote_env_windows/README.md index 92c9a89739..45c0253e0a 100644 --- a/codex-rs/core/tests/remote_env_windows/README.md +++ b/codex-rs/core/tests/remote_env_windows/README.md @@ -1,8 +1,11 @@ -# Windows remote-environment test +# Windows remote-environment tests -This Bazel-only `test_codex` integration test runs a Windows exec-server fixture -under pinned Wine and exercises the normal model tool-call and remote-execution -path. +This directory cross-builds a small Windows exec-server fixture and runs it +under a pinned Wine runtime from x86-64 Linux Bazel. The Windows fixture links +only `codex-exec-server` because the full Codex Windows graph does not yet +cross-build, and the smaller target is faster to iterate on. The direct smoke +test runs pinned PowerShell and built-in `cmd.exe` through the real exec-server +with Windows cwd URI values. ## Running the test @@ -12,13 +15,18 @@ bazel test \ --test_output=errors ``` -No system Wine is required. Every process gets a fresh `WINEPREFIX` and isolated -wineserver. +No system Wine or apt packages are required. The target is intentionally +limited to x86-64 for simplicity, so Bazel marks it incompatible elsewhere. +Every process gets a fresh `WINEPREFIX` and isolated wineserver. + +The reusable rules and Rust harness live under `//bazel/rules/testing`. Tests +resolve their Windows executable with `codex_utils_cargo_bin::cargo_bin`, then +use `WineTestProcess::scope` for async teardown and panic-safe cleanup. ## Current limitations -- PowerShell and ConPTY/TTY behavior are not yet covered. +- ConPTY/TTY behavior is not yet covered. - Wine loads shared objects and PE DLLs at runtime, so the host must still provide the declared compatible glibc version. -- The target is intentionally limited to x86-64 for simplicity. It can expand - if we find aarch64-specific behavior worth testing. +- ARM64 Linux is intentionally unsupported for now. It is more complicated to + support, and the behavior covered here should not vary by architecture. diff --git a/codex-rs/core/tests/remote_env_windows/non_native_cwd_tests.rs b/codex-rs/core/tests/remote_env_windows/non_native_cwd_tests.rs new file mode 100644 index 0000000000..168e072de8 --- /dev/null +++ b/codex-rs/core/tests/remote_env_windows/non_native_cwd_tests.rs @@ -0,0 +1,159 @@ +use anyhow::Context; +use anyhow::Result; +use codex_exec_server::REMOTE_ENVIRONMENT_ID; +use codex_features::Feature; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::protocol::TurnEnvironmentSelections; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::test_codex; +use core_test_support::test_codex::turn_permission_fields; +use core_test_support::wait_for_event; +use serde_json::json; +use tokio::io::AsyncBufReadExt; +use tokio::io::BufReader; +use wine_test_support::WineTestCommand; + +const CALL_ID: &str = "wine-cmd-smoke"; +const COMMAND: &str = "echo WINE_BAZEL_OK&&cd"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn windows_exec_server_rejects_non_native_cwd_uri() -> Result<()> { + let executable = codex_utils_cargo_bin::cargo_bin("wine-windows-exec-server")?; + let mut exec_server = WineTestCommand::new(executable) + .env("CODEX_HOME", r"C:\codex-home") + .spawn()?; + let stdout = exec_server.take_stdout(); + + exec_server + .scope(async move { + let mut lines = BufReader::new(stdout).lines(); + let exec_server_url = loop { + let line = lines + .next_line() + .await? + .context("Wine exec-server exited before reporting its URL")?; + if line.starts_with("ws://") { + break line; + } + }; + + let server = start_mock_server().await; + let arguments = serde_json::to_string(&json!({ + "cmd": COMMAND, + "login": false, + "yield_time_ms": 5_000, + }))?; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(CALL_ID, "exec_command", &arguments), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let mut builder = test_codex() + .with_model("gpt-5.2") + .with_exec_server_url(exec_server_url) + .with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); + let environments = TurnEnvironmentSelections::new( + test.config.cwd.clone(), + vec![TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: test.config.cwd.clone(), + }], + ); + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "run the Windows smoke command".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + environments: Some(environments), + approval_policy: Some(AskForApproval::Never), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: test.session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, + }) + .await?; + + let mut saw_exec_event = false; + loop { + match wait_for_event(&test.codex, |_| true).await { + EventMsg::ExecCommandBegin(event) if event.call_id == CALL_ID => { + saw_exec_event = true + } + EventMsg::ExecCommandEnd(event) if event.call_id == CALL_ID => { + saw_exec_event = true + } + EventMsg::TurnComplete(_) => break, + _ => {} + } + } + + assert!( + !saw_exec_event, + "a non-native cwd should be rejected before process lifecycle events", + ); + + let request = response_mock + .last_request() + .context("model should receive the rejected command output")?; + let (output, success) = request + .function_call_output_content_and_success(CALL_ID) + .context("rejected command output should be present")?; + let output = output.context("rejected command output should contain text")?; + assert!( + output.contains("exec-server rejected request (-32602)") + && output.contains("cwd URI") + && output.contains("is not valid on this exec-server host"), + "unexpected command output: {output:?}", + ); + assert_ne!(success, Some(true)); + + Ok(()) + }) + .await +} diff --git a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs index b505227b67..bb6486bb17 100644 --- a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs +++ b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs @@ -1,161 +1,203 @@ -//! Bazel-only integration coverage for a Windows exec-server running under Wine. +#[cfg(not(target_os = "linux"))] +compile_error!("the Wine exec-server test can only run on Linux"); + +#[path = "wine_exec_server_harness.rs"] +mod wine_exec_server_harness; + +#[path = "non_native_cwd_tests.rs"] +mod non_native_cwd_tests; + +use std::collections::HashMap; +use std::time::Duration; use anyhow::Context; use anyhow::Result; -use codex_exec_server::REMOTE_ENVIRONMENT_ID; -use codex_features::Feature; -use codex_protocol::models::PermissionProfile; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; -use codex_protocol::protocol::TurnEnvironmentSelection; -use codex_protocol::protocol::TurnEnvironmentSelections; -use codex_protocol::user_input::UserInput; -use core_test_support::responses::ev_assistant_message; -use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_function_call; -use core_test_support::responses::ev_response_created; -use core_test_support::responses::mount_sse_sequence; -use core_test_support::responses::sse; -use core_test_support::responses::start_mock_server; -use core_test_support::test_codex::test_codex; -use core_test_support::test_codex::turn_permission_fields; -use core_test_support::wait_for_event; -use serde_json::json; -use tokio::io::AsyncBufReadExt; -use tokio::io::BufReader; -use wine_test_support::WineTestCommand; +use codex_exec_server::ExecEnvPolicy; +use codex_exec_server::ExecParams; +use codex_exec_server::ExecServerClient; +use codex_exec_server::ProcessId; +use codex_exec_server::ReadParams; +use codex_exec_server::RemoteExecServerConnectArgs; +use codex_protocol::config_types::ShellEnvironmentPolicyInherit; +use codex_utils_path_uri::PathUri; +use pretty_assertions::assert_eq; +use tokio::time::timeout; +use wine_exec_server_harness::POWERSHELL_PATH; +use wine_exec_server_harness::POWERSHELL_VERSION; +use wine_exec_server_harness::WINDOWS_WORKSPACE; +use wine_exec_server_harness::WineExecServer; -const CALL_ID: &str = "wine-cmd-smoke"; -const COMMAND: &str = "echo WINE_BAZEL_OK&&cd"; +const TEST_TIMEOUT: Duration = Duration::from_secs(180); +const POWERSHELL_PREFLIGHT_MARKER: &str = "WINE_PWSH_PREFLIGHT"; +const POWERSHELL_PREFLIGHT_SCRIPT: &str = concat!( + "$ErrorActionPreference = 'Stop'; ", + "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ", + "$separatorCode = [int]([System.IO.Path]::DirectorySeparatorChar); ", + "Write-Output ('WINE_PWSH_PREFLIGHT|' + ", + "$PSVersionTable.PSVersion.ToString() + '|' + ", + "$PSVersionTable.PSEdition + '|' + ", + "$IsWindows.ToString().ToLowerInvariant() + '|' + ", + "(Get-Location).ProviderPath + '|' + $separatorCode)", +); + +struct CommandOutput { + exit_code: Option, + output: String, +} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn windows_exec_server_rejects_non_native_cwd_uri() -> Result<()> { - let executable = codex_utils_cargo_bin::cargo_bin("wine-windows-exec-server")?; - let mut exec_server = WineTestCommand::new(executable) - .env("CODEX_HOME", r"C:\codex-home") - .spawn()?; - let stdout = exec_server.take_stdout(); - - exec_server - .scope(async move { - let mut lines = BufReader::new(stdout).lines(); - let exec_server_url = loop { - let line = lines - .next_line() - .await? - .context("Wine exec-server exited before reporting its URL")?; - if line.starts_with("ws://") { - break line; - } - }; - - let server = start_mock_server().await; - let arguments = serde_json::to_string(&json!({ - "cmd": COMMAND, - "login": false, - "yield_time_ms": 5_000, - }))?; - let response_mock = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_function_call(CALL_ID, "exec_command", &arguments), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - ], - ) - .await; - - let mut builder = test_codex() - .with_model("gpt-5.2") - .with_exec_server_url(exec_server_url) - .with_config(|config| { - config.use_experimental_unified_exec_tool = true; - config - .features - .enable(Feature::UnifiedExec) - .expect("test config should allow feature update"); - }); - let test = builder.build(&server).await?; - let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); - let environments = TurnEnvironmentSelections::new( - test.config.cwd.clone(), - vec![TurnEnvironmentSelection { - environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: test.config.cwd.clone(), - }], - ); - - test.codex - .submit(Op::UserInput { - items: vec![UserInput::Text { - text: "run the Windows smoke command".to_string(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - responsesapi_client_metadata: None, - additional_context: Default::default(), - thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { - environments: Some(environments), - approval_policy: Some(AskForApproval::Never), - sandbox_policy: Some(sandbox_policy), - permission_profile, - collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { - mode: codex_protocol::config_types::ModeKind::Default, - settings: codex_protocol::config_types::Settings { - model: test.session_configured.model.clone(), - reasoning_effort: None, - developer_instructions: None, - }, - }), - ..Default::default() - }, - }) - .await?; - - let mut saw_exec_event = false; - loop { - match wait_for_event(&test.codex, |_| true).await { - EventMsg::ExecCommandBegin(event) if event.call_id == CALL_ID => { - saw_exec_event = true - } - EventMsg::ExecCommandEnd(event) if event.call_id == CALL_ID => { - saw_exec_event = true - } - EventMsg::TurnComplete(_) => break, - _ => {} - } - } - - assert!( - !saw_exec_event, - "a non-native cwd should be rejected before process lifecycle events", - ); - - let request = response_mock - .last_request() - .context("model should receive the rejected command output")?; - let (output, success) = request - .function_call_output_content_and_success(CALL_ID) - .context("rejected command output should be present")?; - let output = output.context("rejected command output should contain text")?; - assert!( - output.contains("exec-server rejected request (-32602)") - && output.contains("cwd URI") - && output.contains("is not valid on this exec-server host"), - "unexpected command output: {output:?}", - ); - assert_ne!(success, Some(true)); - - Ok(()) - }) - .await +async fn windows_exec_server_runs_powershell_and_cmd_under_wine() -> Result<()> { + timeout(TEST_TIMEOUT, async { + let (server, websocket_url) = WineExecServer::start().await?; + let test_result = exercise_exec_server(websocket_url).await; + let shutdown_result = server.shutdown().await; + test_result?; + shutdown_result + }) + .await + .context("Wine exec-server test timed out")? +} + +async fn exercise_exec_server(websocket_url: String) -> Result<()> { + let client = ExecServerClient::connect_websocket(RemoteExecServerConnectArgs::new( + websocket_url, + "wine-windows-bazel-test".to_string(), + )) + .await?; + + let info = client.environment_info().await?; + assert_eq!(info.shell.name, "powershell"); + assert!( + info.shell.path.eq_ignore_ascii_case(POWERSHELL_PATH), + "expected pinned PowerShell path, got {info:?}", + ); + + let powershell = run_non_tty_command( + &client, + "wine-pwsh-preflight", + vec![ + info.shell.path, + "-NoLogo".to_string(), + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + POWERSHELL_PREFLIGHT_SCRIPT.to_string(), + ], + PathUri::parse("file:///C:/workspace")?, + ) + .await?; + assert_eq!( + powershell.exit_code, + Some(0), + "unexpected PowerShell output: {:?}", + powershell.output + ); + let preflight = powershell + .output + .lines() + .find(|line| line.starts_with(POWERSHELL_PREFLIGHT_MARKER)) + .with_context(|| { + format!( + "PowerShell preflight marker was missing from {:?}", + powershell.output + ) + })?; + assert_eq!( + preflight.split('|').collect::>(), + vec![ + POWERSHELL_PREFLIGHT_MARKER, + POWERSHELL_VERSION, + "Core", + "true", + WINDOWS_WORKSPACE, + "92", + ] + ); + + let cmd = run_non_tty_command( + &client, + "wine-cmd-smoke", + vec![ + r"C:\windows\system32\cmd.exe".to_string(), + "/D".to_string(), + "/C".to_string(), + "echo WINE_BAZEL_OK&&cd".to_string(), + ], + PathUri::parse("file:///C:/workspace")?, + ) + .await?; + assert_eq!(cmd.exit_code, Some(0)); + assert!( + cmd.output.contains("WINE_BAZEL_OK"), + "unexpected output: {:?}", + cmd.output + ); + assert!( + cmd.output.contains(WINDOWS_WORKSPACE), + "unexpected output: {:?}", + cmd.output + ); + + Ok(()) +} + +async fn run_non_tty_command( + client: &ExecServerClient, + process_id: &str, + argv: Vec, + cwd: PathUri, +) -> Result { + let process_id = ProcessId::from(process_id); + let response = client + .exec(ExecParams { + process_id: process_id.clone(), + argv, + cwd, + env_policy: Some(ExecEnvPolicy { + inherit: ShellEnvironmentPolicyInherit::Core, + ignore_default_excludes: true, + exclude: Vec::new(), + r#set: HashMap::new(), + include_only: Vec::new(), + }), + env: HashMap::from([ + ("DOTNET_CLI_TELEMETRY_OPTOUT".to_string(), "1".to_string()), + ("DOTNET_NOLOGO".to_string(), "1".to_string()), + ( + "POWERSHELL_TELEMETRY_OPTOUT".to_string(), + "1".to_string(), + ), + ("POWERSHELL_UPDATECHECK".to_string(), "Off".to_string()), + ]), + tty: false, + pipe_stdin: false, + arg0: None, + }) + .await?; + assert_eq!(response.process_id, process_id); + + let mut after_seq = None; + let mut output = Vec::new(); + let exit_code = loop { + let response = client + .read(ReadParams { + process_id: process_id.clone(), + after_seq, + max_bytes: Some(1024 * 1024), + wait_ms: Some(5_000), + }) + .await?; + for chunk in response.chunks { + output.extend(chunk.chunk.into_inner()); + } + if response.closed { + break response.exit_code; + } + after_seq = response.next_seq.checked_sub(1); + }; + + Ok(CommandOutput { + exit_code, + output: String::from_utf8(output)?, + }) } diff --git a/codex-rs/core/tests/remote_env_windows/wine_exec_server_harness.rs b/codex-rs/core/tests/remote_env_windows/wine_exec_server_harness.rs new file mode 100644 index 0000000000..3b3962cf22 --- /dev/null +++ b/codex-rs/core/tests/remote_env_windows/wine_exec_server_harness.rs @@ -0,0 +1,266 @@ +use std::collections::HashMap; +use std::io::Write; +use std::path::PathBuf; +use std::process::Stdio; +use std::time::Duration; +use std::time::Instant; + +use anyhow::Context; +use anyhow::Result; +use codex_utils_pty::ProcessHandle; +use codex_utils_pty::SpawnedProcess; +use codex_utils_pty::TerminalSize; +use tempfile::TempDir; +use tokio::process::Command; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +const START_TIMEOUT: Duration = Duration::from_secs(30); +const BLOCKING_CLEANUP_TIMEOUT: Duration = Duration::from_secs(5); + +pub(crate) const POWERSHELL_PATH: &str = r"C:\Program Files\PowerShell\7\pwsh.exe"; +pub(crate) const POWERSHELL_VERSION: &str = "7.2.24"; +pub(crate) const WINDOWS_WORKSPACE: &str = r"C:\workspace"; + +pub(crate) struct WineExecServer { + processes: Option, +} + +struct WineProcesses { + cleanup_complete: bool, + exit_rx: Option>, + prefix: TempDir, + process: ProcessHandle, + stderr_task: JoinHandle<()>, + stdout_task: JoinHandle<()>, + wineserver: PathBuf, +} + +impl WineExecServer { + pub(crate) async fn start() -> Result<(Self, String)> { + let prefix = TempDir::new().context("create Wine prefix")?; + wine_test_support::install_pinned_powershell_runtime(prefix.path())?; + + let executable = codex_utils_cargo_bin::cargo_bin("wine-windows-exec-server")?; + let wine = codex_utils_cargo_bin::cargo_bin("wine")?; + let wine_runtime_marker = codex_utils_cargo_bin::cargo_bin("wine-runtime-marker")?; + let wine_dll_path = wine_runtime_marker + .parent() + .context("locate Wine runtime directory")?; + let wineserver = codex_utils_cargo_bin::cargo_bin("wineserver")?; + let mut env = std::env::vars().collect::>(); + env.remove("DISPLAY"); + env.extend([ + ("HOME".to_string(), prefix.path().to_string_lossy().into_owned()), + ( + "XDG_RUNTIME_DIR".to_string(), + prefix.path().to_string_lossy().into_owned(), + ), + ("WINEARCH".to_string(), "win64".to_string()), + ( + "WINEPREFIX".to_string(), + prefix.path().to_string_lossy().into_owned(), + ), + ( + "WINEDLLPATH".to_string(), + wine_dll_path.to_string_lossy().into_owned(), + ), + ( + "WINESERVER".to_string(), + wineserver.to_string_lossy().into_owned(), + ), + ("WINEDEBUG".to_string(), "-all".to_string()), + ( + "WINEDLLOVERRIDES".to_string(), + "mscoree,mshtml,winegstreamer=".to_string(), + ), + ("LANG".to_string(), "C.UTF-8".to_string()), + ("LC_ALL".to_string(), "C.UTF-8".to_string()), + ("LC_CTYPE".to_string(), "C.UTF-8".to_string()), + ("TEMP".to_string(), r"C:\windows\temp".to_string()), + ("TMP".to_string(), r"C:\windows\temp".to_string()), + ("CODEX_HOME".to_string(), r"C:\codex-home".to_string()), + ("DOTNET_CLI_TELEMETRY_OPTOUT".to_string(), "1".to_string()), + ("DOTNET_NOLOGO".to_string(), "1".to_string()), + ( + "POWERSHELL_TELEMETRY_OPTOUT".to_string(), + "1".to_string(), + ), + ("POWERSHELL_UPDATECHECK".to_string(), "Off".to_string()), + ]); + let wine = wine.to_string_lossy().into_owned(); + let executable = executable.to_string_lossy().into_owned(); + let SpawnedProcess { + session: process, + mut stdout_rx, + mut stderr_rx, + exit_rx, + } = codex_utils_pty::spawn_pty_process( + &wine, + &[executable], + prefix.path(), + &env, + /*arg0*/ &None, + TerminalSize::default(), + ) + .await + .context("start Windows exec-server under Wine")?; + let stderr_task = tokio::spawn(async move { + while let Some(chunk) = stderr_rx.recv().await { + let _ = std::io::stderr().lock().write_all(&chunk); + } + }); + let websocket_url_result = timeout(START_TIMEOUT, async { + let mut output = Vec::new(); + loop { + let chunk = stdout_rx + .recv() + .await + .context("Wine exec-server exited before reporting its URL")?; + output.extend_from_slice(&chunk); + if output.len() > 64 * 1024 { + output.drain(..output.len() - 64 * 1024); + } + let rendered = String::from_utf8_lossy(&output); + if let Some(start) = rendered.find("ws://") { + let url = &rendered[start..]; + let end = url.find(char::is_whitespace).unwrap_or(url.len()); + return Ok::<_, anyhow::Error>(url[..end].to_string()); + } + } + }) + .await + .context("Wine exec-server startup timed out") + .and_then(std::convert::identity); + let stdout_task = tokio::spawn(async move { while stdout_rx.recv().await.is_some() {} }); + let server = Self { + processes: Some(WineProcesses { + cleanup_complete: false, + exit_rx: Some(exit_rx), + prefix, + process, + stderr_task, + stdout_task, + wineserver, + }), + }; + + match websocket_url_result { + Ok(websocket_url) => Ok((server, websocket_url)), + Err(start_error) => { + if let Err(shutdown_error) = server.shutdown().await { + return Err(start_error.context(format!( + "Wine cleanup after startup failure also failed: {shutdown_error:#}" + ))); + } + Err(start_error) + } + } + } + + pub(crate) async fn shutdown(mut self) -> Result<()> { + let result = self + .processes + .as_mut() + .context("Wine process guard is missing")? + .shutdown() + .await; + self.processes.take(); + result + } +} + +impl Drop for WineExecServer { + fn drop(&mut self) { + if self.processes.is_some() && !std::thread::panicking() { + panic!("WineExecServer dropped without calling async shutdown"); + } + } +} + +impl WineProcesses { + async fn shutdown(&mut self) -> Result<()> { + self.process.request_terminate(); + let wait_result = async { + let exit_rx = self + .exit_rx + .take() + .context("Wine process exit receiver is missing")?; + timeout(START_TIMEOUT, exit_rx) + .await + .context("wait for Windows exec-server process timed out")? + .context("wait for Windows exec-server process")?; + Ok::<_, anyhow::Error>(()) + } + .await; + self.stderr_task.abort(); + self.stdout_task.abort(); + let wineserver_result = timeout(START_TIMEOUT, async { + let status = Command::new(&self.wineserver) + .args(["-k", "-w"]) + .env("HOME", self.prefix.path()) + .env("WINEPREFIX", self.prefix.path()) + .env("XDG_RUNTIME_DIR", self.prefix.path()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await + .context("stop isolated wineserver")?; + anyhow::ensure!(status.success(), "wineserver exited with {status}"); + Ok::<_, anyhow::Error>(()) + }) + .await + .context("stop isolated wineserver timed out") + .and_then(std::convert::identity); + + let result = wait_result.and(wineserver_result); + if result.is_ok() { + self.cleanup_complete = true; + } else { + self.shutdown_blocking(); + } + result + } + + fn shutdown_blocking(&mut self) { + self.stderr_task.abort(); + self.stdout_task.abort(); + self.process.terminate(); + let Ok(mut child) = std::process::Command::new(&self.wineserver) + .arg("-k") + .env("HOME", self.prefix.path()) + .env("WINEPREFIX", self.prefix.path()) + .env("XDG_RUNTIME_DIR", self.prefix.path()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + else { + self.cleanup_complete = true; + return; + }; + let deadline = Instant::now() + BLOCKING_CLEANUP_TIMEOUT; + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(50)); + } + Ok(None) | Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + break; + } + } + } + self.cleanup_complete = true; + } +} + +impl Drop for WineProcesses { + fn drop(&mut self) { + if !self.cleanup_complete && std::thread::panicking() { + self.shutdown_blocking(); + } + } +} diff --git a/codex-rs/exec-server/testing/README.md b/codex-rs/exec-server/testing/README.md index 77f8e990d4..f6d6486300 100644 --- a/codex-rs/exec-server/testing/README.md +++ b/codex-rs/exec-server/testing/README.md @@ -1,5 +1,4 @@ -# Windows exec-server fixture +# Exec-server test fixtures -This directory contains the small Windows exec-server binary used by -foreign-OS tests. It links only `codex-exec-server` because the full Codex -Windows graph does not yet cross-build with Bazel. +This package contains the minimal Windows exec-server binary used by the +cross-platform tests in `//codex-rs/core/tests/remote_env_windows`. diff --git a/codex-rs/exec-server/testing/windows_exec_server.rs b/codex-rs/exec-server/testing/windows_exec_server.rs index 9efc5286fc..c9ad9c05c9 100644 --- a/codex-rs/exec-server/testing/windows_exec_server.rs +++ b/codex-rs/exec-server/testing/windows_exec_server.rs @@ -9,6 +9,7 @@ use codex_exec_server::ExecServerRuntimePaths; #[tokio::main] async fn main() -> Result<(), Box> { + std::fs::create_dir_all(r"C:\workspace")?; let current_exe = std::env::current_exe()?; // This fixture is always a Windows executable, so it neither invokes nor // needs the separate Linux sandbox binary.