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
+25
View File
@@ -51,6 +51,31 @@ public sealed class BrokerServiceTests
var caps = result.GetProperty("capabilities").EnumerateArray().Select(c => c.GetString()).ToList(); var caps = result.GetProperty("capabilities").EnumerateArray().Select(c => c.GetString()).ToList();
Assert.Contains("proc", caps); Assert.Contains("proc", caps);
Assert.DoesNotContain("ai", caps); // UnavailableAiProvider 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);
}
/// <summary>
/// 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.
/// </summary>
[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] [Fact]
+6
View File
@@ -24,6 +24,12 @@ public sealed class FakeWslc : IWslc
public string? WslcVersion => "9.9-test"; 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<string> CapabilityList = ["enumerate", "reattach", "tty", "stats"];
public IReadOnlyList<string> Capabilities => CapabilityList;
public void SetEvents(IBrokerEvents events) => Events = events; public void SetEvents(IBrokerEvents events) => Events = events;
private void Record(string call) private void Record(string call)
+5
View File
@@ -86,7 +86,12 @@ public sealed class BrokerService : IBrokerEvents
{ {
case "hello": 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" }; var caps = new List<string> { "components", "session", "image", "container", "proc" };
caps.AddRange(wslc.Capabilities);
if (ai.IsAvailable) caps.Add("ai"); if (ai.IsAvailable) caps.Add("ai");
return new 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> /// <summary>Reported in the `hello` capabilities exchange; null when wslc is absent.</summary>
string? WslcVersion { get; } 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> /// <summary>Install the sink BEFORE any operation that can emit events.</summary>
void SetEvents(IBrokerEvents 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 PullFailed = "image_pull_failed";
public const string StartFailed = "start_failed"; public const string StartFailed = "start_failed";
public const string AiUnavailable = "ai_unavailable"; 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. // DTOs — property names (after camel-casing) match the §3.3 wire keys exactly.
+14 -4
View File
@@ -16,10 +16,20 @@
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(UseWslc)' == 'true'"> <PropertyGroup Condition="'$(UseWslc)' == 'true'">
<!-- Windows SDK 19041, matching the package's own lib TFM <!-- 26100, NOT the 19041 the package's lib folder advertises. Those disagree, and the
(lib/net8.0-windows10.0.19041.0/wslcsdkcs.dll). Targeting a higher SDK revision binary wins: `lib/net8.0-windows10.0.19041.0/wslcsdkcs.dll` is itself compiled against
(26100) only adds a targeting-pack requirement the package does not need. --> Microsoft.Windows.SDK.NET 10.0.26100.79, so a 19041 targeting pack cannot load it —
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework> `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> <DefineConstants>$(DefineConstants);USE_WSLC</DefineConstants>
</PropertyGroup> </PropertyGroup>
+3
View File
@@ -10,6 +10,9 @@ public sealed class UnavailableWslc : IWslc
{ {
public string? WslcVersion => null; 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) { } public void SetEvents(IBrokerEvents events) { }
private static WslcError Unavailable() => private static WslcError Unavailable() =>
+563 -139
View File
@@ -1,94 +1,156 @@
#if USE_WSLC #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; namespace NucleicBroker.Wslc;
// The REAL Microsoft.WSL.Containers adapter (docs/WINDOWS_PORT.md §1.5, §3.2), compiled /// <summary>
// only with -p:UseWslc=true on Windows. /// The real <c>Microsoft.WSL.Containers</c> adapter (docs/WINDOWS_PORT.md §1.5, §3.2), compiled
// /// only with <c>-p:UseWslc=true</c>.
// !! KNOWN WRONG AS WRITTEN — DO NOT BUILD ON IT. !! ///
// /// Written against the **measured** 2.9.3 surface, not the documentation: Microsoft Learn's own
// M1 spike (a) has run (docs/WINDOWS_PORT.md §13.1) and the real API differs from this /// C# sample uses <c>MemoryMB</c>, <c>CmdLine</c> and <c>DeleteContainerFlags</c>, none of which
// transcription in roughly twenty places. Most are renames that belong exactly here and /// exist in the shipped assembly. Re-derive with <c>windows/spikes/WslcApiDump</c> after any
// nowhere else, which is what the IWslc seam is for: GetVersion not GetServiceVersion, /// version bump — that is now its whole job.
// `new Session(settings)` + Start() not CreateOrOpen, MemorySizeInMB, HostName, ImageName, ///
// CreateProcess-then-Start rather than RunProcess, DeleteContainerOption, a named Signal /// Everything the SDK is aliased as <c>Sdk</c> deliberately: <c>ImageInfo</c>,
// enum, RegistryAuth as a string, ImageInfo.Name/.Sha256, and two separate output events /// <c>ContainerInfo</c>, <c>ProcessSettings</c> and <c>Signal</c> all exist BOTH here and in the
// instead of one with a stderr flag. /// parent <c>NucleicBroker</c> namespace, and an unqualified <c>using</c> makes each one
// /// ambiguous at the point of use.
// 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 /// **What this facade cannot do, and why that is reported rather than hidden.** The SDK projects
// projection hiding them: `wslcsdk.dll` wraps `WSLCCompat.idl`, the deliberately-stable /// <c>WSLCCompat.idl</c>, the deliberately-stable SDK-facing COM surface, and that surface
// SDK-facing COM surface, and that surface genuinely lacks them. /// 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:
// They DO exist on `wslc.idl`, the service-internal COM interface `wslc.exe` itself calls ///
// (IWSLCSessionManager, IID 82A7ABC8-6B50-43FC-AB96-15FBBE7E8760) — ListContainers, Stats, /// 1. <c>Sdk.Container</c> has **no Name property** and <c>Sdk.Session</c> has no
// ResizeTty, OpenContainer/Attach, OpenSessionByName, and IWSLCVirtualMachine::GetId, which /// <c>GetContainers()</c>, so a container is reachable only through the handle
// is the VM GUID an AF_HYPERV bind needs. Both IDLs are in the open-source WSL repo. The /// <c>CreateContainer</c> returned. This class therefore keeps its own name→handle roster,
// internal one carries an explicit "ABI breaking changes are OK" warning. /// 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
// D13 (§13.1): this class binds BOTH surfaces — compat SDK for everything it covers, internal /// anything it created before, and <c>session.ensure</c> fails
// COM for those five. Shelling out to `wslc.exe` was considered and rejected (a spawn per call, /// <see cref="WslcError.SessionExists"/> because the compat <c>Start()</c> refuses a name that
// scraped text, no events, a second mechanism to maintain). Because the internal ABI is /// is already running (confirmed on hardware, §13.1). That is the D13 reattach gap.
// 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 /// Both are declared through <see cref="Capabilities"/> so hostd degrades from the `hello`
// than failing the sandbox. All of that lives inside this class: `IWslc` does not change, so /// exchange instead of discovering them at a call site. Closing them needs the service-internal
// nothing on the Swift side knows which surface answered. /// COM interface; see docs/WINDOWS_PORT.md §13.1 for what that costs and what still gates it.
// ///
// Also note: `ProcessSettings` has no uid/gid, so exec wraps argv in setpriv/su — which /// WslcService (components) → Session (VM host, images) → Container → Process.
// §3.2 already anticipated as the fallback, so it costs nothing. /// </summary>
//
// 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.
public sealed class WslcFacade : IWslc public sealed class WslcFacade : IWslc
{ {
private IBrokerEvents? events; private IBrokerEvents? events;
private Session? session; private Sdk.Session? session;
private string? gateway;
private readonly SemaphoreSlim sessionGate = new(1, 1); 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; public void SetEvents(IBrokerEvents events) => this.events = events;
// MARK: - Components (§8 onboarding)
public Task<IReadOnlyList<string>> MissingComponentsAsync(CancellationToken ct) public Task<IReadOnlyList<string>> MissingComponentsAsync(CancellationToken ct)
{ {
var flags = WslcService.GetMissingComponents(); // A LIST of Component, not a flags enum — and it answers from OS feature state, so it
var missing = new List<string>(); // works even when the service class isn't registered. That makes it the one call that
foreach (var flag in Enum.GetValues<ComponentFlags>()) // explains every other failure.
if (flag != ComponentFlags.None && flags.HasFlag(flag)) var missing = Sdk.WslcService.GetMissingComponents().Select(c => c.ToString()).ToList();
missing.Add(flag.ToString().ToLowerInvariant());
return Task.FromResult<IReadOnlyList<string>>(missing); return Task.FromResult<IReadOnlyList<string>>(missing);
} }
public async Task InstallComponentsAsync(CancellationToken ct) public async Task InstallComponentsAsync(CancellationToken ct)
{ {
// M1: wire the component-install progress callback into events.InstallProgress. var operation = Sdk.WslcService.InstallWithDependenciesAsync();
await WslcService.InstallComponentsAsync(WslcService.GetMissingComponents()) operation.Progress = (_, progress) => events?.InstallProgress(
.AsTask(ct).ConfigureAwait(false); progress.Component.ToString(),
progress.Total == 0 ? 0 : 100.0 * progress.Progress / progress.Total);
await operation.AsTask(ct).ConfigureAwait(false);
events?.InstallProgress("done", 100); events?.InstallProgress("done", 100);
} }
// MARK: - Session
public async Task<string> EnsureSessionAsync(SessionSpec spec, CancellationToken ct) public async Task<string> EnsureSessionAsync(SessionSpec spec, CancellationToken ct)
{ {
await sessionGate.WaitAsync(ct).ConfigureAwait(false); await sessionGate.WaitAsync(ct).ConfigureAwait(false);
try 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); created.Start();
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");
} }
// M1: confirm how the WSL vEthernet gateway address is surfaced (session catch (Exception e) when (HResultOf(e) == ErrorAlreadyExists)
// property vs. querying the vNIC); NAT mode is preferred for determinism {
// (docs/WINDOWS_PORT.md §5). Mirrored mode reports the reachable host address. created.Dispose();
return session.HostGatewayAddress?.ToString() // The constructor is lazy — it only captures settings — so reaching this means a
?? throw new WslcError(WslcError.StartFailed, "wslc session has no gateway address"); // 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 finally
{ {
@@ -98,36 +160,111 @@ public sealed class WslcFacade : IWslc
public Task TerminateSessionAsync(CancellationToken ct) public Task TerminateSessionAsync(CancellationToken ct)
{ {
lock (containersLock)
{
foreach (var entry in containers.Values) entry.Container.Dispose();
containers.Clear();
}
session?.Terminate(); session?.Terminate();
session?.Dispose();
session = null; session = null;
gateway = null;
return Task.CompletedTask; return Task.CompletedTask;
} }
private Session RequireSession() => /// <summary>
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.
/// </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) public async Task PullImageAsync(string reference, RegistryAuth? auth, CancellationToken ct)
{ {
var options = new PullImageOptions(reference); var current = RequireSession();
if (auth is { Username: { } user, Password: { } pass }) var options = new Sdk.PullImageOptions(reference);
options.Credentials = new RegistryCredentials(user, pass);
options.Progress += (status, current, total) => // RegistryAuth is a plain STRING, not a credentials object — and the string is minted by
events?.PullProgress(reference, status, current, total); // 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 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) => public Task<IReadOnlyList<ImageInfo>> ListImagesAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<ImageInfo>>( Task.FromResult<IReadOnlyList<ImageInfo>>(
RequireSession().GetImages() RequireSession().GetImages().Select(Describe).ToList());
.Select(i => new ImageInfo(i.Reference, i.Digest, i.Size))
.ToList());
public Task DeleteImageAsync(string reference, CancellationToken ct) public Task DeleteImageAsync(string reference, CancellationToken ct)
{ {
@@ -137,129 +274,416 @@ public sealed class WslcFacade : IWslc
public Task<ImageInfo?> InspectImageAsync(string reference, CancellationToken ct) public Task<ImageInfo?> InspectImageAsync(string reference, CancellationToken ct)
{ {
var image = RequireSession().GetImages().FirstOrDefault(i => i.Reference == reference); var image = RequireSession().GetImages().FirstOrDefault(i => i.Name == reference);
return Task.FromResult(image is null ? null : new ImageInfo(image.Reference, image.Digest, image.Size)); 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) public Task CreateContainerAsync(ContainerCreateSpec spec, CancellationToken ct)
{ {
var settings = new ContainerSettings(spec.Image) { Name = spec.Name }; var settings = new Sdk.ContainerSettings(spec.Image) { Name = spec.Name };
if (spec.Hostname is { } hostname) settings.Hostname = hostname; if (spec.Hostname is { } hostname) settings.HostName = hostname; // capital N
if (spec.NetworkingMode is { } mode) if (spec.NetworkingMode is { } mode)
settings.NetworkingMode = Enum.Parse<ContainerNetworkingMode>(mode, ignoreCase: true); settings.NetworkingMode = Enum.Parse<Sdk.ContainerNetworkingMode>(mode, ignoreCase: true);
foreach (var volume in spec.Volumes ?? []) if (spec.Volumes is { Count: > 0 } volumes)
settings.Volumes.Add(new ContainerVolume(volume.Host, volume.Guest, volume.ReadOnly)); settings.Volumes = volumes
if (spec.InitArgv is { Count: > 0 } argv) .Select(v => new Sdk.ContainerVolume(v.Host, v.Guest, v.ReadOnly))
settings.InitProcess = new ProcessSettings { CmdLine = argv.ToList() }; .ToList();
if (spec.Env is { } env)
foreach (var (key, value) in env) // env and initArgv both live on InitProcess, so build it when EITHER is present —
settings.InitProcess?.Environment.Add(key, value); // 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 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) catch (Exception e) when (e is not WslcError)
{ {
throw new WslcError(WslcError.StartFailed, e.Message); throw Translate(e, WslcError.StartFailed);
} }
return Task.CompletedTask; return Task.CompletedTask;
} }
private Container RequireContainer(string name) => private Sdk.Container RequireContainer(string name)
RequireSession().GetContainers().FirstOrDefault(c => c.Name == name) {
?? throw new WslcError(WslcError.NotFound, $"no container named {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) 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; return Task.CompletedTask;
} }
public Task StopContainerAsync(string name, int signal, int graceMs, CancellationToken ct) 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; return Task.CompletedTask;
} }
public Task DeleteContainerAsync(string name, bool force, CancellationToken ct) 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; return Task.CompletedTask;
} }
public Task<IReadOnlyList<ContainerInfo>> ListContainersAsync(CancellationToken ct) => /// <summary>
Task.FromResult<IReadOnlyList<ContainerInfo>>( /// The broker's OWN roster, not the service's. The compat SDK has no enumeration call at all,
RequireSession().GetContainers() /// so this cannot see a container this process did not create — which is why `enumerate` is
.Select(c => new ContainerInfo(c.Name, c.Image, c.State.ToString().ToLowerInvariant())) /// absent from <see cref="Capabilities"/> and why §2.3's post-restart reconcile is a known
.ToList()); /// 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) public Task<string> ContainerStateAsync(string name, CancellationToken ct)
{ {
var container = RequireSession().GetContainers().FirstOrDefault(c => c.Name == name); lock (containersLock)
return Task.FromResult(container?.State switch
{ {
null => "absent", return Task.FromResult(containers.TryGetValue(name, out var entry)
ContainerState.Running => "running", ? Describe(entry.Container.State)
_ => "stopped", : "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 Sdk.ContainerState.Running => "running",
// monitor folds into ContainerResourceSample). Sdk.ContainerState.Deleted => "absent",
var stats = RequireContainer(name).GetStatistics(); _ => "stopped",
return Task.FromResult<ContainerStatsInfo?>(stats is null };
? null
: new ContainerStatsInfo(stats.CpuUsageUsec, stats.MemoryUsedBytes, stats.MemoryLimitBytes, stats.OomKills)); /// <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) public Task<IWslcProcess> ExecAsync(long procId, ProcSpec spec, CancellationToken ct)
{ {
var container = RequireContainer(spec.Container); if (spec.Tty)
var settings = new ProcessSettings // 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).
CmdLine = spec.Argv.ToList(), throw new WslcError(
WorkingDirectory = spec.Cwd, WslcError.Unsupported,
OutputMode = ProcessOutputMode.Event, "the wslc compat SDK cannot allocate a pty; proc.exec(tty:true) is unavailable");
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.
var process = container.RunProcess(settings); var container = RequireContainer(spec.Container);
process.OutputReceived += (stderr, data) => events?.ProcOutput(procId, stderr, data); 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); 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)); 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); CommandLine = argv.ToList(),
return Task.CompletedTask; 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(); await stdinGate.WaitAsync(ct).ConfigureAwait(false);
return Task.CompletedTask; 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) public Task SignalAsync(int signal, CancellationToken ct)
{ {
process.Signal((Signal)signal); process.Signal(MapSignal(signal));
return Task.CompletedTask; 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); 0x80040601 => WslcError.NotFound, // WSLC_E_IMAGE_NOT_FOUND
return Task.CompletedTask; 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 #endif
+10
View File
@@ -232,6 +232,16 @@ function Build-Core([hashtable]$sql) {
function Build-Broker { function Build-Broker {
Write-Step 'broker: dotnet test windows\Nucleic.sln' Write-Step 'broker: dotnet test windows\Nucleic.sln'
& dotnet test (Join-Path $repo 'windows\Nucleic.sln') --nologo | Out-Host & 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 return $LASTEXITCODE
} }
+32 -8
View File
@@ -28,7 +28,12 @@ internal static class FacadeAssumptions
"hello capabilities — hostd degrades across preview→GA churn on this"), "hello capabilities — hostd degrades across preview→GA churn on this"),
new("WslcService", "GetMissingComponents", Kind.Method, new("WslcService", "GetMissingComponents", Kind.Method,
"components.missing RPC; returns IReadOnlyList<Component>, NOT a flags enum"), "components.missing RPC; returns IReadOnlyList<Component>, 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, new("Component", "WslPackage", Kind.EnumValue,
"one of the three components onboarding can report missing"), "one of the three components onboarding can report missing"),
new("ServiceVersion", "Major", Kind.Property, "the version triple reported in hello"), 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)"), "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("SessionSettings", "Timeout", Kind.Property, "idle timeout for the whole session VM"),
new("Session", ".ctor", Kind.Constructor, new("Session", ".ctor", Kind.Constructor,
"there is NO CreateOrOpen — construction is the only entry point, and whether a second " "there is NO CreateOrOpen — construction is the only entry point, and it is LAZY: a "
+ "construction with an existing name attaches or throws Error.SessionReserved is the " + "second construction with an existing name succeeds and means nothing. Start() is "
+ "open question broker reattach (§2.3) hangs on"), + "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", "Start", Kind.Method, "brings the session VM up after construction"),
new("Session", "Terminate", Kind.Method, "session.terminate RPC"), new("Session", "Terminate", Kind.Method, "session.terminate RPC"),
new("Session", "Terminated", Kind.Event, 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"), "distinguishes a crash from an orderly shutdown in the session.down reason"),
// ---- Images ---- // ---- Images ----
new("Session", "PullImage", Kind.Method, "image.pull RPC (naros-agent from GHCR), sync form"),
new("Session", "PullImageAsync", Kind.Method, 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", ".ctor", Kind.Constructor, "PullImageOptions(uri)"),
new("PullImageOptions", "RegistryAuth", Kind.Property, new("PullImageOptions", "RegistryAuth", Kind.Property,
"GHCR auth — a STRING, not a credentials object"), "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, new("ImageProgress", "CurrentBytes", Kind.Property,
"→ image.pullProgress → the existing controlDownloadProgress UI surface"), "→ image.pullProgress → the existing controlDownloadProgress UI surface"),
new("ImageProgressStatus", "Downloading", Kind.EnumValue, "pull progress phase"), new("ImageProgressStatus", "Downloading", Kind.EnumValue, "pull progress phase"),
new("Session", "GetImages", Kind.Method, "image.list / image.inspect"), new("Session", "GetImages", Kind.Method, "image.list / image.inspect"),
new("ImageInfo", "Name", Kind.Property, "image ref — NOT .Reference"), 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:<hex> the rest of Nucleic speaks"),
new("Session", "DeleteImage", Kind.Method, "image.delete"), new("Session", "DeleteImage", Kind.Method, "image.delete"),
// ---- Containers ---- // ---- Containers ----
@@ -85,9 +100,17 @@ internal static class FacadeAssumptions
new("Container", "Inspect", Kind.Method, new("Container", "Inspect", Kind.Method,
"the ONLY per-container introspection there is — there is no GetStatistics(), so " "the ONLY per-container introspection there is — there is no GetStatistics(), so "
+ "container.stats has to exec cgroup reads instead (§13.1 finding 2)"), + "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, new("Container", "CreateProcess", Kind.Method,
"proc.exec — NOT RunProcess, and it does not start the process"), "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", "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("DeleteContainerOption", "Force", Kind.EnumValue, "container.delete force"),
new("Error", "ContainerNotFound", Kind.EnumValue, new("Error", "ContainerNotFound", Kind.EnumValue,
"structured failure codes — a better source for the RPC's data.kind than string matching"), "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"), "→ 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", "Exited", Kind.Event, "→ proc.exit; must never overtake output (OutboundWriter)"),
new("Process", "GetInputStream", Kind.Method, 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("Process", "Signal", Kind.Method, "proc.signal — the agent's real POSIX Stop path"),
new("Signal", "SIGKILL", Kind.EnumValue, new("Signal", "SIGKILL", Kind.EnumValue,
"signals are a NAMED enum; the RPC carries POSIX ints, so the broker maps them, and " "signals are a NAMED enum; the RPC carries POSIX ints, so the broker maps them, and "