diff --git a/NucleicBroker.Tests/BrokerServiceTests.cs b/NucleicBroker.Tests/BrokerServiceTests.cs
index 8fa0eed..7bce5e8 100644
--- a/NucleicBroker.Tests/BrokerServiceTests.cs
+++ b/NucleicBroker.Tests/BrokerServiceTests.cs
@@ -51,6 +51,31 @@ public sealed class BrokerServiceTests
var caps = result.GetProperty("capabilities").EnumerateArray().Select(c => c.GetString()).ToList();
Assert.Contains("proc", caps);
Assert.DoesNotContain("ai", caps); // UnavailableAiProvider
+ // The facade's own capabilities ride the same list as the RPC families, because hostd
+ // needs both to decide what to attempt (docs/WINDOWS_PORT.md D13).
+ Assert.Contains("tty", caps);
+ Assert.Contains("reattach", caps);
+ }
+
+ ///
+ /// A compat-SDK-only facade reports no `tty`/`reattach`/`enumerate`, and that MUST reach
+ /// hostd through `hello` — it is the whole degradation mechanism D13 relies on, and the
+ /// difference between the Terminal panel being disabled and it failing in the user's face.
+ ///
+ [Fact]
+ public async Task Hello_OmitsCapabilitiesTheFacadeCannotServe()
+ {
+ wslc.CapabilityList = ["stats"]; // what WslcFacade reports with only the compat SDK
+ var result = Result(Assert.Single(await RoundTrip("""{"jsonrpc":"2.0","id":1,"method":"hello"}""")));
+ var caps = result.GetProperty("capabilities").EnumerateArray().Select(c => c.GetString()).ToList();
+ Assert.Contains("stats", caps);
+ Assert.DoesNotContain("tty", caps);
+ Assert.DoesNotContain("reattach", caps);
+ Assert.DoesNotContain("enumerate", caps);
+ // The RPC families are still advertised: the methods exist and answer, they just answer
+ // `unsupported` for the parts the surface cannot do.
+ Assert.Contains("container", caps);
+ Assert.Contains("proc", caps);
}
[Fact]
diff --git a/NucleicBroker.Tests/FakeWslc.cs b/NucleicBroker.Tests/FakeWslc.cs
index a040cc2..da88094 100644
--- a/NucleicBroker.Tests/FakeWslc.cs
+++ b/NucleicBroker.Tests/FakeWslc.cs
@@ -24,6 +24,12 @@ public sealed class FakeWslc : IWslc
public string? WslcVersion => "9.9-test";
+ /// The fake is deliberately fully-capable: it stands in for a facade with BOTH wslc surfaces
+ /// bound, so the broker's happy path is what these tests exercise. Degradation is the
+ /// facade's business (docs/WINDOWS_PORT.md D13), and tests that care set this directly.
+ public List CapabilityList = ["enumerate", "reattach", "tty", "stats"];
+ public IReadOnlyList Capabilities => CapabilityList;
+
public void SetEvents(IBrokerEvents events) => Events = events;
private void Record(string call)
diff --git a/NucleicBroker/BrokerService.cs b/NucleicBroker/BrokerService.cs
index 0b1187a..cd97724 100644
--- a/NucleicBroker/BrokerService.cs
+++ b/NucleicBroker/BrokerService.cs
@@ -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 { "components", "session", "image", "container", "proc" };
+ caps.AddRange(wslc.Capabilities);
if (ai.IsAvailable) caps.Add("ai");
return new
{
diff --git a/NucleicBroker/IWslc.cs b/NucleicBroker/IWslc.cs
index 56c8dc0..4e60873 100644
--- a/NucleicBroker/IWslc.cs
+++ b/NucleicBroker/IWslc.cs
@@ -15,6 +15,24 @@ public interface IWslc
/// Reported in the `hello` capabilities exchange; null when wslc is absent.
string? WslcVersion { get; }
+ ///
+ /// 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:
+ ///
+ /// - enumerate — service-backed container enumeration. Without it
+ /// container.list answers from the broker's OWN roster, so it goes empty across a
+ /// broker restart and ContainerManager.reconcile sees an empty sandbox.
+ /// - reattach — re-adopting a running session or container. Without it a broker
+ /// restart cannot recover the session and session.ensure fails
+ /// .
+ /// - tty — pty allocation and resize, i.e. §7's Terminal panel.
+ /// - stats — per-container resource sampling. Reported when stats are available
+ /// by ANY means, including the in-guest cgroup read the compat facade falls back to.
+ ///
+ ///
+ IReadOnlyList Capabilities { get; }
+
/// Install the sink BEFORE any operation that can emit events.
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";
+
+ /// 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.
+ public const string Unsupported = "unsupported";
+
+ /// 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 because
+ /// the remedy differs: the sandbox is UP, this broker just cannot reach it.
+ public const string SessionExists = "session_exists";
}
// DTOs — property names (after camel-casing) match the §3.3 wire keys exactly.
diff --git a/NucleicBroker/NucleicBroker.csproj b/NucleicBroker/NucleicBroker.csproj
index e6b57ab..43a7c8e 100644
--- a/NucleicBroker/NucleicBroker.csproj
+++ b/NucleicBroker/NucleicBroker.csproj
@@ -16,10 +16,20 @@
-
- net9.0-windows10.0.19041.0
+
+ net9.0-windows10.0.26100.0
+
+ 10.0.26100.80
$(DefineConstants);USE_WSLC
diff --git a/NucleicBroker/UnavailableWslc.cs b/NucleicBroker/UnavailableWslc.cs
index 0de9a3d..b380c69 100644
--- a/NucleicBroker/UnavailableWslc.cs
+++ b/NucleicBroker/UnavailableWslc.cs
@@ -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 Capabilities => [];
+
public void SetEvents(IBrokerEvents events) { }
private static WslcError Unavailable() =>
diff --git a/NucleicBroker/Wslc/WslcFacade.cs b/NucleicBroker/Wslc/WslcFacade.cs
index 06bd7ea..009f4a9 100644
--- a/NucleicBroker/Wslc/WslcFacade.cs
+++ b/NucleicBroker/Wslc/WslcFacade.cs
@@ -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.
+///
+/// The real Microsoft.WSL.Containers adapter (docs/WINDOWS_PORT.md §1.5, §3.2), compiled
+/// only with -p:UseWslc=true.
+///
+/// Written against the **measured** 2.9.3 surface, not the documentation: Microsoft Learn's own
+/// C# sample uses MemoryMB, CmdLine and DeleteContainerFlags, none of which
+/// exist in the shipped assembly. Re-derive with windows/spikes/WslcApiDump after any
+/// version bump — that is now its whole job.
+///
+/// Everything the SDK is aliased as Sdk deliberately: ImageInfo,
+/// ContainerInfo, ProcessSettings and Signal all exist BOTH here and in the
+/// parent NucleicBroker namespace, and an unqualified using 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
+/// WSLCCompat.idl, 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. Sdk.Container has **no Name property** and Sdk.Session has no
+/// GetContainers(), so a container is reachable only through the handle
+/// CreateContainer returned. This class therefore keeps its own name→handle roster,
+/// and that roster — not the service — is what container.list answers from.
+/// 2. The roster dies with the process. A restarted broker cannot see, address or re-adopt
+/// anything it created before, and session.ensure fails
+/// because the compat Start() refuses a name that
+/// is already running (confirmed on hardware, §13.1). That is the D13 reattach gap.
+///
+/// Both are declared through 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.
+///
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 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 Capabilities => ["stats"];
public void SetEvents(IBrokerEvents events) => this.events = events;
+ // MARK: - Components (§8 onboarding)
+
public Task> MissingComponentsAsync(CancellationToken ct)
{
- var flags = WslcService.GetMissingComponents();
- var missing = new List();
- foreach (var flag in Enum.GetValues())
- 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>(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 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)");
+ ///
+ /// 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.
+ ///
+ private static async Task 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);
}
}
+ /// `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.
+ 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> ListImagesAsync(CancellationToken ct) =>
Task.FromResult>(
- 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 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:` 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(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(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(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> ListContainersAsync(CancellationToken ct) =>
- Task.FromResult>(
- RequireSession().GetContainers()
- .Select(c => new ContainerInfo(c.Name, c.Image, c.State.ToString().ToLowerInvariant()))
- .ToList());
+ ///
+ /// 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 and why §2.3's post-restart reconcile is a known
+ /// gap rather than a silently empty answer.
+ ///
+ public Task> ListContainersAsync(CancellationToken ct)
+ {
+ lock (containersLock)
+ {
+ return Task.FromResult>(
+ containers
+ .Select(kv => new ContainerInfo(kv.Key, kv.Value.Image, Describe(kv.Value.Container.State)))
+ .ToList());
+ }
+ }
public Task 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 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(stats is null
- ? null
- : new ContainerStatsInfo(stats.CpuUsageUsec, stats.MemoryUsedBytes, stats.MemoryLimitBytes, stats.OomKills));
+ Sdk.ContainerState.Running => "running",
+ Sdk.ContainerState.Deleted => "absent",
+ _ => "stopped",
+ };
+
+ ///
+ /// There is no GetStatistics() on the compat surface — Container offers only
+ /// Id, State, InitProcess and Inspect(). 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.
+ ///
+ public async Task 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 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(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(new WslcProcess(process));
}
- private sealed class WslcProcess(Process process) : IWslcProcess
+ ///
+ /// ProcessSettings has no UserId/GroupId, 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.
+ ///
+ private static IReadOnlyList WithPrivilegeDrop(ProcSpec spec)
{
- public Task WriteStdinAsync(ReadOnlyMemory 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,
+ ];
+ }
+
+ /// 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.
+ private static async Task<(int ExitCode, string Output)> RunCapturingAsync(
+ Sdk.Container container, IReadOnlyList 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(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 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
+
+ ///
+ /// POSIX int → the named Signal 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.
+ ///
+ 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;
+
+ ///
+ /// 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.
+ ///
+ 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
diff --git a/build.ps1 b/build.ps1
index b1760ee..9a8f5dd 100644
--- a/build.ps1
+++ b/build.ps1
@@ -232,6 +232,16 @@ function Build-Core([hashtable]$sql) {
function Build-Broker {
Write-Step 'broker: dotnet test windows\Nucleic.sln'
& dotnet test (Join-Path $repo 'windows\Nucleic.sln') --nologo | Out-Host
+ if ($LASTEXITCODE -ne 0) { return $LASTEXITCODE }
+
+ # The tests run against FakeWslc, so they never compile Wslc/WslcFacade.cs — the one file
+ # that touches the preview SDK, and the one most likely to break when the pin moves. Build
+ # it too, or the real facade is unguarded on the only machine that can run it.
+ #
+ # Build, not test: exercising it needs a live wslc service, which is M1 (a2)'s job.
+ Write-Step 'broker: dotnet build -p:UseWslc=true (the real Microsoft.WSL.Containers facade)'
+ & dotnet build (Join-Path $repo 'windows\NucleicBroker\NucleicBroker.csproj') `
+ -p:UseWslc=true --nologo | Out-Host
return $LASTEXITCODE
}
diff --git a/spikes/WslcApiDump/FacadeAssumptions.cs b/spikes/WslcApiDump/FacadeAssumptions.cs
index 8dfd098..c6f8f52 100644
--- a/spikes/WslcApiDump/FacadeAssumptions.cs
+++ b/spikes/WslcApiDump/FacadeAssumptions.cs
@@ -28,7 +28,12 @@ internal static class FacadeAssumptions
"hello capabilities — hostd degrades across preview→GA churn on this"),
new("WslcService", "GetMissingComponents", Kind.Method,
"components.missing RPC; returns IReadOnlyList, NOT a flags enum"),
- new("WslcService", "InstallWithDependencies", Kind.Method, "components.install RPC"),
+ new("WslcService", "InstallWithDependenciesAsync", Kind.Method,
+ "components.install RPC — the ASYNC form, because only it carries InstallProgress"),
+ new("InstallProgress", "Component", Kind.Property,
+ "which component is installing → components.installProgress status"),
+ new("InstallProgress", "Total", Kind.Property,
+ "denominator for the install percentage (Progress/Total)"),
new("Component", "WslPackage", Kind.EnumValue,
"one of the three components onboarding can report missing"),
new("ServiceVersion", "Major", Kind.Property, "the version triple reported in hello"),
@@ -40,9 +45,13 @@ internal static class FacadeAssumptions
"session.ensure memoryMB — NOT MemoryMB; a resize needs a sandbox restart (§3.2)"),
new("SessionSettings", "Timeout", Kind.Property, "idle timeout for the whole session VM"),
new("Session", ".ctor", Kind.Constructor,
- "there is NO CreateOrOpen — construction is the only entry point, and whether a second "
- + "construction with an existing name attaches or throws Error.SessionReserved is the "
- + "open question broker reattach (§2.3) hangs on"),
+ "there is NO CreateOrOpen — construction is the only entry point, and it is LAZY: a "
+ + "second construction with an existing name succeeds and means nothing. Start() is "
+ + "where the service is consulted, and it refuses with ERROR_ALREADY_EXISTS, which is "
+ + "why broker reattach (§2.3) cannot be served from this surface"),
+ new("Session", "ProcessCrashed", Kind.Event,
+ "ProcessCrashInformation — the only crash detail wslc offers, and what makes a "
+ + "SIGKILLed agent explicable in the host log (the Swift diagnoseKill seam)"),
new("Session", "Start", Kind.Method, "brings the session VM up after construction"),
new("Session", "Terminate", Kind.Method, "session.terminate RPC"),
new("Session", "Terminated", Kind.Event,
@@ -51,18 +60,24 @@ internal static class FacadeAssumptions
"distinguishes a crash from an orderly shutdown in the session.down reason"),
// ---- Images ----
- new("Session", "PullImage", Kind.Method, "image.pull RPC (naros-agent from GHCR), sync form"),
new("Session", "PullImageAsync", Kind.Method,
- "the async form, which is where pull PROGRESS comes from (ImageProgress)"),
+ "image.pull RPC (naros-agent from GHCR). The async form specifically: the sync "
+ + "PullImage reports no progress at all"),
new("PullImageOptions", ".ctor", Kind.Constructor, "PullImageOptions(uri)"),
new("PullImageOptions", "RegistryAuth", Kind.Property,
"GHCR auth — a STRING, not a credentials object"),
+ new("Session", "Authenticate", Kind.Method,
+ "where that string COMES FROM: Authenticate(registryUri, user, password) mints the "
+ + "identity token PullImageOptions.RegistryAuth wants. Without this the auth field "
+ + "has no documented producer and a private GHCR pull cannot work"),
new("ImageProgress", "CurrentBytes", Kind.Property,
"→ image.pullProgress → the existing controlDownloadProgress UI surface"),
new("ImageProgressStatus", "Downloading", Kind.EnumValue, "pull progress phase"),
new("Session", "GetImages", Kind.Method, "image.list / image.inspect"),
new("ImageInfo", "Name", Kind.Property, "image ref — NOT .Reference"),
- new("ImageInfo", "Sha256", Kind.Property, "image digest — NOT .Digest"),
+ new("ImageInfo", "Sha256", Kind.Property,
+ "image digest — NOT .Digest, and an IBuffer of raw bytes rather than a string, so the "
+ + "facade hex-encodes it into the sha256: the rest of Nucleic speaks"),
new("Session", "DeleteImage", Kind.Method, "image.delete"),
// ---- Containers ----
@@ -85,9 +100,17 @@ internal static class FacadeAssumptions
new("Container", "Inspect", Kind.Method,
"the ONLY per-container introspection there is — there is no GetStatistics(), so "
+ "container.stats has to exec cgroup reads instead (§13.1 finding 2)"),
+ // NOTE the absence: there is no Container.Name and no Session.GetContainers(), so a
+ // container is only ever reachable through the handle CreateContainer returned. That is
+ // why the facade keeps its own name→handle roster, and why container.list cannot see
+ // anything created before a broker restart (§13.1 finding 1).
+ new("Container", "Id", Kind.Property,
+ "the only identity the SDK gives back — there is NO Name property to match on"),
new("Container", "CreateProcess", Kind.Method,
"proc.exec — NOT RunProcess, and it does not start the process"),
new("ContainerState", "Running", Kind.EnumValue, "the one state the facade tests by name"),
+ new("ContainerState", "Deleted", Kind.EnumValue,
+ "folds to \"absent\" — a deleted handle is gone as far as the Swift policy layer cares"),
new("DeleteContainerOption", "Force", Kind.EnumValue, "container.delete force"),
new("Error", "ContainerNotFound", Kind.EnumValue,
"structured failure codes — a better source for the RPC's data.kind than string matching"),
@@ -107,7 +130,8 @@ internal static class FacadeAssumptions
"→ proc.stderr — a SEPARATE event, not a stderr flag on one handler"),
new("Process", "Exited", Kind.Event, "→ proc.exit; must never overtake output (OutboundWriter)"),
new("Process", "GetInputStream", Kind.Method,
- "proc.stdin — a WinRT stream, not a WriteStdin call; closing it is proc.closeStdin"),
+ "proc.stdin — a WinRT IOutputStream written through a DataWriter, not a WriteStdin "
+ + "call; disposing the STREAM is what the guest sees as EOF, i.e. proc.closeStdin"),
new("Process", "Signal", Kind.Method, "proc.signal — the agent's real POSIX Stop path"),
new("Signal", "SIGKILL", Kind.EnumValue,
"signals are a NAMED enum; the RPC carries POSIX ints, so the broker maps them, and "