Merge nucleic/lucid-river-toad-6efj into dev

This commit is contained in:
2026-07-29 02:52:41 -07:00
parent c755e9dee3
commit 57fd65bfe1
9 changed files with 686 additions and 151 deletions
+5
View File
@@ -86,7 +86,12 @@ public sealed class BrokerService : IBrokerEvents
{
case "hello":
{
// Two kinds of capability in one list: the RPC families this broker serves, and
// what the facade underneath can actually do (docs/WINDOWS_PORT.md D13). hostd
// needs both — "proc" says the methods exist, "tty" says proc.exec(tty:true)
// will work rather than failing `unsupported` at the Terminal panel.
var caps = new List<string> { "components", "session", "image", "container", "proc" };
caps.AddRange(wslc.Capabilities);
if (ai.IsAvailable) caps.Add("ai");
return new
{
+28
View File
@@ -15,6 +15,24 @@ public interface IWslc
/// <summary>Reported in the `hello` capabilities exchange; null when wslc is absent.</summary>
string? WslcVersion { get; }
/// <summary>
/// What this facade can actually do, merged into the `hello` capabilities so hostd degrades
/// instead of discovering the gap at the call site (docs/WINDOWS_PORT.md §2.3, D13). The
/// compat SDK alone cannot serve four of them, so a compat-only broker reports none of:
/// <list type="bullet">
/// <item><c>enumerate</c> — service-backed container enumeration. Without it
/// <c>container.list</c> answers from the broker's OWN roster, so it goes empty across a
/// broker restart and <c>ContainerManager.reconcile</c> sees an empty sandbox.</item>
/// <item><c>reattach</c> — re-adopting a running session or container. Without it a broker
/// restart cannot recover the session and <c>session.ensure</c> fails
/// <see cref="WslcError.SessionExists"/>.</item>
/// <item><c>tty</c> — pty allocation and resize, i.e. §7's Terminal panel.</item>
/// <item><c>stats</c> — per-container resource sampling. Reported when stats are available
/// by ANY means, including the in-guest cgroup read the compat facade falls back to.</item>
/// </list>
/// </summary>
IReadOnlyList<string> Capabilities { get; }
/// <summary>Install the sink BEFORE any operation that can emit events.</summary>
void SetEvents(IBrokerEvents events);
@@ -84,6 +102,16 @@ public sealed class WslcError(string kind, string message) : Exception(message)
public const string PullFailed = "image_pull_failed";
public const string StartFailed = "start_failed";
public const string AiUnavailable = "ai_unavailable";
/// <summary>The installed wslc cannot do this at all (no pty, an unmappable signal). A
/// permanent capability gap, not a transient failure — hostd must not retry.</summary>
public const string Unsupported = "unsupported";
/// <summary>A session of that name is already running and this facade cannot re-adopt it
/// (the compat SDK's `Start()` answers ERROR_ALREADY_EXISTS, and its constructor is lazy, so
/// a second handle is not a second session). Distinct from <see cref="StartFailed"/> because
/// the remedy differs: the sandbox is UP, this broker just cannot reach it.</summary>
public const string SessionExists = "session_exists";
}
// DTOs — property names (after camel-casing) match the §3.3 wire keys exactly.
+14 -4
View File
@@ -16,10 +16,20 @@
</PropertyGroup>
<PropertyGroup Condition="'$(UseWslc)' == 'true'">
<!-- Windows SDK 19041, matching the package's own lib TFM
(lib/net8.0-windows10.0.19041.0/wslcsdkcs.dll). Targeting a higher SDK revision
(26100) only adds a targeting-pack requirement the package does not need. -->
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
<!-- 26100, NOT the 19041 the package's lib folder advertises. Those disagree, and the
binary wins: `lib/net8.0-windows10.0.19041.0/wslcsdkcs.dll` is itself compiled against
Microsoft.Windows.SDK.NET 10.0.26100.79, so a 19041 targeting pack cannot load it —
`error CS1705: ... uses 'Microsoft.Windows.SDK.NET, Version=10.0.26100.79' which has a
higher version than referenced assembly ... 10.0.19041.38`. Matching the folder name
looks right and does not build. -->
<TargetFramework>net9.0-windows10.0.26100.0</TargetFramework>
<!-- The TFM alone selects a targeting pack by MAJOR revision only, and the .NET 9 SDK's
default for 26100 is 10.0.26100.38 — still below the .79 wslcsdkcs was built against,
so CS1705 survives the TFM bump. This pins the projection itself. .80 rather than the
.79 the error names because .79 was never published to nuget.org; the constraint is a
floor, so the next one up satisfies it. Raise it, never lower it, if a package bump
moves the floor again. -->
<WindowsSdkPackageVersion>10.0.26100.80</WindowsSdkPackageVersion>
<DefineConstants>$(DefineConstants);USE_WSLC</DefineConstants>
</PropertyGroup>
+3
View File
@@ -10,6 +10,9 @@ public sealed class UnavailableWslc : IWslc
{
public string? WslcVersion => null;
/// Nothing bound, so nothing is capable — the empty list IS the capability report.
public IReadOnlyList<string> Capabilities => [];
public void SetEvents(IBrokerEvents events) { }
private static WslcError Unavailable() =>
+563 -139
View File
@@ -1,94 +1,156 @@
#if USE_WSLC
using Microsoft.WSL.Containers;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using Sdk = Microsoft.WSL.Containers;
using Windows.Storage.Streams;
namespace NucleicBroker.Wslc;
// The REAL Microsoft.WSL.Containers adapter (docs/WINDOWS_PORT.md §1.5, §3.2), compiled
// only with -p:UseWslc=true on Windows.
//
// !! KNOWN WRONG AS WRITTEN — DO NOT BUILD ON IT. !!
//
// M1 spike (a) has run (docs/WINDOWS_PORT.md §13.1) and the real API differs from this
// transcription in roughly twenty places. Most are renames that belong exactly here and
// nowhere else, which is what the IWslc seam is for: GetVersion not GetServiceVersion,
// `new Session(settings)` + Start() not CreateOrOpen, MemorySizeInMB, HostName, ImageName,
// CreateProcess-then-Start rather than RunProcess, DeleteContainerOption, a named Signal
// enum, RegistryAuth as a string, ImageInfo.Name/.Sha256, and two separate output events
// instead of one with a stderr flag.
//
// Four things this SDK cannot do at all — enumerate containers, report per-container stats,
// allocate/resize a pty, and attach to an existing container or session. That is not the
// projection hiding them: `wslcsdk.dll` wraps `WSLCCompat.idl`, the deliberately-stable
// SDK-facing COM surface, and that surface genuinely lacks them.
//
// They DO exist on `wslc.idl`, the service-internal COM interface `wslc.exe` itself calls
// (IWSLCSessionManager, IID 82A7ABC8-6B50-43FC-AB96-15FBBE7E8760) — ListContainers, Stats,
// ResizeTty, OpenContainer/Attach, OpenSessionByName, and IWSLCVirtualMachine::GetId, which
// is the VM GUID an AF_HYPERV bind needs. Both IDLs are in the open-source WSL repo. The
// internal one carries an explicit "ABI breaking changes are OK" warning.
//
// D13 (§13.1): this class binds BOTH surfaces — compat SDK for everything it covers, internal
// COM for those five. Shelling out to `wslc.exe` was considered and rejected (a spawn per call,
// scraped text, no events, a second mechanism to maintain). Because the internal ABI is
// explicitly unstable, bind it defensively: probe at startup, report what bound in the
// `capabilities` hello, and degrade — losing reattach, stats and the Terminal panel — rather
// than failing the sandbox. All of that lives inside this class: `IWslc` does not change, so
// nothing on the Swift side knows which surface answered.
//
// Also note: `ProcessSettings` has no uid/gid, so exec wraps argv in setpriv/su — which
// §3.2 already anticipated as the fallback, so it costs nothing.
//
// Read §13.1 before touching this file. Fixing it is the next step of item 5, and it is
// now a fast loop: the package restores, so `dotnet build -p:UseWslc=true` compiles it.
//
// WslcService (components) → Session (VM host, images) → Container → Process.
/// <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>
public sealed class WslcFacade : IWslc
{
private IBrokerEvents? events;
private Session? session;
private Sdk.Session? session;
private string? gateway;
private readonly SemaphoreSlim sessionGate = new(1, 1);
public string? WslcVersion => WslcService.GetServiceVersion()?.ToString();
/// 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;
}
}
}
/// Compat-only, so: no enumeration, no reattach, no pty. Stats ARE offered — not from the SDK
/// (there is no GetStatistics()) but from an in-guest cgroup read, which is the escape hatch
/// §13.1 names and is indistinguishable to hostd.
public IReadOnlyList<string> Capabilities => ["stats"];
public void SetEvents(IBrokerEvents events) => this.events = events;
// MARK: - Components (§8 onboarding)
public Task<IReadOnlyList<string>> MissingComponentsAsync(CancellationToken ct)
{
var flags = WslcService.GetMissingComponents();
var missing = new List<string>();
foreach (var flag in Enum.GetValues<ComponentFlags>())
if (flag != ComponentFlags.None && flags.HasFlag(flag))
missing.Add(flag.ToString().ToLowerInvariant());
// 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();
return Task.FromResult<IReadOnlyList<string>>(missing);
}
public async Task InstallComponentsAsync(CancellationToken ct)
{
// M1: wire the component-install progress callback into events.InstallProgress.
await WslcService.InstallComponentsAsync(WslcService.GetMissingComponents())
.AsTask(ct).ConfigureAwait(false);
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);
events?.InstallProgress("done", 100);
}
// MARK: - Session
public async Task<string> EnsureSessionAsync(SessionSpec spec, CancellationToken ct)
{
await sessionGate.WaitAsync(ct).ConfigureAwait(false);
try
{
if (session is null)
if (session is not null) return gateway!;
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);
// Subscribe BEFORE Start(): a session that dies during boot must still report down.
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}");
try
{
var settings = new SessionSettings(spec.Name, spec.DataDir);
if (spec.Cpu is { } cpu) settings.CpuCount = cpu;
if (spec.MemoryMB is { } mem) settings.MemoryMB = mem;
session = Session.CreateOrOpen(settings);
session.SessionTerminationHandler = reason =>
events?.SessionDown(reason?.ToString() ?? "unknown");
created.Start();
}
// M1: confirm how the WSL vEthernet gateway address is surfaced (session
// property vs. querying the vNIC); NAT mode is preferred for determinism
// (docs/WINDOWS_PORT.md §5). Mirrored mode reports the reachable host address.
return session.HostGatewayAddress?.ToString()
?? throw new WslcError(WslcError.StartFailed, "wslc session has no gateway address");
catch (Exception e) when (HResultOf(e) == ErrorAlreadyExists)
{
created.Dispose();
// The constructor is lazy — it only captures settings — so reaching this means a
// session of this name is genuinely RUNNING, started by a previous broker or
// another process. The compat surface cannot re-adopt it, and there is no handle
// to terminate it through either, so this is terminal for this broker.
throw new WslcError(
WslcError.SessionExists,
$"a wslc session named '{spec.Name}' is already running and the compat SDK "
+ "cannot re-adopt it; run `wsl --shutdown` to clear it");
}
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;
}
finally
{
@@ -98,36 +160,111 @@ public sealed class WslcFacade : IWslc
public Task TerminateSessionAsync(CancellationToken ct)
{
lock (containersLock)
{
foreach (var entry in containers.Values) entry.Container.Dispose();
containers.Clear();
}
session?.Terminate();
session?.Dispose();
session = null;
gateway = null;
return Task.CompletedTask;
}
private Session RequireSession() =>
session ?? throw new WslcError(WslcError.NotRunning, "no wslc session (call session.ensure first)");
/// <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
public async Task PullImageAsync(string reference, RegistryAuth? auth, CancellationToken ct)
{
var options = new PullImageOptions(reference);
if (auth is { Username: { } user, Password: { } pass })
options.Credentials = new RegistryCredentials(user, pass);
options.Progress += (status, current, total) =>
events?.PullProgress(reference, status, current, total);
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);
}
}
try
{
await RequireSession().PullImageAsync(options).AsTask(ct).ConfigureAwait(false);
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);
}
catch (Exception e) when (e is not WslcError)
catch (Exception e) when (e is not WslcError && e is not OperationCanceledException)
{
throw new WslcError(WslcError.PullFailed, e.Message);
throw Translate(e, WslcError.PullFailed);
}
}
/// <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")}");
}
public Task<IReadOnlyList<ImageInfo>> ListImagesAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<ImageInfo>>(
RequireSession().GetImages()
.Select(i => new ImageInfo(i.Reference, i.Digest, i.Size))
.ToList());
RequireSession().GetImages().Select(Describe).ToList());
public Task DeleteImageAsync(string reference, CancellationToken ct)
{
@@ -137,129 +274,416 @@ public sealed class WslcFacade : IWslc
public Task<ImageInfo?> InspectImageAsync(string reference, CancellationToken ct)
{
var image = RequireSession().GetImages().FirstOrDefault(i => i.Reference == reference);
return Task.FromResult(image is null ? null : new ImageInfo(image.Reference, image.Digest, image.Size));
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()}";
}
// MARK: - Containers
public Task CreateContainerAsync(ContainerCreateSpec spec, CancellationToken ct)
{
var settings = new ContainerSettings(spec.Image) { Name = spec.Name };
if (spec.Hostname is { } hostname) settings.Hostname = hostname;
var settings = new Sdk.ContainerSettings(spec.Image) { Name = spec.Name };
if (spec.Hostname is { } hostname) settings.HostName = hostname; // capital N
if (spec.NetworkingMode is { } mode)
settings.NetworkingMode = Enum.Parse<ContainerNetworkingMode>(mode, ignoreCase: true);
foreach (var volume in spec.Volumes ?? [])
settings.Volumes.Add(new ContainerVolume(volume.Host, volume.Guest, volume.ReadOnly));
if (spec.InitArgv is { Count: > 0 } argv)
settings.InitProcess = new ProcessSettings { CmdLine = argv.ToList() };
if (spec.Env is { } env)
foreach (var (key, value) in env)
settings.InitProcess?.Environment.Add(key, value);
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;
}
try
{
RequireSession().CreateContainer(settings);
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);
}
}
catch (Exception e) when (e is not WslcError)
{
throw new WslcError(WslcError.StartFailed, e.Message);
throw Translate(e, WslcError.StartFailed);
}
return Task.CompletedTask;
}
private Container RequireContainer(string name) =>
RequireSession().GetContainers().FirstOrDefault(c => c.Name == name)
?? throw new WslcError(WslcError.NotFound, $"no container named {name}");
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}");
}
}
public Task StartContainerAsync(string name, CancellationToken ct)
{
RequireContainer(name).Start();
try
{
RequireContainer(name).Start();
}
catch (Exception e) when (e is not WslcError)
{
throw Translate(e, WslcError.StartFailed);
}
return Task.CompletedTask;
}
public Task StopContainerAsync(string name, int signal, int graceMs, CancellationToken ct)
{
RequireContainer(name).Stop((Signal)signal, TimeSpan.FromMilliseconds(graceMs));
RequireContainer(name).Stop(MapSignal(signal), TimeSpan.FromMilliseconds(graceMs));
return Task.CompletedTask;
}
public Task DeleteContainerAsync(string name, bool force, CancellationToken ct)
{
RequireContainer(name).Delete(force ? DeleteContainerFlags.Force : DeleteContainerFlags.None);
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();
}
return Task.CompletedTask;
}
public Task<IReadOnlyList<ContainerInfo>> ListContainersAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<ContainerInfo>>(
RequireSession().GetContainers()
.Select(c => new ContainerInfo(c.Name, c.Image, c.State.ToString().ToLowerInvariant()))
.ToList());
/// <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());
}
}
public Task<string> ContainerStateAsync(string name, CancellationToken ct)
{
var container = RequireSession().GetContainers().FirstOrDefault(c => c.Name == name);
return Task.FromResult(container?.State switch
lock (containersLock)
{
null => "absent",
ContainerState.Running => "running",
_ => "stopped",
});
return Task.FromResult(containers.TryGetValue(name, out var entry)
? Describe(entry.Container.State)
: "absent");
}
}
public Task<ContainerStatsInfo?> ContainerStatsAsync(string name, CancellationToken ct)
/// 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
{
// M1: confirm the stats surface (cgroup v2 counters are what the Swift resource
// monitor folds into ContainerResourceSample).
var stats = RequireContainer(name).GetStatistics();
return Task.FromResult<ContainerStatsInfo?>(stats is null
? null
: new ContainerStatsInfo(stats.CpuUsageUsec, stats.MemoryUsedBytes, stats.MemoryLimitBytes, stats.OomKills));
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;
}
private static long? Number(string section) =>
long.TryParse(section.Trim(), out var value) ? value : null;
// MARK: - Processes
public Task<IWslcProcess> ExecAsync(long procId, ProcSpec spec, CancellationToken ct)
{
var container = RequireContainer(spec.Container);
var settings = new ProcessSettings
{
CmdLine = spec.Argv.ToList(),
WorkingDirectory = spec.Cwd,
OutputMode = ProcessOutputMode.Event,
Terminal = spec.Tty,
};
if (spec.Env is { } env)
foreach (var (key, value) in env) settings.Environment.Add(key, value);
if (spec.Uid is { } uid) settings.UserId = uid; // M1: verify uid semantics (§3.2);
if (spec.Gid is { } gid) settings.GroupId = gid; // fall back to a setpriv wrapper argv.
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");
var process = container.RunProcess(settings);
process.OutputReceived += (stderr, data) => events?.ProcOutput(procId, stderr, data);
var container = RequireContainer(spec.Container);
var settings = new Sdk.ProcessSettings
{
CommandLine = WithPrivilegeDrop(spec).ToList(), // CommandLine, not CmdLine
OutputMode = Sdk.ProcessOutputMode.Event,
};
if (spec.Cwd is { } cwd) settings.WorkingDirectory = cwd;
if (spec.Env is { Count: > 0 } env)
settings.EnvironmentVariables = new Dictionary<string, string>(env); // not Environment
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);
process.Exited += code => events?.ProcExited(procId, code);
try
{
process.Start();
}
catch (Exception e) when (e is not WslcError)
{
process.Dispose();
throw Translate(e, WslcError.StartFailed);
}
return Task.FromResult<IWslcProcess>(new WslcProcess(process));
}
private sealed class WslcProcess(Process process) : IWslcProcess
/// <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)
{
public Task WriteStdinAsync(ReadOnlyMemory<byte> data, CancellationToken ct)
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
{
process.WriteStdin(data.Span);
return Task.CompletedTask;
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();
}
}
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;
public async Task WriteStdinAsync(ReadOnlyMemory<byte> data, CancellationToken ct)
{
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();
}
}
public Task CloseStdinAsync(CancellationToken ct)
public async Task CloseStdinAsync(CancellationToken ct)
{
process.CloseStdin();
return Task.CompletedTask;
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();
}
}
public Task SignalAsync(int signal, CancellationToken ct)
{
process.Signal((Signal)signal);
process.Signal(MapSignal(signal));
return Task.CompletedTask;
}
public Task ResizeAsync(int cols, int rows, CancellationToken ct)
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
{
process.ResizeTerminal(cols, rows);
return Task.CompletedTask;
}
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);
}
}
#endif