2026-07-27 22:02:34 -07:00
|
|
|
#if USE_WSLC
|
2026-07-29 02:52:41 -07:00
|
|
|
using System.Net.NetworkInformation;
|
|
|
|
|
using System.Net.Sockets;
|
|
|
|
|
using System.Runtime.InteropServices;
|
|
|
|
|
using Sdk = Microsoft.WSL.Containers;
|
|
|
|
|
using Windows.Storage.Streams;
|
2026-07-27 22:02:34 -07:00
|
|
|
|
|
|
|
|
namespace NucleicBroker.Wslc;
|
|
|
|
|
|
2026-07-29 02:52:41 -07:00
|
|
|
/// <summary>
|
|
|
|
|
/// The real <c>Microsoft.WSL.Containers</c> adapter (docs/WINDOWS_PORT.md §1.5, §3.2), compiled
|
|
|
|
|
/// only with <c>-p:UseWslc=true</c>.
|
|
|
|
|
///
|
|
|
|
|
/// Written against the **measured** 2.9.3 surface, not the documentation: Microsoft Learn's own
|
|
|
|
|
/// C# sample uses <c>MemoryMB</c>, <c>CmdLine</c> and <c>DeleteContainerFlags</c>, none of which
|
|
|
|
|
/// exist in the shipped assembly. Re-derive with <c>windows/spikes/WslcApiDump</c> after any
|
|
|
|
|
/// version bump — that is now its whole job.
|
|
|
|
|
///
|
|
|
|
|
/// Everything the SDK is aliased as <c>Sdk</c> deliberately: <c>ImageInfo</c>,
|
|
|
|
|
/// <c>ContainerInfo</c>, <c>ProcessSettings</c> and <c>Signal</c> all exist BOTH here and in the
|
|
|
|
|
/// parent <c>NucleicBroker</c> namespace, and an unqualified <c>using</c> makes each one
|
|
|
|
|
/// ambiguous at the point of use.
|
|
|
|
|
///
|
|
|
|
|
/// **What this facade cannot do, and why that is reported rather than hidden.** The SDK projects
|
|
|
|
|
/// <c>WSLCCompat.idl</c>, the deliberately-stable SDK-facing COM surface, and that surface
|
|
|
|
|
/// genuinely lacks container enumeration, per-container stats, pty/resize, and any way to
|
|
|
|
|
/// re-adopt a running session or container. Two consequences run through this whole file:
|
|
|
|
|
///
|
|
|
|
|
/// 1. <c>Sdk.Container</c> has **no Name property** and <c>Sdk.Session</c> has no
|
|
|
|
|
/// <c>GetContainers()</c>, so a container is reachable only through the handle
|
|
|
|
|
/// <c>CreateContainer</c> returned. This class therefore keeps its own name→handle roster,
|
|
|
|
|
/// and that roster — not the service — is what <c>container.list</c> answers from.
|
|
|
|
|
/// 2. The roster dies with the process. A restarted broker cannot see, address or re-adopt
|
|
|
|
|
/// anything it created before, and <c>session.ensure</c> fails
|
|
|
|
|
/// <see cref="WslcError.SessionExists"/> because the compat <c>Start()</c> refuses a name that
|
|
|
|
|
/// is already running (confirmed on hardware, §13.1). That is the D13 reattach gap.
|
|
|
|
|
///
|
|
|
|
|
/// Both are declared through <see cref="Capabilities"/> so hostd degrades from the `hello`
|
|
|
|
|
/// exchange instead of discovering them at a call site. Closing them needs the service-internal
|
|
|
|
|
/// COM interface; see docs/WINDOWS_PORT.md §13.1 for what that costs and what still gates it.
|
|
|
|
|
///
|
|
|
|
|
/// WslcService (components) → Session (VM host, images) → Container → Process.
|
|
|
|
|
/// </summary>
|
2026-07-27 22:02:34 -07:00
|
|
|
public sealed class WslcFacade : IWslc
|
|
|
|
|
{
|
|
|
|
|
private IBrokerEvents? events;
|
2026-07-29 02:52:41 -07:00
|
|
|
private Sdk.Session? session;
|
|
|
|
|
private string? gateway;
|
2026-07-27 22:02:34 -07:00
|
|
|
private readonly SemaphoreSlim sessionGate = new(1, 1);
|
|
|
|
|
|
2026-07-29 04:04:16 -07:00
|
|
|
/// D13's Tier 1 internal-COM arm, or null where it could not bind. Only used to recover an
|
|
|
|
|
/// orphaned session after a broker restart; every other call rides the compat SDK.
|
|
|
|
|
private readonly WslcInternal? recovery = WslcInternal.TryBind();
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Construct the facade with COM security configured first.
|
|
|
|
|
///
|
|
|
|
|
/// The ordering is load-bearing and easy to lose: `CoInitializeSecurity` must precede the
|
|
|
|
|
/// **first COM call in the process**, and the compat SDK makes its own. Doing it in a factory
|
|
|
|
|
/// keeps that constraint next to the code that depends on it rather than in `Program.cs`,
|
|
|
|
|
/// where a later reorder would silently break session recovery with a `0x80070542` that reads
|
|
|
|
|
/// as "not found".
|
|
|
|
|
/// </summary>
|
|
|
|
|
public static WslcFacade Create()
|
|
|
|
|
{
|
|
|
|
|
WslcInternal.InitializeSecurity();
|
|
|
|
|
return new WslcFacade();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 02:52:41 -07:00
|
|
|
/// name → the handle CreateContainer returned. See the class remarks: without this there is
|
|
|
|
|
/// no way to address a container at all, because Sdk.Container carries no name.
|
|
|
|
|
private readonly Dictionary<string, Entry> containers = [];
|
|
|
|
|
private readonly Lock containersLock = new();
|
|
|
|
|
|
|
|
|
|
private sealed record Entry(Sdk.Container Container, string Image);
|
|
|
|
|
|
|
|
|
|
public string? WslcVersion
|
|
|
|
|
{
|
|
|
|
|
get
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var version = Sdk.WslcService.GetVersion();
|
|
|
|
|
// The projection doesn't override ToString(), so the default renders the type
|
|
|
|
|
// name — format the triple by hand or `hello` reports a class name as a version.
|
|
|
|
|
return $"{version.Major}.{version.Minor}.{version.Revision}";
|
|
|
|
|
}
|
|
|
|
|
catch (Exception)
|
|
|
|
|
{
|
|
|
|
|
// A missing/too-old WSL throws here. That is an onboarding condition (§8), not a
|
|
|
|
|
// broker failure: null is exactly the "wslc absent" signal `hello` is meant to
|
|
|
|
|
// carry, and components.missing explains it properly.
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 04:04:16 -07:00
|
|
|
/// <summary>
|
|
|
|
|
/// Stats are always offered — not from the SDK (there is no `GetStatistics()`) but from an
|
|
|
|
|
/// in-guest cgroup read, which is indistinguishable to hostd. `recover` is added when D13's
|
|
|
|
|
/// Tier 1 arm bound: a broker restart re-adopts and clears its orphaned session instead of
|
|
|
|
|
/// leaving the user a sandbox only `wsl --shutdown` can fix.
|
|
|
|
|
///
|
|
|
|
|
/// Still absent, and deliberately: `enumerate` (`container.list` answers from this broker's
|
|
|
|
|
/// own roster, not the service), `reattach` (containers do not survive a restart — Tier 2,
|
|
|
|
|
/// blocked, §13.2) and `tty` (the compat surface has no pty).
|
|
|
|
|
/// </summary>
|
|
|
|
|
public IReadOnlyList<string> Capabilities =>
|
|
|
|
|
recovery is not null ? ["stats", "recover"] : ["stats"];
|
2026-07-27 22:02:34 -07:00
|
|
|
|
|
|
|
|
public void SetEvents(IBrokerEvents events) => this.events = events;
|
|
|
|
|
|
2026-07-29 02:52:41 -07:00
|
|
|
// MARK: - Components (§8 onboarding)
|
|
|
|
|
|
2026-07-27 22:02:34 -07:00
|
|
|
public Task<IReadOnlyList<string>> MissingComponentsAsync(CancellationToken ct)
|
|
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
// A LIST of Component, not a flags enum — and it answers from OS feature state, so it
|
|
|
|
|
// works even when the service class isn't registered. That makes it the one call that
|
|
|
|
|
// explains every other failure.
|
|
|
|
|
var missing = Sdk.WslcService.GetMissingComponents().Select(c => c.ToString()).ToList();
|
2026-07-27 22:02:34 -07:00
|
|
|
return Task.FromResult<IReadOnlyList<string>>(missing);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task InstallComponentsAsync(CancellationToken ct)
|
|
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
var operation = Sdk.WslcService.InstallWithDependenciesAsync();
|
|
|
|
|
operation.Progress = (_, progress) => events?.InstallProgress(
|
|
|
|
|
progress.Component.ToString(),
|
|
|
|
|
progress.Total == 0 ? 0 : 100.0 * progress.Progress / progress.Total);
|
|
|
|
|
await operation.AsTask(ct).ConfigureAwait(false);
|
2026-07-27 22:02:34 -07:00
|
|
|
events?.InstallProgress("done", 100);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 02:52:41 -07:00
|
|
|
// MARK: - Session
|
|
|
|
|
|
2026-07-27 22:02:34 -07:00
|
|
|
public async Task<string> EnsureSessionAsync(SessionSpec spec, CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
await sessionGate.WaitAsync(ct).ConfigureAwait(false);
|
|
|
|
|
try
|
|
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
if (session is not null) return gateway!;
|
|
|
|
|
|
2026-07-29 04:04:16 -07:00
|
|
|
var created = NewSession(spec);
|
2026-07-29 02:52:41 -07:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
created.Start();
|
|
|
|
|
}
|
|
|
|
|
catch (Exception e) when (HResultOf(e) == ErrorAlreadyExists)
|
2026-07-27 22:02:34 -07:00
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
// The constructor is lazy — it only captures settings — so reaching this means a
|
2026-07-29 04:04:16 -07:00
|
|
|
// session of this name is genuinely RUNNING, started by a previous broker that
|
|
|
|
|
// died. The compat surface cannot re-adopt it, so recovery goes through D13's
|
|
|
|
|
// internal-COM arm: open it, note what it was running, terminate it, retry.
|
|
|
|
|
created.Dispose();
|
|
|
|
|
created = await RecoverAndRestartAsync(spec, ct).ConfigureAwait(false);
|
2026-07-27 22:02:34 -07:00
|
|
|
}
|
2026-07-29 02:52:41 -07:00
|
|
|
catch (Exception e) when (e is not WslcError)
|
|
|
|
|
{
|
|
|
|
|
created.Dispose();
|
|
|
|
|
throw Translate(e, WslcError.StartFailed);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
session = created;
|
|
|
|
|
gateway = await ResolveGatewayAsync(ct).ConfigureAwait(false);
|
|
|
|
|
return gateway;
|
2026-07-27 22:02:34 -07:00
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
sessionGate.Release();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 04:04:16 -07:00
|
|
|
/// <summary>A settings-configured session with its handlers already attached. Subscribing
|
|
|
|
|
/// must happen BEFORE `Start()`, or a session that dies during boot never reports down.</summary>
|
|
|
|
|
private Sdk.Session NewSession(SessionSpec spec)
|
|
|
|
|
{
|
|
|
|
|
var settings = new Sdk.SessionSettings(spec.Name, spec.DataDir);
|
|
|
|
|
if (spec.Cpu is { } cpu) settings.CpuCount = (uint)cpu;
|
|
|
|
|
if (spec.MemoryMB is { } memory) settings.MemorySizeInMB = (uint)memory;
|
|
|
|
|
|
|
|
|
|
var created = new Sdk.Session(settings);
|
|
|
|
|
created.Terminated += reason => events?.SessionDown(reason.ToString());
|
|
|
|
|
// Not surfaced as an RPC, but it is the only crash detail wslc offers and it is what
|
|
|
|
|
// makes a SIGKILLed agent explicable in the host log (the Swift `diagnoseKill` seam).
|
|
|
|
|
created.ProcessCrashed += crash => Console.Error.WriteLine(
|
|
|
|
|
$"wslc: process {crash.ProcessName} (pid {crash.Pid}) crashed with signal "
|
|
|
|
|
+ $"{crash.Signal}; dump at {crash.DumpPath}");
|
|
|
|
|
return created;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// A session of this name is already running and we do not own it — the signature of a broker
|
|
|
|
|
/// that died with its sandbox up (docs/WINDOWS_PORT.md §13.2, D13 Tier 1).
|
|
|
|
|
///
|
|
|
|
|
/// Clear it through the internal COM arm and start fresh. The orphan's containers are lost,
|
|
|
|
|
/// which is the deliberate Tier 1 trade: they are lost today too (nothing could reach that
|
|
|
|
|
/// session at all), `ContainerManager.reconcile` already copes with an empty sandbox, and the
|
|
|
|
|
/// alternative — keeping them alive — is Tier 2 and blocked. What this buys is that a broker
|
|
|
|
|
/// restart stops requiring the user to run `wsl --shutdown` by hand.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private async Task<Sdk.Session> RecoverAndRestartAsync(SessionSpec spec, CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
if (recovery is null)
|
|
|
|
|
throw new WslcError(
|
|
|
|
|
WslcError.SessionExists,
|
|
|
|
|
$"a wslc session named '{spec.Name}' is already running, the compat SDK cannot "
|
|
|
|
|
+ "re-adopt it, and the internal COM interface did not bind; run `wsl --shutdown`");
|
|
|
|
|
|
|
|
|
|
if (recovery.RecoverSession(spec.Name) is null)
|
|
|
|
|
throw new WslcError(
|
|
|
|
|
WslcError.SessionExists,
|
|
|
|
|
$"a wslc session named '{spec.Name}' is already running and could not be "
|
|
|
|
|
+ "recovered; run `wsl --shutdown` to clear it");
|
|
|
|
|
|
|
|
|
|
// Terminate() returns before the VM is gone — the service tears it down asynchronously —
|
|
|
|
|
// so the next Start() can still see the old name. Retry rather than reporting a failure
|
|
|
|
|
// that a second attempt a moment later would not have hit.
|
|
|
|
|
for (var attempt = 0; ; attempt++)
|
|
|
|
|
{
|
|
|
|
|
await Task.Delay(500, ct).ConfigureAwait(false);
|
|
|
|
|
var retry = NewSession(spec);
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
retry.Start();
|
|
|
|
|
Console.Error.WriteLine($"wslc: session '{spec.Name}' restarted after recovery");
|
|
|
|
|
return retry;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception e) when (HResultOf(e) == ErrorAlreadyExists && attempt < 20)
|
|
|
|
|
{
|
|
|
|
|
retry.Dispose();
|
|
|
|
|
}
|
|
|
|
|
catch (Exception e)
|
|
|
|
|
{
|
|
|
|
|
retry.Dispose();
|
|
|
|
|
throw e is WslcError ? e : Translate(e, WslcError.StartFailed);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 22:02:34 -07:00
|
|
|
public Task TerminateSessionAsync(CancellationToken ct)
|
|
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
lock (containersLock)
|
|
|
|
|
{
|
|
|
|
|
foreach (var entry in containers.Values) entry.Container.Dispose();
|
|
|
|
|
containers.Clear();
|
|
|
|
|
}
|
2026-07-27 22:02:34 -07:00
|
|
|
session?.Terminate();
|
2026-07-29 02:52:41 -07:00
|
|
|
session?.Dispose();
|
2026-07-27 22:02:34 -07:00
|
|
|
session = null;
|
2026-07-29 02:52:41 -07:00
|
|
|
gateway = null;
|
2026-07-27 22:02:34 -07:00
|
|
|
return Task.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 02:52:41 -07:00
|
|
|
/// <summary>
|
|
|
|
|
/// The address a guest reaches the host on (§5). **No wslc API surfaces it** — there is no
|
|
|
|
|
/// gateway property anywhere on Session or Container, and ContainerPortMapping is inbound
|
|
|
|
|
/// host→guest, the wrong direction. So it comes from Windows networking instead: the IPv4
|
|
|
|
|
/// address the host holds on the WSL vSwitch.
|
|
|
|
|
///
|
|
|
|
|
/// Polled, because the vNIC appears as the VM boots and Start() returning does not mean it
|
|
|
|
|
/// is up yet.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static async Task<string> ResolveGatewayAsync(CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
for (var attempt = 0; ; attempt++)
|
|
|
|
|
{
|
|
|
|
|
if (FindWslAdapterAddress() is { } address) return address;
|
|
|
|
|
if (attempt >= 20)
|
|
|
|
|
throw new WslcError(
|
|
|
|
|
WslcError.StartFailed,
|
|
|
|
|
"no 'vEthernet (WSL)' adapter address after session start — the guest has no "
|
|
|
|
|
+ "route to the host control plane");
|
|
|
|
|
await Task.Delay(250, ct).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string? FindWslAdapterAddress() =>
|
|
|
|
|
NetworkInterface.GetAllNetworkInterfaces()
|
|
|
|
|
.Where(nic => nic.OperationalStatus == OperationalStatus.Up)
|
|
|
|
|
.Where(nic => nic.Name.Contains("WSL", StringComparison.OrdinalIgnoreCase)
|
|
|
|
|
|| nic.Description.Contains("WSL", StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
.SelectMany(nic => nic.GetIPProperties().UnicastAddresses)
|
|
|
|
|
.Select(unicast => unicast.Address)
|
|
|
|
|
.Where(address => address.AddressFamily == AddressFamily.InterNetwork)
|
|
|
|
|
.Select(address => address.ToString())
|
|
|
|
|
.FirstOrDefault();
|
|
|
|
|
|
|
|
|
|
private Sdk.Session RequireSession() =>
|
|
|
|
|
session ?? throw new WslcError(
|
|
|
|
|
WslcError.NotRunning, "no wslc session (call session.ensure first)");
|
|
|
|
|
|
|
|
|
|
// MARK: - Images
|
2026-07-27 22:02:34 -07:00
|
|
|
|
|
|
|
|
public async Task PullImageAsync(string reference, RegistryAuth? auth, CancellationToken ct)
|
|
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
var current = RequireSession();
|
|
|
|
|
var options = new Sdk.PullImageOptions(reference);
|
|
|
|
|
|
|
|
|
|
// RegistryAuth is a plain STRING, not a credentials object — and the string is minted by
|
|
|
|
|
// Session.Authenticate against the registry the ref names. GHCR reads a PAT with
|
|
|
|
|
// read:packages as the password (ContainerEngine.registryAuth(for:) does the same).
|
|
|
|
|
if (auth is { Username: { } username, Password: { } password })
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
options.RegistryAuth = current.Authenticate(
|
|
|
|
|
RegistryUri(reference), username, password);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception e) when (e is not WslcError)
|
|
|
|
|
{
|
|
|
|
|
throw Translate(e, WslcError.PullFailed);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 22:02:34 -07:00
|
|
|
try
|
|
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
var operation = current.PullImageAsync(options);
|
|
|
|
|
// Progress rides the ASYNC overload only; the sync PullImage reports nothing. This is
|
|
|
|
|
// what feeds the existing controlDownloadProgress UI surface.
|
|
|
|
|
operation.Progress = (_, progress) => events?.PullProgress(
|
|
|
|
|
reference,
|
|
|
|
|
progress.Status.ToString(),
|
|
|
|
|
(long)progress.CurrentBytes,
|
|
|
|
|
(long)progress.TotalBytes);
|
|
|
|
|
await operation.AsTask(ct).ConfigureAwait(false);
|
2026-07-27 22:02:34 -07:00
|
|
|
}
|
2026-07-29 02:52:41 -07:00
|
|
|
catch (Exception e) when (e is not WslcError && e is not OperationCanceledException)
|
2026-07-27 22:02:34 -07:00
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
throw Translate(e, WslcError.PullFailed);
|
2026-07-27 22:02:34 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 02:52:41 -07:00
|
|
|
/// <summary>`ghcr.io/abkslm/naros-agent:26.07` → `https://ghcr.io`. A ref whose first segment
|
|
|
|
|
/// carries no dot or port has no registry host at all (`ubuntu:24.04`), which means Docker
|
|
|
|
|
/// Hub.</summary>
|
|
|
|
|
private static Uri RegistryUri(string reference)
|
|
|
|
|
{
|
|
|
|
|
var firstSegment = reference.Split('/')[0];
|
|
|
|
|
var isHost = firstSegment.Contains('.') || firstSegment.Contains(':')
|
|
|
|
|
|| firstSegment == "localhost";
|
|
|
|
|
return new Uri($"https://{(isHost ? firstSegment : "index.docker.io")}");
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 22:02:34 -07:00
|
|
|
public Task<IReadOnlyList<ImageInfo>> ListImagesAsync(CancellationToken ct) =>
|
|
|
|
|
Task.FromResult<IReadOnlyList<ImageInfo>>(
|
2026-07-29 02:52:41 -07:00
|
|
|
RequireSession().GetImages().Select(Describe).ToList());
|
2026-07-27 22:02:34 -07:00
|
|
|
|
|
|
|
|
public Task DeleteImageAsync(string reference, CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
RequireSession().DeleteImage(reference);
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Task<ImageInfo?> InspectImageAsync(string reference, CancellationToken ct)
|
|
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
var image = RequireSession().GetImages().FirstOrDefault(i => i.Name == reference);
|
|
|
|
|
return Task.FromResult(image is null ? null : Describe(image));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `.Name`/`.Sha256`/`.Size` — not the `.Reference`/`.Digest` the docs show. Sha256 is an
|
|
|
|
|
/// IBuffer of raw bytes, so it becomes the `sha256:<hex>` digest the rest of Nucleic speaks.
|
|
|
|
|
private static ImageInfo Describe(Sdk.ImageInfo image) =>
|
|
|
|
|
new(image.Name, HexDigest(image.Sha256), (long)image.Size);
|
|
|
|
|
|
|
|
|
|
private static string? HexDigest(IBuffer? buffer)
|
|
|
|
|
{
|
|
|
|
|
if (buffer is null || buffer.Length == 0) return null;
|
|
|
|
|
var bytes = new byte[buffer.Length];
|
|
|
|
|
DataReader.FromBuffer(buffer).ReadBytes(bytes);
|
|
|
|
|
return $"sha256:{Convert.ToHexString(bytes).ToLowerInvariant()}";
|
2026-07-27 22:02:34 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-29 02:52:41 -07:00
|
|
|
// MARK: - Containers
|
|
|
|
|
|
2026-07-27 22:02:34 -07:00
|
|
|
public Task CreateContainerAsync(ContainerCreateSpec spec, CancellationToken ct)
|
|
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
var settings = new Sdk.ContainerSettings(spec.Image) { Name = spec.Name };
|
|
|
|
|
if (spec.Hostname is { } hostname) settings.HostName = hostname; // capital N
|
2026-07-27 22:02:34 -07:00
|
|
|
if (spec.NetworkingMode is { } mode)
|
2026-07-29 02:52:41 -07:00
|
|
|
settings.NetworkingMode = Enum.Parse<Sdk.ContainerNetworkingMode>(mode, ignoreCase: true);
|
|
|
|
|
if (spec.Volumes is { Count: > 0 } volumes)
|
|
|
|
|
settings.Volumes = volumes
|
|
|
|
|
.Select(v => new Sdk.ContainerVolume(v.Host, v.Guest, v.ReadOnly))
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
// env and initArgv both live on InitProcess, so build it when EITHER is present —
|
|
|
|
|
// attaching env to a null InitProcess silently dropped the whole environment before.
|
|
|
|
|
if (spec.InitArgv is { Count: > 0 } || spec.Env is { Count: > 0 })
|
|
|
|
|
{
|
|
|
|
|
var init = new Sdk.ProcessSettings();
|
|
|
|
|
if (spec.InitArgv is { Count: > 0 } argv) init.CommandLine = argv.ToList();
|
|
|
|
|
if (spec.Env is { Count: > 0 } env) init.EnvironmentVariables = new Dictionary<string, string>(env);
|
|
|
|
|
settings.InitProcess = init;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 22:02:34 -07:00
|
|
|
try
|
|
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
var container = RequireSession().CreateContainer(settings);
|
|
|
|
|
lock (containersLock)
|
|
|
|
|
{
|
|
|
|
|
if (containers.Remove(spec.Name, out var stale)) stale.Container.Dispose();
|
|
|
|
|
containers[spec.Name] = new Entry(container, spec.Image);
|
|
|
|
|
}
|
2026-07-27 22:02:34 -07:00
|
|
|
}
|
|
|
|
|
catch (Exception e) when (e is not WslcError)
|
|
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
throw Translate(e, WslcError.StartFailed);
|
2026-07-27 22:02:34 -07:00
|
|
|
}
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 02:52:41 -07:00
|
|
|
private Sdk.Container RequireContainer(string name)
|
|
|
|
|
{
|
|
|
|
|
lock (containersLock)
|
|
|
|
|
{
|
|
|
|
|
return containers.TryGetValue(name, out var entry)
|
|
|
|
|
? entry.Container
|
|
|
|
|
// Not necessarily absent from the SERVICE — absent from this broker's roster,
|
|
|
|
|
// which after a restart is everything it ever created. See the class remarks.
|
|
|
|
|
: throw new WslcError(WslcError.NotFound, $"no container named {name}");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-27 22:02:34 -07:00
|
|
|
|
|
|
|
|
public Task StartContainerAsync(string name, CancellationToken ct)
|
|
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
RequireContainer(name).Start();
|
|
|
|
|
}
|
|
|
|
|
catch (Exception e) when (e is not WslcError)
|
|
|
|
|
{
|
|
|
|
|
throw Translate(e, WslcError.StartFailed);
|
|
|
|
|
}
|
2026-07-27 22:02:34 -07:00
|
|
|
return Task.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Task StopContainerAsync(string name, int signal, int graceMs, CancellationToken ct)
|
|
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
RequireContainer(name).Stop(MapSignal(signal), TimeSpan.FromMilliseconds(graceMs));
|
2026-07-27 22:02:34 -07:00
|
|
|
return Task.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Task DeleteContainerAsync(string name, bool force, CancellationToken ct)
|
|
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
var container = RequireContainer(name);
|
|
|
|
|
container.Delete(force ? Sdk.DeleteContainerOption.Force : Sdk.DeleteContainerOption.None);
|
|
|
|
|
lock (containersLock)
|
|
|
|
|
{
|
|
|
|
|
if (containers.Remove(name, out var entry)) entry.Container.Dispose();
|
|
|
|
|
}
|
2026-07-27 22:02:34 -07:00
|
|
|
return Task.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 02:52:41 -07:00
|
|
|
/// <summary>
|
|
|
|
|
/// The broker's OWN roster, not the service's. The compat SDK has no enumeration call at all,
|
|
|
|
|
/// so this cannot see a container this process did not create — which is why `enumerate` is
|
|
|
|
|
/// absent from <see cref="Capabilities"/> and why §2.3's post-restart reconcile is a known
|
|
|
|
|
/// gap rather than a silently empty answer.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public Task<IReadOnlyList<ContainerInfo>> ListContainersAsync(CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
lock (containersLock)
|
|
|
|
|
{
|
|
|
|
|
return Task.FromResult<IReadOnlyList<ContainerInfo>>(
|
|
|
|
|
containers
|
|
|
|
|
.Select(kv => new ContainerInfo(kv.Key, kv.Value.Image, Describe(kv.Value.Container.State)))
|
|
|
|
|
.ToList());
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-27 22:02:34 -07:00
|
|
|
|
|
|
|
|
public Task<string> ContainerStateAsync(string name, CancellationToken ct)
|
|
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
lock (containersLock)
|
2026-07-27 22:02:34 -07:00
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
return Task.FromResult(containers.TryGetValue(name, out var entry)
|
|
|
|
|
? Describe(entry.Container.State)
|
|
|
|
|
: "absent");
|
|
|
|
|
}
|
2026-07-27 22:02:34 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-29 02:52:41 -07:00
|
|
|
/// ContainerState is Invalid|Created|Running|Exited|Deleted; the Swift policy layer only
|
|
|
|
|
/// distinguishes running/stopped/absent, and a Deleted handle is absent as far as it cares.
|
|
|
|
|
private static string Describe(Sdk.ContainerState state) => state switch
|
2026-07-27 22:02:34 -07:00
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
Sdk.ContainerState.Running => "running",
|
|
|
|
|
Sdk.ContainerState.Deleted => "absent",
|
|
|
|
|
_ => "stopped",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// There is no <c>GetStatistics()</c> on the compat surface — <c>Container</c> offers only
|
|
|
|
|
/// <c>Id</c>, <c>State</c>, <c>InitProcess</c> and <c>Inspect()</c>. So the counters come from
|
|
|
|
|
/// the guest's own cgroup v2 files, read the way a Linux-native engine would (§13.1 finding 2).
|
|
|
|
|
///
|
|
|
|
|
/// Deliberately `cat` and `echo` only: no awk, no sed. This runs in whatever image the user
|
|
|
|
|
/// configured, and nash is `/bin/sh` in narOS — the smaller the tool surface, the fewer images
|
|
|
|
|
/// this silently fails in.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public async Task<ContainerStatsInfo?> ContainerStatsAsync(string name, CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
var container = RequireContainer(name);
|
|
|
|
|
if (container.State != Sdk.ContainerState.Running) return null;
|
|
|
|
|
|
|
|
|
|
const string script =
|
|
|
|
|
"cat /sys/fs/cgroup/cpu.stat 2>/dev/null; echo ---; "
|
|
|
|
|
+ "cat /sys/fs/cgroup/memory.current 2>/dev/null; echo ---; "
|
|
|
|
|
+ "cat /sys/fs/cgroup/memory.max 2>/dev/null; echo ---; "
|
|
|
|
|
+ "cat /sys/fs/cgroup/memory.events 2>/dev/null";
|
|
|
|
|
|
|
|
|
|
var (exitCode, output) = await RunCapturingAsync(
|
|
|
|
|
container, ["/bin/sh", "-c", script], TimeSpan.FromSeconds(10), ct).ConfigureAwait(false);
|
|
|
|
|
if (exitCode != 0) return null;
|
|
|
|
|
|
|
|
|
|
var sections = output.Split("---", StringSplitOptions.TrimEntries);
|
|
|
|
|
if (sections.Length < 4) return null;
|
|
|
|
|
|
|
|
|
|
return new ContainerStatsInfo(
|
|
|
|
|
CpuUsageUsec: FieldValue(sections[0], "usage_usec") ?? 0,
|
|
|
|
|
MemoryUsedBytes: Number(sections[1]) ?? 0,
|
|
|
|
|
// `memory.max` reads the literal "max" when the cgroup is unlimited — not a number.
|
|
|
|
|
// -1 rather than 0 so "unlimited" stays distinguishable on the wire; the Swift
|
|
|
|
|
// consumer clamps with max(0,…) today, which is its existing "unknown".
|
|
|
|
|
MemoryLimitBytes: Number(sections[2]) ?? -1,
|
|
|
|
|
OomKills: FieldValue(sections[3], "oom_kill"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A cgroup "flat keyed" file: `key value` per line.
|
|
|
|
|
private static long? FieldValue(string section, string key)
|
|
|
|
|
{
|
|
|
|
|
foreach (var line in section.Split('\n', StringSplitOptions.TrimEntries))
|
|
|
|
|
{
|
|
|
|
|
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
|
if (parts.Length == 2 && parts[0] == key && long.TryParse(parts[1], out var value))
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
2026-07-27 22:02:34 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-29 02:52:41 -07:00
|
|
|
private static long? Number(string section) =>
|
|
|
|
|
long.TryParse(section.Trim(), out var value) ? value : null;
|
|
|
|
|
|
|
|
|
|
// MARK: - Processes
|
|
|
|
|
|
2026-07-27 22:02:34 -07:00
|
|
|
public Task<IWslcProcess> ExecAsync(long procId, ProcSpec spec, CancellationToken ct)
|
|
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
if (spec.Tty)
|
|
|
|
|
// Not a failure to retry: ProcessSettings has no Terminal and Process has no resize.
|
|
|
|
|
// §7's Terminal panel needs the internal COM interface (IWSLCProcess::ResizeTty).
|
|
|
|
|
throw new WslcError(
|
|
|
|
|
WslcError.Unsupported,
|
|
|
|
|
"the wslc compat SDK cannot allocate a pty; proc.exec(tty:true) is unavailable");
|
|
|
|
|
|
2026-07-27 22:02:34 -07:00
|
|
|
var container = RequireContainer(spec.Container);
|
2026-07-29 02:52:41 -07:00
|
|
|
var settings = new Sdk.ProcessSettings
|
2026-07-27 22:02:34 -07:00
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
CommandLine = WithPrivilegeDrop(spec).ToList(), // CommandLine, not CmdLine
|
|
|
|
|
OutputMode = Sdk.ProcessOutputMode.Event,
|
2026-07-27 22:02:34 -07:00
|
|
|
};
|
2026-07-29 02:52:41 -07:00
|
|
|
if (spec.Cwd is { } cwd) settings.WorkingDirectory = cwd;
|
|
|
|
|
if (spec.Env is { Count: > 0 } env)
|
|
|
|
|
settings.EnvironmentVariables = new Dictionary<string, string>(env); // not Environment
|
2026-07-27 22:02:34 -07:00
|
|
|
|
2026-07-29 02:52:41 -07:00
|
|
|
Sdk.Process process;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
// CreateProcess then Start() — two steps, and the split is the point: handlers are
|
|
|
|
|
// attached in between, so the first output chunk cannot be missed.
|
|
|
|
|
process = container.CreateProcess(settings);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception e) when (e is not WslcError)
|
|
|
|
|
{
|
|
|
|
|
throw Translate(e, WslcError.StartFailed);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// OutputReceived and ErrorReceived are SEPARATE events, each carrying only bytes — there
|
|
|
|
|
// is no stderr flag to read, so the stream is decided by which handler fired.
|
|
|
|
|
process.OutputReceived += data => events?.ProcOutput(procId, stderr: false, data);
|
|
|
|
|
process.ErrorReceived += data => events?.ProcOutput(procId, stderr: true, data);
|
2026-07-27 22:02:34 -07:00
|
|
|
process.Exited += code => events?.ProcExited(procId, code);
|
2026-07-29 02:52:41 -07:00
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
process.Start();
|
|
|
|
|
}
|
|
|
|
|
catch (Exception e) when (e is not WslcError)
|
|
|
|
|
{
|
|
|
|
|
process.Dispose();
|
|
|
|
|
throw Translate(e, WslcError.StartFailed);
|
|
|
|
|
}
|
2026-07-27 22:02:34 -07:00
|
|
|
return Task.FromResult<IWslcProcess>(new WslcProcess(process));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 02:52:41 -07:00
|
|
|
/// <summary>
|
|
|
|
|
/// <c>ProcessSettings</c> has no <c>UserId</c>/<c>GroupId</c>, so dropping to the agent uid is
|
|
|
|
|
/// done in-guest by wrapping argv — the fallback §3.2 always named, which costs nothing
|
|
|
|
|
/// because the interceptors and nash never look at the numeric uid.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static IReadOnlyList<string> WithPrivilegeDrop(ProcSpec spec)
|
2026-07-27 22:02:34 -07:00
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
if (spec.Uid is not { } uid || uid == 0) return spec.Argv;
|
|
|
|
|
var gid = spec.Gid ?? uid;
|
|
|
|
|
return
|
|
|
|
|
[
|
|
|
|
|
"setpriv", $"--reuid={uid}", $"--regid={gid}", "--init-groups", "--",
|
|
|
|
|
.. spec.Argv,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Run to completion and capture stdout+stderr. The in-guest half of what the macOS
|
|
|
|
|
/// engine's `runCapturing` does, and the only way stats and shim re-seeding work without a
|
|
|
|
|
/// second mechanism.</summary>
|
|
|
|
|
private static async Task<(int ExitCode, string Output)> RunCapturingAsync(
|
|
|
|
|
Sdk.Container container, IReadOnlyList<string> argv, TimeSpan timeout, CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
var settings = new Sdk.ProcessSettings
|
2026-07-27 22:02:34 -07:00
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
CommandLine = argv.ToList(),
|
|
|
|
|
OutputMode = Sdk.ProcessOutputMode.Event,
|
|
|
|
|
};
|
|
|
|
|
var buffer = new MemoryStream();
|
|
|
|
|
var exited = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
|
|
|
|
|
|
var process = container.CreateProcess(settings);
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
process.OutputReceived += data => { lock (buffer) buffer.Write(data, 0, data.Length); };
|
|
|
|
|
process.ErrorReceived += data => { lock (buffer) buffer.Write(data, 0, data.Length); };
|
|
|
|
|
process.Exited += code => exited.TrySetResult(code);
|
|
|
|
|
process.Start();
|
|
|
|
|
|
|
|
|
|
int exitCode;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
exitCode = await exited.Task.WaitAsync(timeout, ct).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
catch (TimeoutException)
|
|
|
|
|
{
|
|
|
|
|
process.Signal(Sdk.Signal.SIGKILL);
|
|
|
|
|
throw new WslcError(
|
|
|
|
|
WslcError.NotRunning, $"`{string.Join(' ', argv)}` timed out in the container");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Exited and OutputReceived are independent event sources, so a final chunk can still
|
|
|
|
|
// be in flight when the exit fires. Waiting a beat costs nothing here (this path is
|
|
|
|
|
// never on the agent's hot stdio) and avoids truncating the last line.
|
|
|
|
|
await Task.Delay(50, ct).ConfigureAwait(false);
|
|
|
|
|
lock (buffer) return (exitCode, System.Text.Encoding.UTF8.GetString(buffer.ToArray()));
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
process.Dispose();
|
2026-07-27 22:02:34 -07:00
|
|
|
}
|
2026-07-29 02:52:41 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class WslcProcess(Sdk.Process process) : IWslcProcess
|
|
|
|
|
{
|
|
|
|
|
// stdin is a WinRT stream, not a WriteStdin call, and DataWriter is how you put bytes
|
|
|
|
|
// into an IOutputStream. Held for the process lifetime and guarded, because hostd may
|
|
|
|
|
// pipeline proc.stdin writes and StoreAsync is not reentrant.
|
|
|
|
|
private readonly SemaphoreSlim stdinGate = new(1, 1);
|
|
|
|
|
private IOutputStream? stdin;
|
|
|
|
|
private DataWriter? writer;
|
2026-07-27 22:02:34 -07:00
|
|
|
|
2026-07-29 02:52:41 -07:00
|
|
|
public async Task WriteStdinAsync(ReadOnlyMemory<byte> data, CancellationToken ct)
|
2026-07-27 22:02:34 -07:00
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
await stdinGate.WaitAsync(ct).ConfigureAwait(false);
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (writer is null)
|
|
|
|
|
{
|
|
|
|
|
stdin = process.GetInputStream();
|
|
|
|
|
writer = new DataWriter(stdin);
|
|
|
|
|
}
|
|
|
|
|
writer.WriteBytes(data.ToArray());
|
|
|
|
|
await writer.StoreAsync().AsTask(ct).ConfigureAwait(false);
|
|
|
|
|
await writer.FlushAsync().AsTask(ct).ConfigureAwait(false);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
stdinGate.Release();
|
|
|
|
|
}
|
2026-07-27 22:02:34 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-29 02:52:41 -07:00
|
|
|
public async Task CloseStdinAsync(CancellationToken ct)
|
2026-07-27 22:02:34 -07:00
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
await stdinGate.WaitAsync(ct).ConfigureAwait(false);
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
// Detach before disposing the writer, or the writer takes the stream down with it
|
|
|
|
|
// and the close races the last StoreAsync. Closing the STREAM is what the guest
|
|
|
|
|
// observes as EOF on stdin.
|
|
|
|
|
writer?.DetachStream();
|
|
|
|
|
writer?.Dispose();
|
|
|
|
|
writer = null;
|
|
|
|
|
stdin?.Dispose();
|
|
|
|
|
stdin = null;
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
stdinGate.Release();
|
|
|
|
|
}
|
2026-07-27 22:02:34 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-29 02:52:41 -07:00
|
|
|
public Task SignalAsync(int signal, CancellationToken ct)
|
2026-07-27 22:02:34 -07:00
|
|
|
{
|
2026-07-29 02:52:41 -07:00
|
|
|
process.Signal(MapSignal(signal));
|
2026-07-27 22:02:34 -07:00
|
|
|
return Task.CompletedTask;
|
|
|
|
|
}
|
2026-07-29 02:52:41 -07:00
|
|
|
|
|
|
|
|
public Task ResizeAsync(int cols, int rows, CancellationToken ct) =>
|
|
|
|
|
Task.FromException(new WslcError(
|
|
|
|
|
WslcError.Unsupported,
|
|
|
|
|
"the wslc compat SDK has no pty, so there is no terminal to resize"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Error translation
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// POSIX int → the named <c>Signal</c> enum. The RPC carries POSIX numbers because every
|
|
|
|
|
/// caller above it is platform-neutral, but wslc accepts only six, so anything else (SIGUSR1,
|
|
|
|
|
/// SIGWINCH) is a permanent gap rather than a number to pass through.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static Sdk.Signal MapSignal(int signal) => signal switch
|
|
|
|
|
{
|
|
|
|
|
0 => Sdk.Signal.None,
|
|
|
|
|
1 => Sdk.Signal.SIGHUP,
|
|
|
|
|
2 => Sdk.Signal.SIGINT,
|
|
|
|
|
3 => Sdk.Signal.SIGQUIT,
|
|
|
|
|
9 => Sdk.Signal.SIGKILL,
|
|
|
|
|
15 => Sdk.Signal.SIGTERM,
|
|
|
|
|
_ => throw new WslcError(
|
|
|
|
|
WslcError.Unsupported,
|
|
|
|
|
$"wslc cannot deliver signal {signal} (only HUP, INT, QUIT, KILL and TERM)"),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private const int ErrorAlreadyExists = unchecked((int)0x800700B7);
|
|
|
|
|
|
|
|
|
|
private static int HResultOf(Exception e) => e is COMException com ? com.HResult : e.HResult;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// COM HRESULTs → the RPC's `data.kind`. Keyed on the number, never the message: these
|
|
|
|
|
/// exceptions frequently arrive with an EMPTY message, so string matching would silently
|
|
|
|
|
/// classify every one of them as the fallback.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static WslcError Translate(Exception e, string fallbackKind)
|
|
|
|
|
{
|
|
|
|
|
var code = HResultOf(e);
|
|
|
|
|
var kind = (uint)code switch
|
|
|
|
|
{
|
|
|
|
|
0x80040601 => WslcError.NotFound, // WSLC_E_IMAGE_NOT_FOUND
|
|
|
|
|
0x80040603 => WslcError.NotFound, // WSLC_E_CONTAINER_NOT_FOUND
|
|
|
|
|
0x80040605 => WslcError.NotRunning, // WSLC_E_CONTAINER_NOT_RUNNING
|
|
|
|
|
0x8004060F => WslcError.NotFound, // WSLC_E_SESSION_NOT_FOUND
|
|
|
|
|
0x80040607 => WslcError.SessionExists, // WSLC_E_SESSION_RESERVED
|
|
|
|
|
0x800700B7 => WslcError.SessionExists, // ERROR_ALREADY_EXISTS
|
|
|
|
|
// Nothing installed vs. installed-but-too-old: opposite diagnoses, and both mean the
|
|
|
|
|
// sandbox is unusable rather than this call being wrong.
|
|
|
|
|
0x80040154 => WslcError.Unavailable, // REGDB_E_CLASSNOTREG
|
|
|
|
|
0x80070032 => WslcError.Unavailable, // ERROR_NOT_SUPPORTED
|
|
|
|
|
0x8004060B => WslcError.Unavailable, // WSLC_E_SDK_UPDATE_NEEDED
|
|
|
|
|
0x8004060D => WslcError.PullFailed, // WSLC_E_REGISTRY_BLOCKED_BY_POLICY
|
|
|
|
|
_ => fallbackKind,
|
|
|
|
|
};
|
|
|
|
|
var message = string.IsNullOrWhiteSpace(e.Message) ? $"wslc failed (0x{code:X8})" : e.Message;
|
|
|
|
|
return new WslcError(kind, message);
|
2026-07-27 22:02:34 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#endif
|