From 862df2765b270072550dc062dccf20fd1f3a7a53 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Tue, 29 Sep 2026 09:48:59 -0700 Subject: [PATCH] [WSLC] Add live-host state-aware provision tests to the Rust and .NET SDKs Neither the Rust nor the .NET SDK had a test that provisioned a WSLC sandbox; their coverage stopped at request and envelope construction, so only the Node SDK exercised the real in-process path. Rust gets `src/core/mxc-sdk/tests/wslc_state_aware.rs` and .NET gets `MxcWslcLifecycleE2ETests` with a `WslcHost` gate modelled on the isolation-session suites. Both drive provision, start, exec, stop, and deprovision through their own binding, assert the minted id, the workload's stdout and exit code, a non-zero exit, and that a deprovisioned id is refused. Both deprovision on the unwind path so a failed assertion cannot leak a container, and both serialize: the .NET class joins the existing MxcLiveHost collection, and the Rust tests take a process-wide lock. A Rust test binary built with `--features wslc` could not see the WSLC backend at all. Cargo stages `wslcsdk.dll` and `wxc-wslc-daemon.exe` into `target//`, but an integration test runs from `target//deps/`, and both the DLL loader and the daemon lookup search beside the running binary. `available_backends()` therefore dropped `wslc`, a skip-if-unavailable gate would have skipped forever on a working host, and the suite would have reported green while testing nothing. The suite now copies both units beside its own binary before the first probe, comparing content rather than timestamps because every rebuild re-copies an identical DLL and re-staging one a live daemon has loaded fails. Skips are loud and `MXC_WSLC_TESTS_REQUIRED=1` turns them into failures, matching `MXC_ISO_TESTS_REQUIRED`. `MXC_WSLC_TEST_IMAGE` selects the image; the default `alpine:latest` is expected to be cached, since pulling is already covered by the WSLC executor E2E suite. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../MxcWslcLifecycleE2ETests.cs | 170 +++++++++++ .../Microsoft.Mxc.Sdk.Tests/WslcHost.cs | 43 +++ sdk/dotnet/README.md | 8 +- src/core/mxc-sdk/README.md | 22 ++ src/core/mxc-sdk/tests/wslc_state_aware.rs | 279 ++++++++++++++++++ 5 files changed, 521 insertions(+), 1 deletion(-) create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcWslcLifecycleE2ETests.cs create mode 100644 sdk/dotnet/Microsoft.Mxc.Sdk.Tests/WslcHost.cs create mode 100644 src/core/mxc-sdk/tests/wslc_state_aware.rs diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcWslcLifecycleE2ETests.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcWslcLifecycleE2ETests.cs new file mode 100644 index 000000000..e25a89fda --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/MxcWslcLifecycleE2ETests.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mxc.Sdk; +using Xunit; + +namespace Microsoft.Mxc.Sdk.Tests; + +/// +/// Drives the state-aware lifecycle against a live WSLC host, which needs +/// Windows, a host running WSL2 with the WSLC runtime, and a native library +/// built with the wslc feature. +/// +/// +/// These establish that the lifecycle works end to end through this binding +/// rather than only through the engine: the id provision mints, and the output +/// and exit code an exec returns. The image is expected to be cached already; +/// pulling is covered by the WSLC executor E2E suite. +/// +[Collection("MxcLiveHost")] +public class MxcWslcLifecycleE2ETests +{ + /// + /// Deprovisions a sandbox the test did not deprovision itself. Provision + /// mints a real container, so an assertion that throws part-way would + /// otherwise leave it on the host. + /// + private sealed class Teardown : IDisposable + { + private SandboxId? _id; + + public Teardown(SandboxId id) => _id = id; + + /// + /// Gives up ownership once the test has deprovisioned itself. + /// Deprovision is not idempotent, so without this the disposal below + /// would report a failure that did not happen. + /// + public void Defuse() => _id = null; + + public void Dispose() + { + if (_id is not { } id) + { + return; + } + _id = null; + try + { + MxcLifecycle.StopSandbox(id); + } + catch (MxcException) + { + // A sandbox that never started, or already stopped, still has to + // be deprovisioned — that is the step that frees the container. + } + try + { + MxcLifecycle.DeprovisionSandbox(id); + } + catch (MxcException e) + { + Console.Error.WriteLine( + $"WARNING: deprovision failed, the container may leak: {e.Message}"); + } + } + } + + private sealed record Started(SandboxId Id, Teardown Teardown); + + private static Started Provision() + { + var provisioned = MxcLifecycle.ProvisionSandbox( + StateAwareContainment.Wslc, + new WslcProvisionOptions { Image = WslcHost.Image }); + return new Started(provisioned.SandboxId, new Teardown(provisioned.SandboxId)); + } + + private static Started ProvisionAndStart() + { + var started = Provision(); + try + { + MxcLifecycle.StartSandbox(started.Id); + return started; + } + catch + { + started.Teardown.Dispose(); + throw; + } + } + + /// Provision reaches the backend through this binding and mints an + /// id the later phases accept. + [Fact] + public void Provision_ReturnsAUsableSandboxId() + { + WslcHost.Require(); + + var started = Provision(); + using (started.Teardown) + { + // Post-provision phases resolve the backend from this prefix. + Assert.StartsWith("wslc:", started.Id.Value, StringComparison.Ordinal); + + MxcLifecycle.StartSandbox(started.Id); + } + } + + /// The lifecycle runs a command and its output reaches the caller. + [Fact] + public async Task Lifecycle_RunsEndToEnd() + { + WslcHost.Require(); + + var started = ProvisionAndStart(); + using (started.Teardown) + { + var run = await MxcLifecycle.ExecInSandboxAsync( + started.Id, + "echo dotnet-wslc-marker", + TestContext.Current.CancellationToken); + + Assert.False(run.TimedOut); + Assert.Equal(0, run.ExitCode); + Assert.Contains("dotnet-wslc-marker", run.Stdout); + } + } + + /// The sandboxed process's exit code must reach the caller + /// unchanged, through the streaming handle's wait. + [Fact] + public void Exec_PropagatesANonZeroExitCode() + { + WslcHost.Require(); + + var started = ProvisionAndStart(); + using (started.Teardown) + { + using var proc = MxcLifecycle.ExecInSandbox(started.Id, "exit 7"); + var result = proc.Wait(); + + Assert.False(result.TimedOut); + Assert.Equal(7, result.ExitCode); + } + } + + /// + /// Stop and deprovision must be reachable through this binding, and a + /// deprovisioned id must not still be usable. + /// + [Fact] + public void Deprovision_RetiresTheSandboxId() + { + WslcHost.Require(); + + var started = ProvisionAndStart(); + using (started.Teardown) + { + MxcLifecycle.StopSandbox(started.Id); + MxcLifecycle.DeprovisionSandbox(started.Id); + started.Teardown.Defuse(); + + var ex = Assert.Throws( + () => MxcLifecycle.StartSandbox(started.Id)); + Assert.Equal(ErrorCode.NotProvisioned, ex.Code); + } + } +} diff --git a/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/WslcHost.cs b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/WslcHost.cs new file mode 100644 index 000000000..0ff79a810 --- /dev/null +++ b/sdk/dotnet/Microsoft.Mxc.Sdk.Tests/WslcHost.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mxc.Sdk; +using Xunit; + +namespace Microsoft.Mxc.Sdk.Tests; + +/// +/// The live-host gate the WSLC suite shares. A live run needs Windows, a host +/// running WSL2 with the WSLC runtime, and a native library built with the +/// wslc feature. +/// +internal static class WslcHost +{ + // Evaluated once: this answer decides failure versus skip, so it has to be + // the same for every test that consults it. + private static readonly Lazy Available = new(() => + MxcSandbox.GetAvailableBackends() + .Any(b => b.Backend == ContainmentBackend.Wslc)); + + // Without this, a run in which everything skipped is indistinguishable from + // one that passed. The same variable the Rust WSLC suite honours. + private static bool SkipsAreFailures => + Environment.GetEnvironmentVariable("MXC_WSLC_TESTS_REQUIRED") is "1" or "true"; + + internal static string Image => + Environment.GetEnvironmentVariable("MXC_WSLC_TEST_IMAGE") ?? "alpine:latest"; + + /// Skips the calling test when the backend is unavailable, or fails + /// it when skips have been declared failures. + internal static void Require() + { + Assert.False( + SkipsAreFailures && !Available.Value, + "MXC_WSLC_TESTS_REQUIRED is set, but GetAvailableBackends() does not " + + "report the WSLC backend. That needs both a build with " + + "MxcWithWslc and a host running WSL2 with the WSLC runtime."); + Assert.SkipUnless( + Available.Value, + "GetAvailableBackends() does not report the WSLC backend"); + } +} diff --git a/sdk/dotnet/README.md b/sdk/dotnet/README.md index ef58b4b74..8f7e2e3c0 100644 --- a/sdk/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -747,7 +747,13 @@ exception messages and stack traces. See The isolation-session end-to-end tests skip unless `GetAvailableBackends()` reports that backend, which needs both a build with `-p:MxcWithIsolationSession=true` and a host running the OS-side service. Set - `MXC_ISO_TESTS_REQUIRED=1` (or `true`) to turn those skips into failures. + `MXC_ISO_TESTS_REQUIRED=1` (or `true`) to turn those skips into failures. The + WSLC end-to-end tests work the same way against + `-p:MxcWithWslc=true` and a host running WSL2 with the WSLC runtime, with + `MXC_WSLC_TESTS_REQUIRED=1` as their gate and `MXC_WSLC_TEST_IMAGE` selecting + the image to provision from (the default `alpine:latest` is expected to be + cached already). Both live-host suites share one xUnit collection, so they + never run concurrently. Build/test everything: `dotnet test --solution sdk/dotnet/Microsoft.Mxc.Sdk.slnx`. diff --git a/src/core/mxc-sdk/README.md b/src/core/mxc-sdk/README.md index 7c29bb11d..409e75119 100644 --- a/src/core/mxc-sdk/README.md +++ b/src/core/mxc-sdk/README.md @@ -624,6 +624,28 @@ host probe fails closed, which is why [`platform_support`] drops `"wslc"`, and a `Containment::Wslc` run then fails with an error naming the directory it searched. +#### Host-gated tests + +`tests/wslc_state_aware.rs` drives the live lifecycle — provision, start, exec, +deprovision — against a host running WSL2 with the WSLC runtime. A cargo +integration test runs from `target//deps/`, one directory below where +the profile build stages the runtime files, so the suite copies both of them +beside its own binary first. Without that copy the host probe finds no +`wslcsdk.dll`, drops `"wslc"`, and every test skips on a host that can run it. + +Build the daemon before running the suite, because `--features wslc` does not +pull it into the dependency graph: + +```text +cargo build -p wxc_wslc_daemon -p mxc-sdk --features mxc-sdk/wslc +cargo test -p mxc-sdk --features wslc --test wslc_state_aware +``` + +| Variable | Effect | +|----------|--------| +| `MXC_WSLC_TESTS_REQUIRED=1` | Turns every skip into a failure, so a run that tested nothing is distinguishable from one that passed. | +| `MXC_WSLC_TEST_IMAGE` | Selects the image to provision from; the default `alpine:latest` is expected to be cached already. | + ## Telemetry consent MXC only ever collects telemetry on Windows, and only after the end user has diff --git a/src/core/mxc-sdk/tests/wslc_state_aware.rs b/src/core/mxc-sdk/tests/wslc_state_aware.rs new file mode 100644 index 000000000..6df5f1eb9 --- /dev/null +++ b/src/core/mxc-sdk/tests/wslc_state_aware.rs @@ -0,0 +1,279 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! WSLC state-aware lifecycle reachability from the Rust SDK. +//! +//! Host-dependent tests skip when the backend is unavailable, so this file is +//! safe to run on any host. They assume the image is already cached; pulling is +//! covered by the WSLC executor E2E suite. + +#![cfg(all(target_os = "windows", feature = "wslc"))] + +use std::path::Path; +use std::sync::{Mutex, MutexGuard, OnceLock}; + +use mxc_sdk::{ + sandbox, ErrorCode, ExecRequest, LifecycleRequest, OperationOptions, Output, ProvisionRequest, + SandboxId, WaitOutcome, +}; + +const VERSION: &str = "0.9.0-alpha"; + +const EXEC_TIMEOUT_MS: u32 = 60_000; + +const RUNTIME_UNITS: [&str; 2] = ["wslcsdk.dll", "wxc-wslc-daemon.exe"]; + +/// Cargo stages the WSLC runtime units one directory above `deps/`, where this +/// test binary runs. +fn stage_runtime_units() -> Result<(), String> { + let exe = + std::env::current_exe().map_err(|e| format!("cannot locate this test binary: {e}"))?; + let test_dir = exe + .parent() + .ok_or_else(|| format!("{} has no parent directory", exe.display()))?; + let profile_dir = test_dir + .parent() + .ok_or_else(|| format!("{} has no parent directory", test_dir.display()))?; + + for unit in RUNTIME_UNITS { + let source = profile_dir.join(unit); + let staged = test_dir.join(unit); + if !source.exists() { + if staged.exists() { + continue; + } + return Err(format!( + "{unit} is beside neither this test binary nor {}; produce both units with \ + `cargo build -p wxc_wslc_daemon -p mxc-sdk --features mxc-sdk/wslc`", + profile_dir.display() + )); + } + if is_already_staged(&source, &staged) { + continue; + } + std::fs::copy(&source, &staged).map_err(|e| { + format!( + "cannot stage {} over {}: {e}. A daemon left running by an earlier run holds \ + its own image open; stop it or wait out its idle timeout", + source.display(), + staged.display() + ) + })?; + } + Ok(()) +} + +/// Compares content rather than timestamps, because every rebuild re-copies an +/// identical `wslcsdk.dll` into the profile directory. +fn is_already_staged(source: &Path, staged: &Path) -> bool { + let Ok(source) = std::fs::read(source) else { + return false; + }; + let Ok(staged) = std::fs::read(staged) else { + return false; + }; + source == staged +} + +fn staging() -> &'static Result<(), String> { + static STAGED: OnceLock> = OnceLock::new(); + STAGED.get_or_init(stage_runtime_units) +} + +fn host_supports_wslc() -> bool { + mxc_sdk::available_backends() + .iter() + .any(|b| b.backend == "wslc") +} + +/// A skipped test reports as a pass, so a fully-skipped suite looks like one +/// that ran. `MXC_WSLC_TESTS_REQUIRED=1` turns every skip into a failure. +fn skips_are_failures() -> bool { + matches!( + std::env::var("MXC_WSLC_TESTS_REQUIRED").as_deref(), + Ok("1") | Ok("true") + ) +} + +macro_rules! skip_unless_supported { + () => { + if let Err(reason) = staging() { + assert!( + !skips_are_failures(), + "MXC_WSLC_TESTS_REQUIRED is set, but the WSLC runtime units are not beside \ + this test binary: {reason}" + ); + eprintln!("skipping: {reason} (set MXC_WSLC_TESTS_REQUIRED=1 to make skips fail)"); + return; + } + if !host_supports_wslc() { + assert!( + !skips_are_failures(), + "MXC_WSLC_TESTS_REQUIRED is set, but available_backends() does not report the \ + WSLC backend. That needs a build with the wslc feature and a host running \ + WSL2 with the WSLC runtime." + ); + eprintln!( + "skipping: WSLC is not available on this host \ + (set MXC_WSLC_TESTS_REQUIRED=1 to make skips fail)" + ); + return; + } + }; +} + +/// Concurrent WSLC sessions sharing an image store fail against each other. +fn host_lock() -> MutexGuard<'static, ()> { + static LOCK: Mutex<()> = Mutex::new(()); + LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// Provision mints a real container; a failure before deprovision leaves it on +/// the host. +struct Teardown(Option); + +impl Teardown { + /// Gives up ownership after the test has deprovisioned itself, which is not + /// idempotent — a second call fails the id as unprovisioned. + fn defuse(mut self) { + self.0 = None; + } +} + +impl Drop for Teardown { + fn drop(&mut self) { + let Some(id) = self.0.take() else { + return; + }; + let _ = sandbox::stop( + &id, + LifecycleRequest::new(VERSION), + OperationOptions::default(), + ); + if let Err(e) = sandbox::deprovision( + &id, + LifecycleRequest::new(VERSION), + OperationOptions::default(), + ) { + eprintln!("WARNING: deprovision of {id} failed, the container may leak: {e:?}"); + } + } +} + +fn test_image() -> String { + std::env::var("MXC_WSLC_TEST_IMAGE").unwrap_or_else(|_| "alpine:latest".to_string()) +} + +fn provision() -> (SandboxId, Teardown) { + let request = ProvisionRequest::wslc(VERSION, Some(test_image()), None); + let provisioned = sandbox::provision(request, OperationOptions::default()) + .expect("provision must succeed with the test image cached"); + let sandbox_id = provisioned.sandbox_id; + let teardown = Teardown(Some(sandbox_id.clone())); + (sandbox_id, teardown) +} + +fn provision_and_start() -> (SandboxId, Teardown) { + let (sandbox_id, teardown) = provision(); + sandbox::start( + &sandbox_id, + LifecycleRequest::new(VERSION), + OperationOptions::default(), + ) + .expect("start must succeed"); + (sandbox_id, teardown) +} + +fn exec_capture(sandbox_id: &SandboxId, command: &str) -> Output { + let mut request = ExecRequest::new(VERSION, command); + request.set_timeout(EXEC_TIMEOUT_MS); + sandbox::exec(sandbox_id, request, OperationOptions::default()) + .expect("exec must return a handle") + .wait_with_output() + .expect("waiting on the exec must succeed") +} + +/// Staging is what makes the backend reachable from a test binary. +#[test] +fn available_backends_reports_wslc_to_a_test_binary() { + skip_unless_supported!(); + + let reported: Vec = mxc_sdk::available_backends() + .iter() + .map(|b| b.backend.clone()) + .collect(); + assert!( + reported.iter().any(|backend| backend == "wslc"), + "available_backends() reported {reported:?}" + ); +} + +#[test] +fn provision_returns_a_usable_sandbox_id() { + skip_unless_supported!(); + let _serialized = host_lock(); + + let (sandbox_id, _teardown) = provision(); + + // Post-provision phases resolve the backend from this prefix. + assert!( + sandbox_id.as_str().starts_with("wslc:"), + "provision returned {sandbox_id}" + ); +} + +#[test] +fn start_and_exec_return_the_workload_output() { + skip_unless_supported!(); + let _serialized = host_lock(); + + let (sandbox_id, _teardown) = provision_and_start(); + let output = exec_capture(&sandbox_id, "echo rust-wslc-marker"); + + assert_eq!(output.outcome, WaitOutcome::Exited(0)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("rust-wslc-marker"), + "exec stdout did not carry the marker, got: {stdout:?}" + ); +} + +#[test] +fn exec_propagates_a_non_zero_exit_code() { + skip_unless_supported!(); + let _serialized = host_lock(); + + let (sandbox_id, _teardown) = provision_and_start(); + let output = exec_capture(&sandbox_id, "exit 7"); + + assert_eq!(output.outcome, WaitOutcome::Exited(7)); +} + +#[test] +fn deprovision_retires_the_sandbox_id() { + skip_unless_supported!(); + let _serialized = host_lock(); + + let (sandbox_id, teardown) = provision_and_start(); + sandbox::stop( + &sandbox_id, + LifecycleRequest::new(VERSION), + OperationOptions::default(), + ) + .expect("stop must succeed"); + sandbox::deprovision( + &sandbox_id, + LifecycleRequest::new(VERSION), + OperationOptions::default(), + ) + .expect("deprovision must succeed"); + teardown.defuse(); + + let error = sandbox::start( + &sandbox_id, + LifecycleRequest::new(VERSION), + OperationOptions::default(), + ) + .expect_err("a deprovisioned id must not start"); + assert_eq!(error.code, ErrorCode::NotProvisioned); +}