diff --git a/bazel/modules/wine.MODULE.bazel b/bazel/modules/wine.MODULE.bazel index d50e8a360..9f60b2ac4 100644 --- a/bazel/modules/wine.MODULE.bazel +++ b/bazel/modules/wine.MODULE.bazel @@ -11,3 +11,16 @@ http_archive( "https://github.com/Kron4ek/Wine-Builds/releases/download/11.0/wine-11.0-amd64-wow64.tar.xz", ], ) + +# Pin the self-contained Windows distribution so Wine tests need neither a +# system PowerShell installation nor a separate .NET runtime. This intentionally +# stays on 7.2.24 for the test fixture: 7.4.16 and 7.6.2 currently fail during +# CLR startup under the pinned Wine 11 runtime, while 7.2.24 runs successfully. +http_archive( + name = "powershell_windows_x86_64", + build_file = "//third_party/powershell:BUILD.bazel", + sha256 = "a1ccb6d8ad52f917470a136c3752af4465f261bcbe570cf44f52aa69ae6e867e", + urls = [ + "https://github.com/PowerShell/PowerShell/releases/download/v7.2.24/PowerShell-7.2.24-win-x64.zip", + ], +) diff --git a/bazel/rules/testing/wine.bzl b/bazel/rules/testing/wine.bzl index ac1f8d317..9f0fbade1 100644 --- a/bazel/rules/testing/wine.bzl +++ b/bazel/rules/testing/wine.bzl @@ -5,6 +5,8 @@ load("//:defs.bzl", "WINDOWS_GNULLVM_RUSTC_LINK_FLAGS") load(":foreign_platform_binary.bzl", "foreign_platform_binary") _WINE_RUNTIME_BINARIES = { + "pwsh": "@powershell_windows_x86_64//:pwsh", + "pwsh-runtime-marker": "@powershell_windows_x86_64//:runtime_marker", "wine": "@wine_linux_x86_64//:wine", "wine-runtime-marker": "@wine_linux_x86_64//:runtime_marker", "wineserver": "@wine_linux_x86_64//:wineserver", @@ -28,6 +30,9 @@ def wine_rust_test( * `CARGO_BIN_EXE_wine` and `CARGO_BIN_EXE_wineserver` identify Wine tools. * `CARGO_BIN_EXE_wine-runtime-marker` identifies a file whose parent is the Wine DLL directory to use as `WINEDLLPATH`. + * `CARGO_BIN_EXE_pwsh` identifies the pinned PowerShell executable and + `CARGO_BIN_EXE_pwsh-runtime-marker` identifies a file whose parent is the + complete PowerShell runtime. These are Bazel runfile locations. Resolve binaries with `codex_utils_cargo_bin::cargo_bin`; `:wine_test_support` resolves the fixed @@ -42,9 +47,14 @@ def wine_rust_test( **kwargs: Remaining attributes forwarded to `rust_test`. """ binaries = dict(_WINE_RUNTIME_BINARIES) + runtime_data = [ + "@powershell_windows_x86_64//:runtime", + "@wine_linux_x86_64//:runtime", + ] + for binary_name in sorted(host_binaries.keys()): if binary_name in binaries: - fail("host test binary name collides with Wine runtime: {}".format(binary_name)) + fail("host test binary name collides with test runtime: {}".format(binary_name)) binaries[binary_name] = host_binaries[binary_name] for index, binary_name in enumerate(sorted(windows_binaries.keys())): @@ -68,9 +78,7 @@ def wine_rust_test( rust_test( name = name, - data = data + [ - "@wine_linux_x86_64//:runtime", - ] + [binary for binary in binaries.values()], + data = data + runtime_data + [binary for binary in binaries.values()], env = { "CARGO_BIN_EXE_{}".format(binary_name): "$(rlocationpath {})".format(binary) for binary_name, binary in binaries.items() diff --git a/bazel/rules/testing/wine/BUILD.bazel b/bazel/rules/testing/wine/BUILD.bazel index 7e5cb1bb1..a19b29fd1 100644 --- a/bazel/rules/testing/wine/BUILD.bazel +++ b/bazel/rules/testing/wine/BUILD.bazel @@ -16,6 +16,7 @@ rust_library( target_compatible_with = ["@platforms//os:linux"], deps = [ "//codex-rs/utils/cargo-bin", + "//codex-rs/utils/pty", "@crates//:anyhow", "@crates//:tempfile", "@crates//:tokio", diff --git a/bazel/rules/testing/wine/src/lib.rs b/bazel/rules/testing/wine/src/lib.rs index 8f5eef1bf..0cc4c6a0b 100644 --- a/bazel/rules/testing/wine/src/lib.rs +++ b/bazel/rules/testing/wine/src/lib.rs @@ -2,6 +2,7 @@ compile_error!("wine_test_support can only run on Linux"); use std::ffi::OsString; +use std::fs; use std::future::Future; use std::io::Write; use std::path::Path; @@ -42,6 +43,7 @@ struct WineProcesses { struct WineRuntimePaths { dll_path: PathBuf, + powershell_runtime: PathBuf, wine: PathBuf, wineserver: PathBuf, } @@ -74,6 +76,7 @@ impl WineTestCommand { pub fn spawn(self) -> Result { let runtime = WineRuntimePaths::from_runfiles()?; let prefix = TempDir::new().context("create isolated Wine prefix")?; + install_powershell_runtime(prefix.path(), &runtime.powershell_runtime)?; let mut command = StdCommand::new(&runtime.wine); configure_wine_environment(&mut command, &runtime, prefix.path()); command @@ -164,8 +167,13 @@ impl WineRuntimePaths { .context("locate Wine runtime directory")? .to_path_buf(); let wineserver = codex_utils_cargo_bin::cargo_bin("wineserver")?; + let powershell_runtime = codex_utils_cargo_bin::cargo_bin("pwsh-runtime-marker")? + .parent() + .context("locate PowerShell runtime directory")? + .to_path_buf(); Ok(Self { dll_path, + powershell_runtime, wine, wineserver, }) @@ -298,6 +306,77 @@ fn configure_wine_environment(command: &mut StdCommand, runtime: &WineRuntimePat .env("TMP", r"C:\windows\temp"); } +/// Installs the complete pinned PowerShell distribution where Windows tooling +/// expects to discover PowerShell 7. +/// +/// `pwsh.exe` is not a standalone executable: it loads its adjacent .NET host, +/// managed assemblies, native libraries, modules, and configuration files at +/// startup. The Bazel archive is exposed through runfiles rather than a normal +/// Windows installation, while shell detection deliberately probes the +/// conventional `C:\Program Files\PowerShell\7` fallback. We therefore have to +/// reproduce the archive's directory tree inside each isolated Wine prefix; +/// copying only the executable would fail before a command could run. +fn install_powershell_runtime(prefix: &Path, runtime: &Path) -> Result<()> { + let powershell_parent = prefix + .join("drive_c") + .join("Program Files") + .join("PowerShell"); + fs::create_dir_all(&powershell_parent).context("create PowerShell installation parent")?; + let destination = powershell_parent.join("7"); + materialize_runtime_directory(runtime, &destination) +} + +/// Recursively reproduces a runfiles directory in a writable Wine prefix. +/// +/// Bazel runfiles may be immutable and may contain the PowerShell distribution +/// on a different filesystem from the temporary prefix. Hard links avoid +/// repeatedly copying the roughly hundred-megabyte runtime when both locations +/// share a filesystem; the copy fallback preserves correctness for sandbox or +/// remote-execution layouts where cross-device hard links are unavailable. +fn materialize_runtime_directory(source: &Path, destination: &Path) -> Result<()> { + fs::create_dir_all(destination).with_context(|| { + format!( + "create PowerShell runtime directory {}", + destination.display() + ) + })?; + for entry in fs::read_dir(source) + .with_context(|| format!("read PowerShell runtime directory {}", source.display()))? + { + let entry = entry.context("read PowerShell runtime entry")?; + let source_path = entry.path(); + let destination_path = destination.join(entry.file_name()); + let file_type = entry + .file_type() + .with_context(|| format!("inspect PowerShell runtime entry {}", source_path.display()))?; + if file_type.is_dir() { + // PowerShell resolves assemblies and modules by their relative + // locations, so flattening the archive is not an option. + materialize_runtime_directory(&source_path, &destination_path)?; + } else if file_type.is_file() { + // A hard link gives each prefix the expected installation layout + // without duplicating the large runtime in the common local case. + if fs::hard_link(&source_path, &destination_path).is_err() { + // Cross-device links are common under Bazel sandboxing and + // remote execution, where an ordinary copy is still valid. + fs::copy(&source_path, &destination_path).with_context(|| { + format!( + "copy PowerShell runtime file {} to {}", + source_path.display(), + destination_path.display() + ) + })?; + } + } else { + anyhow::bail!( + "unsupported PowerShell runtime entry type at {}", + source_path.display() + ); + } + } + Ok(()) +} + #[cfg(test)] #[path = "lib_tests.rs"] mod tests; diff --git a/bazel/rules/testing/wine/src/lib_tests.rs b/bazel/rules/testing/wine/src/lib_tests.rs index 3b11de730..fd8e667fd 100644 --- a/bazel/rules/testing/wine/src/lib_tests.rs +++ b/bazel/rules/testing/wine/src/lib_tests.rs @@ -1,20 +1,29 @@ use std::any::Any; +use std::collections::HashMap; use std::future::Future; +use std::fs; use std::panic::AssertUnwindSafe; use std::path::Path; use std::path::PathBuf; +use std::time::Duration; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; +use codex_utils_pty::SpawnedProcess; +use codex_utils_pty::TerminalSize; use futures::FutureExt; use pretty_assertions::assert_eq; +use tempfile::TempDir; use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; use tokio::process::Command as TokioCommand; +use tokio::time::timeout; use super::WineTestCommand; use super::WineTestProcess; +use super::WineRuntimePaths; +use super::install_powershell_runtime; async fn waiting_smoke_process() -> Result { let executable = codex_utils_cargo_bin::cargo_bin("wine-smoke")?; @@ -230,3 +239,183 @@ async fn shutdown_returns_teardown_error() -> Result<()> { assert_prefix_removed(&prefix); Ok(()) } + +#[test] +fn powershell_runtime_is_materialized_at_the_windows_fallback_path() -> Result<()> { + let prefix = TempDir::new()?; + let runtime = TempDir::new()?; + fs::create_dir(runtime.path().join("Modules"))?; + fs::write(runtime.path().join("pwsh.exe"), b"pwsh")?; + fs::write(runtime.path().join("Modules").join("marker.txt"), b"module")?; + + install_powershell_runtime(prefix.path(), runtime.path())?; + + let installed = prefix + .path() + .join("drive_c") + .join("Program Files") + .join("PowerShell") + .join("7"); + assert_eq!(fs::read(installed.join("pwsh.exe"))?, b"pwsh"); + assert_eq!( + fs::read(installed.join("Modules").join("marker.txt"))?, + b"module" + ); + Ok(()) +} + +#[tokio::test] +async fn pinned_powershell_runs_under_wine_with_a_pty() -> Result<()> { + // Keep this integration smoke test local to the Wine support crate. The + // production-shaped PowerShell launch path belongs to exec-server tests. + // The marker makes the assertion resilient to Wine or PTY startup chatter. + const POWERSHELL_SMOKE_MARKER: &str = "WINE_PWSH_SMOKE"; + // Besides proving that the pinned runtime starts, report the properties + // that shell detection and command construction rely on: PowerShell 7 Core + // running with Windows semantics and a backslash path separator. + const POWERSHELL_SMOKE_SCRIPT: &str = concat!( + "$ErrorActionPreference = 'Stop'; ", + "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ", + "$separatorCode = [int]([System.IO.Path]::DirectorySeparatorChar); ", + "if ($PSVersionTable.PSVersion.Major -ne 7) { throw 'expected PowerShell 7' }; ", + "if ($PSVersionTable.PSEdition -ne 'Core') { throw 'expected PowerShell Core' }; ", + "if (-not $IsWindows) { throw 'expected Windows semantics' }; ", + "if ($separatorCode -ne 92) { throw 'expected backslash path separator' }; ", + "Write-Output ('WINE_PWSH_SMOKE|' + ", + "$PSVersionTable.PSVersion.ToString() + '|' + ", + "$PSVersionTable.PSEdition + '|' + ", + "$IsWindows.ToString().ToLowerInvariant() + '|' + $separatorCode)", + ); + let runtime = WineRuntimePaths::from_runfiles()?; + let prefix = TempDir::new()?; + install_powershell_runtime(prefix.path(), &runtime.powershell_runtime)?; + 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(), + runtime.dll_path.to_string_lossy().into_owned(), + ), + ( + "WINESERVER".to_string(), + runtime.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()), + ]); + let args = [ + r"C:\Program Files\PowerShell\7\pwsh.exe".to_string(), + "-NoLogo".to_string(), + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + POWERSHELL_SMOKE_SCRIPT.to_string(), + ]; + let wine = runtime.wine.to_string_lossy().into_owned(); + let SpawnedProcess { + session, + mut stdout_rx, + mut stderr_rx, + exit_rx, + } = codex_utils_pty::spawn_pty_process( + &wine, + &args, + prefix.path(), + &env, + /*arg0*/ &None, + TerminalSize::default(), + ) + .await?; + let command_result = timeout(Duration::from_secs(30), async { + let stdout = async { + let mut output = Vec::new(); + while let Some(chunk) = stdout_rx.recv().await { + output.extend(chunk); + } + output + }; + let stderr = async { + let mut output = Vec::new(); + while let Some(chunk) = stderr_rx.recv().await { + output.extend(chunk); + } + output + }; + let (stdout, stderr, exit_code) = tokio::join!(stdout, stderr, exit_rx); + Ok::<_, anyhow::Error>((stdout, stderr, exit_code.context("wait for PowerShell")?)) + }) + .await + .context("PowerShell smoke test timed out") + .and_then(std::convert::identity); + drop(session); + let shutdown_result = timeout(Duration::from_secs(10), async { + let mut command = TokioCommand::new(&runtime.wineserver); + command + .args(["-k", "-w"]) + .env("HOME", prefix.path()) + .env("WINEPREFIX", prefix.path()) + .env("XDG_RUNTIME_DIR", prefix.path()) + .kill_on_drop(true); + let status = command.status().await.context("stop isolated wineserver")?; + anyhow::ensure!( + status.success() || status.code() == Some(1), + "wineserver exited with {status}" + ); + Ok::<_, anyhow::Error>(()) + }) + .await + .context("stop isolated wineserver timed out") + .and_then(std::convert::identity); + let (stdout, stderr, exit_code) = match (command_result, shutdown_result) { + (Ok(output), Ok(())) => output, + (Err(error), Ok(())) => return Err(error), + (Ok(_), Err(error)) => return Err(error), + (Err(error), Err(shutdown_error)) => { + return Err(error.context(format!("Wine teardown also failed: {shutdown_error:#}"))); + } + }; + anyhow::ensure!( + exit_code == 0, + "PowerShell exited with {}; stderr: {}", + exit_code, + String::from_utf8_lossy(&stderr), + ); + let output = String::from_utf8(stdout)?; + let marker_start = output + .find(POWERSHELL_SMOKE_MARKER) + .with_context(|| format!("PowerShell smoke marker was missing from {output:?}"))?; + let smoke = output[marker_start..] + .lines() + .next() + .context("PowerShell smoke marker line was incomplete")? + .trim_end_matches('\r'); + let fields = smoke.split('|').collect::>(); + assert_eq!(fields.len(), 5, "unexpected PowerShell smoke output: {smoke}"); + assert_eq!(fields[0], POWERSHELL_SMOKE_MARKER); + assert_eq!( + fields[1].split('.').next(), + Some("7"), + "expected PowerShell 7.x, got {}", + fields[1], + ); + assert_eq!(&fields[2..], &["Core", "true", "92"]); + Ok(()) +} diff --git a/third_party/powershell/BUILD.bazel b/third_party/powershell/BUILD.bazel new file mode 100644 index 000000000..fb5ca91e9 --- /dev/null +++ b/third_party/powershell/BUILD.bazel @@ -0,0 +1,27 @@ +package(default_visibility = ["//visibility:public"]) + +exports_files(["pwsh.exe"]) + +filegroup( + name = "pwsh", + srcs = ["pwsh.exe"], + tags = ["manual"], +) + +# The executable is also a stable marker whose parent is the runtime root. +filegroup( + name = "runtime_marker", + srcs = ["pwsh.exe"], + tags = ["manual"], +) + +filegroup( + name = "runtime", + srcs = glob( + ["**"], + # This BUILD file is also loaded from the source tree, where the + # archive-only paths are intentionally absent. + allow_empty = True, + ), + tags = ["manual"], +)