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);
+}