bazel: add PowerShell to Wine test harness (#28120)

## Why

Cross-OS tests in the wine environment will be much more faithful if we
can also test powershell integration.

## What

Add an x86_64 powershell binary to the bazel wine environment and
include smoke tests.
This commit is contained in:
Adam Perry @ OpenAI
2026-06-13 21:52:32 -07:00
committed by GitHub
Unverified
parent 740c4f269d
commit 42dec90bc4
6 changed files with 321 additions and 4 deletions
+13
View File
@@ -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",
],
)
+12 -4
View File
@@ -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()
+1
View File
@@ -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",
+79
View File
@@ -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<WineTestProcess> {
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;
+189
View File
@@ -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<WineTestProcess> {
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::<HashMap<_, _>>();
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::<Vec<_>>();
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(())
}
+27
View File
@@ -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"],
)