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 "