#if USE_WSLC 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. /// /// 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 Sdk.Session? session; private string? gateway; private readonly SemaphoreSlim sessionGate = new(1, 1); /// D13's Tier 1 internal-COM arm, or null where it could not bind. Only used to recover an /// orphaned session after a broker restart; every other call rides the compat SDK. private readonly WslcInternal? recovery = WslcInternal.TryBind(); /// /// Construct the facade with COM security configured first. /// /// The ordering is load-bearing and easy to lose: `CoInitializeSecurity` must precede the /// **first COM call in the process**, and the compat SDK makes its own. Doing it in a factory /// keeps that constraint next to the code that depends on it rather than in `Program.cs`, /// where a later reorder would silently break session recovery with a `0x80070542` that reads /// as "not found". /// public static WslcFacade Create() { WslcInternal.InitializeSecurity(); return new WslcFacade(); } /// 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(); /// /// A container handle plus what we have learned about it. SetprivWorks is resolved /// lazily on the first uid-dropping exec and then cached — see /// . /// private sealed class Entry(Sdk.Container container, string image) { internal Sdk.Container Container { get; } = container; internal string Image { get; } = image; internal bool? SetprivWorks { get; set; } } 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; } } } /// /// Stats are always offered — not from the SDK (there is no `GetStatistics()`) but from an /// in-guest cgroup read, which is indistinguishable to hostd. `recover` is added when D13's /// Tier 1 arm bound: a broker restart re-adopts and clears its orphaned session instead of /// leaving the user a sandbox only `wsl --shutdown` can fix. /// /// Still absent, and deliberately: `enumerate` (`container.list` answers from this broker's /// own roster, not the service), `reattach` (containers do not survive a restart — Tier 2, /// blocked, §13.2) and `tty` (the compat surface has no pty). /// public IReadOnlyList Capabilities => recovery is not null ? ["stats", "recover"] : ["stats"]; public void SetEvents(IBrokerEvents events) => this.events = events; // MARK: - Components (§8 onboarding) public Task> MissingComponentsAsync(CancellationToken ct) { // 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) { 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 not null) return gateway!; var created = NewSession(spec); try { created.Start(); } catch (Exception e) when (HResultOf(e) == ErrorAlreadyExists) { // 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 that // died. The compat surface cannot re-adopt it, so recovery goes through D13's // internal-COM arm: open it, note what it was running, terminate it, retry. created.Dispose(); created = await RecoverAndRestartAsync(spec, ct).ConfigureAwait(false); } 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 { sessionGate.Release(); } } /// A settings-configured session with its handlers already attached. Subscribing /// must happen BEFORE `Start()`, or a session that dies during boot never reports down. private Sdk.Session NewSession(SessionSpec spec) { var settings = new Sdk.SessionSettings(spec.Name, spec.DataDir); if (spec.Cpu is { } cpu) settings.CpuCount = (uint)cpu; if (spec.MemoryMB is { } memory) settings.MemorySizeInMB = (uint)memory; var created = new Sdk.Session(settings); created.Terminated += reason => events?.SessionDown(reason.ToString()); // Not surfaced as an RPC, but it is the only crash detail wslc offers and it is what // makes a SIGKILLed agent explicable in the host log (the Swift `diagnoseKill` seam). created.ProcessCrashed += crash => Console.Error.WriteLine( $"wslc: process {crash.ProcessName} (pid {crash.Pid}) crashed with signal " + $"{crash.Signal}; dump at {crash.DumpPath}"); return created; } /// /// A session of this name is already running and we do not own it — the signature of a broker /// that died with its sandbox up (docs/WINDOWS_PORT.md §13.2, D13 Tier 1). /// /// Clear it through the internal COM arm and start fresh. The orphan's containers are lost, /// which is the deliberate Tier 1 trade: they are lost today too (nothing could reach that /// session at all), `ContainerManager.reconcile` already copes with an empty sandbox, and the /// alternative — keeping them alive — is Tier 2 and blocked. What this buys is that a broker /// restart stops requiring the user to run `wsl --shutdown` by hand. /// private async Task RecoverAndRestartAsync(SessionSpec spec, CancellationToken ct) { if (recovery is null) throw new WslcError( WslcError.SessionExists, $"a wslc session named '{spec.Name}' is already running, the compat SDK cannot " + "re-adopt it, and the internal COM interface did not bind; run `wsl --shutdown`"); if (recovery.RecoverSession(spec.Name) is null) throw new WslcError( WslcError.SessionExists, $"a wslc session named '{spec.Name}' is already running and could not be " + "recovered; run `wsl --shutdown` to clear it"); // Terminate() returns before the VM is gone — the service tears it down asynchronously — // so the next Start() can still see the old name. Retry rather than reporting a failure // that a second attempt a moment later would not have hit. for (var attempt = 0; ; attempt++) { await Task.Delay(500, ct).ConfigureAwait(false); var retry = NewSession(spec); try { retry.Start(); Console.Error.WriteLine($"wslc: session '{spec.Name}' restarted after recovery"); return retry; } catch (Exception e) when (HResultOf(e) == ErrorAlreadyExists && attempt < 20) { retry.Dispose(); } catch (Exception e) { retry.Dispose(); throw e is WslcError ? e : Translate(e, WslcError.StartFailed); } } } 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; } /// /// 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 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 { 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 && e is not OperationCanceledException) { throw Translate(e, WslcError.PullFailed); } } /// `ghcr.io/abkslm/hydrangeaos-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(Describe).ToList()); public Task DeleteImageAsync(string reference, CancellationToken ct) { RequireSession().DeleteImage(reference); return Task.CompletedTask; } public Task InspectImageAsync(string reference, CancellationToken ct) { 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 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); 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 { 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 Translate(e, WslcError.StartFailed); } return Task.CompletedTask; } 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) { 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(MapSignal(signal), TimeSpan.FromMilliseconds(graceMs)); return Task.CompletedTask; } public Task DeleteContainerAsync(string name, bool force, CancellationToken ct) { 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; } /// /// 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) { lock (containersLock) { return Task.FromResult(containers.TryGetValue(name, out var entry) ? Describe(entry.Container.State) : "absent"); } } /// 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 { 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 hydrashell is `/bin/sh` in hydrangeaOS — 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 async Task ExecAsync(long procId, ProcSpec spec, CancellationToken ct) { 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 container = RequireContainer(spec.Container); var settings = new Sdk.ProcessSettings { CommandLine = (await WithPrivilegeDropAsync(spec, ct).ConfigureAwait(false)).ToList(), 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); // NOT started here — BrokerService starts it after the `procId` response is on the wire. // See IWslcProcess.StartAsync for why that ordering is load-bearing. return new WslcProcess(process); } /// /// 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 hydrashell never look at the numeric uid. /// /// argv is passed to `setpriv` directly rather than through a shell, so nothing here can be /// quoted wrong or injected into. /// private async Task> WithPrivilegeDropAsync( ProcSpec spec, CancellationToken ct) { if (spec.Uid is not { } uid || uid == 0) return spec.Argv; var gid = spec.Gid ?? uid; if (!await ResolvePrivilegeDropAsync(spec.Container, ct).ConfigureAwait(false)) // Refuse rather than run the agent as root. This path exists because the alternative // is a silent privilege escalation: the caller asked for uid 501 and got 0, in the // one place the sandbox's user separation is enforced. throw new WslcError( WslcError.Unsupported, $"cannot drop to uid {uid} in this image: it has no util-linux `setpriv` " + "(BusyBox ships a `setpriv` that does not support --reuid). Use an image with " + "util-linux, as the hydrangeaOS agent image does — refusing to run as root instead."); return [ "setpriv", $"--reuid={uid}", $"--regid={gid}", "--init-groups", "--", .. spec.Argv, ]; } /// /// Does this image have a `setpriv` that can actually change uid? Probed once per container /// and cached, because the answer is a property of the image and an extra exec per agent /// command would not be free. /// /// The probe is `setpriv --reuid=0 --regid=0 --init-groups -- true`: a no-op on util-linux, /// and an "unrecognized option" failure on BusyBox's namesake, which accepts only capability /// flags. Testing for the *binary* is not enough — BusyBox has one, it just cannot do this /// (observed on hardware, docs/WINDOWS_PORT.md §13.3). /// private async Task ResolvePrivilegeDropAsync(string name, CancellationToken ct) { Entry entry; lock (containersLock) { if (!containers.TryGetValue(name, out entry!)) throw new WslcError(WslcError.NotFound, $"no container named {name}"); if (entry.SetprivWorks is { } cached) return cached; } bool works; try { var (exitCode, _) = await RunCapturingAsync( entry.Container, ["setpriv", "--reuid=0", "--regid=0", "--init-groups", "--", "true"], TimeSpan.FromSeconds(15), ct).ConfigureAwait(false); works = exitCode == 0; } catch (Exception) { works = false; } lock (containersLock) entry.SetprivWorks = works; if (!works) Console.Error.WriteLine( $"wslc: container '{name}' has no usable setpriv — uid-dropping execs will be " + "refused rather than run as root"); return works; } /// 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 { 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 { public Task StartAsync(CancellationToken ct) { try { process.Start(); } catch (Exception e) when (e is not WslcError) { process.Dispose(); throw Translate(e, WslcError.StartFailed); } return Task.CompletedTask; } // 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 async Task CloseStdinAsync(CancellationToken ct) { 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(MapSignal(signal)); return Task.CompletedTask; } 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 { 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 // ERROR_ALREADY_EXISTS is CONTEXT-FREE: wslc returns it for a session name conflict // AND a container name conflict. It used to map to session_exists here, which made a // stale container report itself as a stuck session — a wrong diagnosis with a wrong // remedy (`wsl --shutdown` instead of removing one container). The session paths catch // this code by number before reaching Translate, so anything arriving here is the // other kind. 0x800700B7 => WslcError.AlreadyExists, // ERROR_ALREADY_EXISTS (container name in use) // 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