Merge nucleic/lucid-river-toad-6efj into dev
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
/// <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]
|
||||
|
||||
@@ -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<string> CapabilityList = ["enumerate", "reattach", "tty", "stats"];
|
||||
public IReadOnlyList<string> Capabilities => CapabilityList;
|
||||
|
||||
public void SetEvents(IBrokerEvents events) => Events = events;
|
||||
|
||||
private void Record(string call)
|
||||
|
||||
@@ -86,7 +86,12 @@ public sealed class BrokerService : IBrokerEvents
|
||||
{
|
||||
case "hello":
|
||||
{
|
||||
// Two kinds of capability in one list: the RPC families this broker serves, and
|
||||
// what the facade underneath can actually do (docs/WINDOWS_PORT.md D13). hostd
|
||||
// needs both — "proc" says the methods exist, "tty" says proc.exec(tty:true)
|
||||
// will work rather than failing `unsupported` at the Terminal panel.
|
||||
var caps = new List<string> { "components", "session", "image", "container", "proc" };
|
||||
caps.AddRange(wslc.Capabilities);
|
||||
if (ai.IsAvailable) caps.Add("ai");
|
||||
return new
|
||||
{
|
||||
|
||||
@@ -15,6 +15,24 @@ public interface IWslc
|
||||
/// <summary>Reported in the `hello` capabilities exchange; null when wslc is absent.</summary>
|
||||
string? WslcVersion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// What this facade can actually do, merged into the `hello` capabilities so hostd degrades
|
||||
/// instead of discovering the gap at the call site (docs/WINDOWS_PORT.md §2.3, D13). The
|
||||
/// compat SDK alone cannot serve four of them, so a compat-only broker reports none of:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>enumerate</c> — service-backed container enumeration. Without it
|
||||
/// <c>container.list</c> answers from the broker's OWN roster, so it goes empty across a
|
||||
/// broker restart and <c>ContainerManager.reconcile</c> sees an empty sandbox.</item>
|
||||
/// <item><c>reattach</c> — re-adopting a running session or container. Without it a broker
|
||||
/// restart cannot recover the session and <c>session.ensure</c> fails
|
||||
/// <see cref="WslcError.SessionExists"/>.</item>
|
||||
/// <item><c>tty</c> — pty allocation and resize, i.e. §7's Terminal panel.</item>
|
||||
/// <item><c>stats</c> — per-container resource sampling. Reported when stats are available
|
||||
/// by ANY means, including the in-guest cgroup read the compat facade falls back to.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
IReadOnlyList<string> Capabilities { get; }
|
||||
|
||||
/// <summary>Install the sink BEFORE any operation that can emit events.</summary>
|
||||
void SetEvents(IBrokerEvents events);
|
||||
|
||||
@@ -84,6 +102,16 @@ public sealed class WslcError(string kind, string message) : Exception(message)
|
||||
public const string PullFailed = "image_pull_failed";
|
||||
public const string StartFailed = "start_failed";
|
||||
public const string AiUnavailable = "ai_unavailable";
|
||||
|
||||
/// <summary>The installed wslc cannot do this at all (no pty, an unmappable signal). A
|
||||
/// permanent capability gap, not a transient failure — hostd must not retry.</summary>
|
||||
public const string Unsupported = "unsupported";
|
||||
|
||||
/// <summary>A session of that name is already running and this facade cannot re-adopt it
|
||||
/// (the compat SDK's `Start()` answers ERROR_ALREADY_EXISTS, and its constructor is lazy, so
|
||||
/// a second handle is not a second session). Distinct from <see cref="StartFailed"/> because
|
||||
/// the remedy differs: the sandbox is UP, this broker just cannot reach it.</summary>
|
||||
public const string SessionExists = "session_exists";
|
||||
}
|
||||
|
||||
// DTOs — property names (after camel-casing) match the §3.3 wire keys exactly.
|
||||
|
||||
@@ -16,10 +16,20 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(UseWslc)' == 'true'">
|
||||
<!-- Windows SDK 19041, matching the package's own lib TFM
|
||||
(lib/net8.0-windows10.0.19041.0/wslcsdkcs.dll). Targeting a higher SDK revision
|
||||
(26100) only adds a targeting-pack requirement the package does not need. -->
|
||||
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
|
||||
<!-- 26100, NOT the 19041 the package's lib folder advertises. Those disagree, and the
|
||||
binary wins: `lib/net8.0-windows10.0.19041.0/wslcsdkcs.dll` is itself compiled against
|
||||
Microsoft.Windows.SDK.NET 10.0.26100.79, so a 19041 targeting pack cannot load it —
|
||||
`error CS1705: ... uses 'Microsoft.Windows.SDK.NET, Version=10.0.26100.79' which has a
|
||||
higher version than referenced assembly ... 10.0.19041.38`. Matching the folder name
|
||||
looks right and does not build. -->
|
||||
<TargetFramework>net9.0-windows10.0.26100.0</TargetFramework>
|
||||
<!-- The TFM alone selects a targeting pack by MAJOR revision only, and the .NET 9 SDK's
|
||||
default for 26100 is 10.0.26100.38 — still below the .79 wslcsdkcs was built against,
|
||||
so CS1705 survives the TFM bump. This pins the projection itself. .80 rather than the
|
||||
.79 the error names because .79 was never published to nuget.org; the constraint is a
|
||||
floor, so the next one up satisfies it. Raise it, never lower it, if a package bump
|
||||
moves the floor again. -->
|
||||
<WindowsSdkPackageVersion>10.0.26100.80</WindowsSdkPackageVersion>
|
||||
<DefineConstants>$(DefineConstants);USE_WSLC</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ public sealed class UnavailableWslc : IWslc
|
||||
{
|
||||
public string? WslcVersion => null;
|
||||
|
||||
/// Nothing bound, so nothing is capable — the empty list IS the capability report.
|
||||
public IReadOnlyList<string> Capabilities => [];
|
||||
|
||||
public void SetEvents(IBrokerEvents events) { }
|
||||
|
||||
private static WslcError Unavailable() =>
|
||||
|
||||
+563
-139
@@ -1,94 +1,156 @@
|
||||
#if USE_WSLC
|
||||
using Microsoft.WSL.Containers;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
using Sdk = Microsoft.WSL.Containers;
|
||||
using Windows.Storage.Streams;
|
||||
|
||||
namespace NucleicBroker.Wslc;
|
||||
|
||||
// The REAL Microsoft.WSL.Containers adapter (docs/WINDOWS_PORT.md §1.5, §3.2), compiled
|
||||
// only with -p:UseWslc=true on Windows.
|
||||
//
|
||||
// !! KNOWN WRONG AS WRITTEN — DO NOT BUILD ON IT. !!
|
||||
//
|
||||
// M1 spike (a) has run (docs/WINDOWS_PORT.md §13.1) and the real API differs from this
|
||||
// transcription in roughly twenty places. Most are renames that belong exactly here and
|
||||
// nowhere else, which is what the IWslc seam is for: GetVersion not GetServiceVersion,
|
||||
// `new Session(settings)` + Start() not CreateOrOpen, MemorySizeInMB, HostName, ImageName,
|
||||
// CreateProcess-then-Start rather than RunProcess, DeleteContainerOption, a named Signal
|
||||
// enum, RegistryAuth as a string, ImageInfo.Name/.Sha256, and two separate output events
|
||||
// instead of one with a stderr flag.
|
||||
//
|
||||
// Four things this SDK cannot do at all — enumerate containers, report per-container stats,
|
||||
// allocate/resize a pty, and attach to an existing container or session. That is not the
|
||||
// projection hiding them: `wslcsdk.dll` wraps `WSLCCompat.idl`, the deliberately-stable
|
||||
// SDK-facing COM surface, and that surface genuinely lacks them.
|
||||
//
|
||||
// They DO exist on `wslc.idl`, the service-internal COM interface `wslc.exe` itself calls
|
||||
// (IWSLCSessionManager, IID 82A7ABC8-6B50-43FC-AB96-15FBBE7E8760) — ListContainers, Stats,
|
||||
// ResizeTty, OpenContainer/Attach, OpenSessionByName, and IWSLCVirtualMachine::GetId, which
|
||||
// is the VM GUID an AF_HYPERV bind needs. Both IDLs are in the open-source WSL repo. The
|
||||
// internal one carries an explicit "ABI breaking changes are OK" warning.
|
||||
//
|
||||
// D13 (§13.1): this class binds BOTH surfaces — compat SDK for everything it covers, internal
|
||||
// COM for those five. Shelling out to `wslc.exe` was considered and rejected (a spawn per call,
|
||||
// scraped text, no events, a second mechanism to maintain). Because the internal ABI is
|
||||
// explicitly unstable, bind it defensively: probe at startup, report what bound in the
|
||||
// `capabilities` hello, and degrade — losing reattach, stats and the Terminal panel — rather
|
||||
// than failing the sandbox. All of that lives inside this class: `IWslc` does not change, so
|
||||
// nothing on the Swift side knows which surface answered.
|
||||
//
|
||||
// Also note: `ProcessSettings` has no uid/gid, so exec wraps argv in setpriv/su — which
|
||||
// §3.2 already anticipated as the fallback, so it costs nothing.
|
||||
//
|
||||
// Read §13.1 before touching this file. Fixing it is the next step of item 5, and it is
|
||||
// now a fast loop: the package restores, so `dotnet build -p:UseWslc=true` compiles it.
|
||||
//
|
||||
// WslcService (components) → Session (VM host, images) → Container → Process.
|
||||
/// <summary>
|
||||
/// The real <c>Microsoft.WSL.Containers</c> adapter (docs/WINDOWS_PORT.md §1.5, §3.2), compiled
|
||||
/// only with <c>-p:UseWslc=true</c>.
|
||||
///
|
||||
/// Written against the **measured** 2.9.3 surface, not the documentation: Microsoft Learn's own
|
||||
/// C# sample uses <c>MemoryMB</c>, <c>CmdLine</c> and <c>DeleteContainerFlags</c>, none of which
|
||||
/// exist in the shipped assembly. Re-derive with <c>windows/spikes/WslcApiDump</c> after any
|
||||
/// version bump — that is now its whole job.
|
||||
///
|
||||
/// Everything the SDK is aliased as <c>Sdk</c> deliberately: <c>ImageInfo</c>,
|
||||
/// <c>ContainerInfo</c>, <c>ProcessSettings</c> and <c>Signal</c> all exist BOTH here and in the
|
||||
/// parent <c>NucleicBroker</c> namespace, and an unqualified <c>using</c> makes each one
|
||||
/// ambiguous at the point of use.
|
||||
///
|
||||
/// **What this facade cannot do, and why that is reported rather than hidden.** The SDK projects
|
||||
/// <c>WSLCCompat.idl</c>, the deliberately-stable SDK-facing COM surface, and that surface
|
||||
/// genuinely lacks container enumeration, per-container stats, pty/resize, and any way to
|
||||
/// re-adopt a running session or container. Two consequences run through this whole file:
|
||||
///
|
||||
/// 1. <c>Sdk.Container</c> has **no Name property** and <c>Sdk.Session</c> has no
|
||||
/// <c>GetContainers()</c>, so a container is reachable only through the handle
|
||||
/// <c>CreateContainer</c> returned. This class therefore keeps its own name→handle roster,
|
||||
/// and that roster — not the service — is what <c>container.list</c> answers from.
|
||||
/// 2. The roster dies with the process. A restarted broker cannot see, address or re-adopt
|
||||
/// anything it created before, and <c>session.ensure</c> fails
|
||||
/// <see cref="WslcError.SessionExists"/> because the compat <c>Start()</c> refuses a name that
|
||||
/// is already running (confirmed on hardware, §13.1). That is the D13 reattach gap.
|
||||
///
|
||||
/// Both are declared through <see cref="Capabilities"/> so hostd degrades from the `hello`
|
||||
/// exchange instead of discovering them at a call site. Closing them needs the service-internal
|
||||
/// COM interface; see docs/WINDOWS_PORT.md §13.1 for what that costs and what still gates it.
|
||||
///
|
||||
/// WslcService (components) → Session (VM host, images) → Container → Process.
|
||||
/// </summary>
|
||||
public sealed class WslcFacade : IWslc
|
||||
{
|
||||
private IBrokerEvents? events;
|
||||
private Session? session;
|
||||
private Sdk.Session? session;
|
||||
private string? gateway;
|
||||
private readonly SemaphoreSlim sessionGate = new(1, 1);
|
||||
|
||||
public string? WslcVersion => WslcService.GetServiceVersion()?.ToString();
|
||||
/// name → the handle CreateContainer returned. See the class remarks: without this there is
|
||||
/// no way to address a container at all, because Sdk.Container carries no name.
|
||||
private readonly Dictionary<string, Entry> containers = [];
|
||||
private readonly Lock containersLock = new();
|
||||
|
||||
private sealed record Entry(Sdk.Container Container, string Image);
|
||||
|
||||
public string? WslcVersion
|
||||
{
|
||||
get
|
||||
{
|
||||
try
|
||||
{
|
||||
var version = Sdk.WslcService.GetVersion();
|
||||
// The projection doesn't override ToString(), so the default renders the type
|
||||
// name — format the triple by hand or `hello` reports a class name as a version.
|
||||
return $"{version.Major}.{version.Minor}.{version.Revision}";
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A missing/too-old WSL throws here. That is an onboarding condition (§8), not a
|
||||
// broker failure: null is exactly the "wslc absent" signal `hello` is meant to
|
||||
// carry, and components.missing explains it properly.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compat-only, so: no enumeration, no reattach, no pty. Stats ARE offered — not from the SDK
|
||||
/// (there is no GetStatistics()) but from an in-guest cgroup read, which is the escape hatch
|
||||
/// §13.1 names and is indistinguishable to hostd.
|
||||
public IReadOnlyList<string> Capabilities => ["stats"];
|
||||
|
||||
public void SetEvents(IBrokerEvents events) => this.events = events;
|
||||
|
||||
// MARK: - Components (§8 onboarding)
|
||||
|
||||
public Task<IReadOnlyList<string>> MissingComponentsAsync(CancellationToken ct)
|
||||
{
|
||||
var flags = WslcService.GetMissingComponents();
|
||||
var missing = new List<string>();
|
||||
foreach (var flag in Enum.GetValues<ComponentFlags>())
|
||||
if (flag != ComponentFlags.None && flags.HasFlag(flag))
|
||||
missing.Add(flag.ToString().ToLowerInvariant());
|
||||
// A LIST of Component, not a flags enum — and it answers from OS feature state, so it
|
||||
// works even when the service class isn't registered. That makes it the one call that
|
||||
// explains every other failure.
|
||||
var missing = Sdk.WslcService.GetMissingComponents().Select(c => c.ToString()).ToList();
|
||||
return Task.FromResult<IReadOnlyList<string>>(missing);
|
||||
}
|
||||
|
||||
public async Task InstallComponentsAsync(CancellationToken ct)
|
||||
{
|
||||
// M1: wire the component-install progress callback into events.InstallProgress.
|
||||
await WslcService.InstallComponentsAsync(WslcService.GetMissingComponents())
|
||||
.AsTask(ct).ConfigureAwait(false);
|
||||
var operation = Sdk.WslcService.InstallWithDependenciesAsync();
|
||||
operation.Progress = (_, progress) => events?.InstallProgress(
|
||||
progress.Component.ToString(),
|
||||
progress.Total == 0 ? 0 : 100.0 * progress.Progress / progress.Total);
|
||||
await operation.AsTask(ct).ConfigureAwait(false);
|
||||
events?.InstallProgress("done", 100);
|
||||
}
|
||||
|
||||
// MARK: - Session
|
||||
|
||||
public async Task<string> EnsureSessionAsync(SessionSpec spec, CancellationToken ct)
|
||||
{
|
||||
await sessionGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (session is null)
|
||||
if (session is not null) return gateway!;
|
||||
|
||||
var settings = new Sdk.SessionSettings(spec.Name, spec.DataDir);
|
||||
if (spec.Cpu is { } cpu) settings.CpuCount = (uint)cpu;
|
||||
if (spec.MemoryMB is { } memory) settings.MemorySizeInMB = (uint)memory;
|
||||
|
||||
var created = new Sdk.Session(settings);
|
||||
// Subscribe BEFORE Start(): a session that dies during boot must still report down.
|
||||
created.Terminated += reason => events?.SessionDown(reason.ToString());
|
||||
// Not surfaced as an RPC, but it is the only crash detail wslc offers and it is what
|
||||
// makes a SIGKILLed agent explicable in the host log (the Swift `diagnoseKill` seam).
|
||||
created.ProcessCrashed += crash => Console.Error.WriteLine(
|
||||
$"wslc: process {crash.ProcessName} (pid {crash.Pid}) crashed with signal "
|
||||
+ $"{crash.Signal}; dump at {crash.DumpPath}");
|
||||
|
||||
try
|
||||
{
|
||||
var settings = new SessionSettings(spec.Name, spec.DataDir);
|
||||
if (spec.Cpu is { } cpu) settings.CpuCount = cpu;
|
||||
if (spec.MemoryMB is { } mem) settings.MemoryMB = mem;
|
||||
session = Session.CreateOrOpen(settings);
|
||||
session.SessionTerminationHandler = reason =>
|
||||
events?.SessionDown(reason?.ToString() ?? "unknown");
|
||||
created.Start();
|
||||
}
|
||||
// M1: confirm how the WSL vEthernet gateway address is surfaced (session
|
||||
// property vs. querying the vNIC); NAT mode is preferred for determinism
|
||||
// (docs/WINDOWS_PORT.md §5). Mirrored mode reports the reachable host address.
|
||||
return session.HostGatewayAddress?.ToString()
|
||||
?? throw new WslcError(WslcError.StartFailed, "wslc session has no gateway address");
|
||||
catch (Exception e) when (HResultOf(e) == ErrorAlreadyExists)
|
||||
{
|
||||
created.Dispose();
|
||||
// The constructor is lazy — it only captures settings — so reaching this means a
|
||||
// session of this name is genuinely RUNNING, started by a previous broker or
|
||||
// another process. The compat surface cannot re-adopt it, and there is no handle
|
||||
// to terminate it through either, so this is terminal for this broker.
|
||||
throw new WslcError(
|
||||
WslcError.SessionExists,
|
||||
$"a wslc session named '{spec.Name}' is already running and the compat SDK "
|
||||
+ "cannot re-adopt it; run `wsl --shutdown` to clear it");
|
||||
}
|
||||
catch (Exception e) when (e is not WslcError)
|
||||
{
|
||||
created.Dispose();
|
||||
throw Translate(e, WslcError.StartFailed);
|
||||
}
|
||||
|
||||
session = created;
|
||||
gateway = await ResolveGatewayAsync(ct).ConfigureAwait(false);
|
||||
return gateway;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -98,36 +160,111 @@ public sealed class WslcFacade : IWslc
|
||||
|
||||
public Task TerminateSessionAsync(CancellationToken ct)
|
||||
{
|
||||
lock (containersLock)
|
||||
{
|
||||
foreach (var entry in containers.Values) entry.Container.Dispose();
|
||||
containers.Clear();
|
||||
}
|
||||
session?.Terminate();
|
||||
session?.Dispose();
|
||||
session = null;
|
||||
gateway = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Session RequireSession() =>
|
||||
session ?? throw new WslcError(WslcError.NotRunning, "no wslc session (call session.ensure first)");
|
||||
/// <summary>
|
||||
/// The address a guest reaches the host on (§5). **No wslc API surfaces it** — there is no
|
||||
/// gateway property anywhere on Session or Container, and ContainerPortMapping is inbound
|
||||
/// host→guest, the wrong direction. So it comes from Windows networking instead: the IPv4
|
||||
/// address the host holds on the WSL vSwitch.
|
||||
///
|
||||
/// Polled, because the vNIC appears as the VM boots and Start() returning does not mean it
|
||||
/// is up yet.
|
||||
/// </summary>
|
||||
private static async Task<string> ResolveGatewayAsync(CancellationToken ct)
|
||||
{
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
if (FindWslAdapterAddress() is { } address) return address;
|
||||
if (attempt >= 20)
|
||||
throw new WslcError(
|
||||
WslcError.StartFailed,
|
||||
"no 'vEthernet (WSL)' adapter address after session start — the guest has no "
|
||||
+ "route to the host control plane");
|
||||
await Task.Delay(250, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindWslAdapterAddress() =>
|
||||
NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(nic => nic.OperationalStatus == OperationalStatus.Up)
|
||||
.Where(nic => nic.Name.Contains("WSL", StringComparison.OrdinalIgnoreCase)
|
||||
|| nic.Description.Contains("WSL", StringComparison.OrdinalIgnoreCase))
|
||||
.SelectMany(nic => nic.GetIPProperties().UnicastAddresses)
|
||||
.Select(unicast => unicast.Address)
|
||||
.Where(address => address.AddressFamily == AddressFamily.InterNetwork)
|
||||
.Select(address => address.ToString())
|
||||
.FirstOrDefault();
|
||||
|
||||
private Sdk.Session RequireSession() =>
|
||||
session ?? throw new WslcError(
|
||||
WslcError.NotRunning, "no wslc session (call session.ensure first)");
|
||||
|
||||
// MARK: - Images
|
||||
|
||||
public async Task PullImageAsync(string reference, RegistryAuth? auth, CancellationToken ct)
|
||||
{
|
||||
var options = new PullImageOptions(reference);
|
||||
if (auth is { Username: { } user, Password: { } pass })
|
||||
options.Credentials = new RegistryCredentials(user, pass);
|
||||
options.Progress += (status, current, total) =>
|
||||
events?.PullProgress(reference, status, current, total);
|
||||
var current = RequireSession();
|
||||
var options = new Sdk.PullImageOptions(reference);
|
||||
|
||||
// RegistryAuth is a plain STRING, not a credentials object — and the string is minted by
|
||||
// Session.Authenticate against the registry the ref names. GHCR reads a PAT with
|
||||
// read:packages as the password (ContainerEngine.registryAuth(for:) does the same).
|
||||
if (auth is { Username: { } username, Password: { } password })
|
||||
{
|
||||
try
|
||||
{
|
||||
options.RegistryAuth = current.Authenticate(
|
||||
RegistryUri(reference), username, password);
|
||||
}
|
||||
catch (Exception e) when (e is not WslcError)
|
||||
{
|
||||
throw Translate(e, WslcError.PullFailed);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await RequireSession().PullImageAsync(options).AsTask(ct).ConfigureAwait(false);
|
||||
var operation = current.PullImageAsync(options);
|
||||
// Progress rides the ASYNC overload only; the sync PullImage reports nothing. This is
|
||||
// what feeds the existing controlDownloadProgress UI surface.
|
||||
operation.Progress = (_, progress) => events?.PullProgress(
|
||||
reference,
|
||||
progress.Status.ToString(),
|
||||
(long)progress.CurrentBytes,
|
||||
(long)progress.TotalBytes);
|
||||
await operation.AsTask(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception e) when (e is not WslcError)
|
||||
catch (Exception e) when (e is not WslcError && e is not OperationCanceledException)
|
||||
{
|
||||
throw new WslcError(WslcError.PullFailed, e.Message);
|
||||
throw Translate(e, WslcError.PullFailed);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>`ghcr.io/abkslm/naros-agent:26.07` → `https://ghcr.io`. A ref whose first segment
|
||||
/// carries no dot or port has no registry host at all (`ubuntu:24.04`), which means Docker
|
||||
/// Hub.</summary>
|
||||
private static Uri RegistryUri(string reference)
|
||||
{
|
||||
var firstSegment = reference.Split('/')[0];
|
||||
var isHost = firstSegment.Contains('.') || firstSegment.Contains(':')
|
||||
|| firstSegment == "localhost";
|
||||
return new Uri($"https://{(isHost ? firstSegment : "index.docker.io")}");
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ImageInfo>> ListImagesAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<ImageInfo>>(
|
||||
RequireSession().GetImages()
|
||||
.Select(i => new ImageInfo(i.Reference, i.Digest, i.Size))
|
||||
.ToList());
|
||||
RequireSession().GetImages().Select(Describe).ToList());
|
||||
|
||||
public Task DeleteImageAsync(string reference, CancellationToken ct)
|
||||
{
|
||||
@@ -137,129 +274,416 @@ public sealed class WslcFacade : IWslc
|
||||
|
||||
public Task<ImageInfo?> InspectImageAsync(string reference, CancellationToken ct)
|
||||
{
|
||||
var image = RequireSession().GetImages().FirstOrDefault(i => i.Reference == reference);
|
||||
return Task.FromResult(image is null ? null : new ImageInfo(image.Reference, image.Digest, image.Size));
|
||||
var image = RequireSession().GetImages().FirstOrDefault(i => i.Name == reference);
|
||||
return Task.FromResult(image is null ? null : Describe(image));
|
||||
}
|
||||
|
||||
/// `.Name`/`.Sha256`/`.Size` — not the `.Reference`/`.Digest` the docs show. Sha256 is an
|
||||
/// IBuffer of raw bytes, so it becomes the `sha256:<hex>` digest the rest of Nucleic speaks.
|
||||
private static ImageInfo Describe(Sdk.ImageInfo image) =>
|
||||
new(image.Name, HexDigest(image.Sha256), (long)image.Size);
|
||||
|
||||
private static string? HexDigest(IBuffer? buffer)
|
||||
{
|
||||
if (buffer is null || buffer.Length == 0) return null;
|
||||
var bytes = new byte[buffer.Length];
|
||||
DataReader.FromBuffer(buffer).ReadBytes(bytes);
|
||||
return $"sha256:{Convert.ToHexString(bytes).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
// MARK: - Containers
|
||||
|
||||
public Task CreateContainerAsync(ContainerCreateSpec spec, CancellationToken ct)
|
||||
{
|
||||
var settings = new ContainerSettings(spec.Image) { Name = spec.Name };
|
||||
if (spec.Hostname is { } hostname) settings.Hostname = hostname;
|
||||
var settings = new Sdk.ContainerSettings(spec.Image) { Name = spec.Name };
|
||||
if (spec.Hostname is { } hostname) settings.HostName = hostname; // capital N
|
||||
if (spec.NetworkingMode is { } mode)
|
||||
settings.NetworkingMode = Enum.Parse<ContainerNetworkingMode>(mode, ignoreCase: true);
|
||||
foreach (var volume in spec.Volumes ?? [])
|
||||
settings.Volumes.Add(new ContainerVolume(volume.Host, volume.Guest, volume.ReadOnly));
|
||||
if (spec.InitArgv is { Count: > 0 } argv)
|
||||
settings.InitProcess = new ProcessSettings { CmdLine = argv.ToList() };
|
||||
if (spec.Env is { } env)
|
||||
foreach (var (key, value) in env)
|
||||
settings.InitProcess?.Environment.Add(key, value);
|
||||
settings.NetworkingMode = Enum.Parse<Sdk.ContainerNetworkingMode>(mode, ignoreCase: true);
|
||||
if (spec.Volumes is { Count: > 0 } volumes)
|
||||
settings.Volumes = volumes
|
||||
.Select(v => new Sdk.ContainerVolume(v.Host, v.Guest, v.ReadOnly))
|
||||
.ToList();
|
||||
|
||||
// env and initArgv both live on InitProcess, so build it when EITHER is present —
|
||||
// attaching env to a null InitProcess silently dropped the whole environment before.
|
||||
if (spec.InitArgv is { Count: > 0 } || spec.Env is { Count: > 0 })
|
||||
{
|
||||
var init = new Sdk.ProcessSettings();
|
||||
if (spec.InitArgv is { Count: > 0 } argv) init.CommandLine = argv.ToList();
|
||||
if (spec.Env is { Count: > 0 } env) init.EnvironmentVariables = new Dictionary<string, string>(env);
|
||||
settings.InitProcess = init;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
RequireSession().CreateContainer(settings);
|
||||
var container = RequireSession().CreateContainer(settings);
|
||||
lock (containersLock)
|
||||
{
|
||||
if (containers.Remove(spec.Name, out var stale)) stale.Container.Dispose();
|
||||
containers[spec.Name] = new Entry(container, spec.Image);
|
||||
}
|
||||
}
|
||||
catch (Exception e) when (e is not WslcError)
|
||||
{
|
||||
throw new WslcError(WslcError.StartFailed, e.Message);
|
||||
throw Translate(e, WslcError.StartFailed);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Container RequireContainer(string name) =>
|
||||
RequireSession().GetContainers().FirstOrDefault(c => c.Name == name)
|
||||
?? throw new WslcError(WslcError.NotFound, $"no container named {name}");
|
||||
private Sdk.Container RequireContainer(string name)
|
||||
{
|
||||
lock (containersLock)
|
||||
{
|
||||
return containers.TryGetValue(name, out var entry)
|
||||
? entry.Container
|
||||
// Not necessarily absent from the SERVICE — absent from this broker's roster,
|
||||
// which after a restart is everything it ever created. See the class remarks.
|
||||
: throw new WslcError(WslcError.NotFound, $"no container named {name}");
|
||||
}
|
||||
}
|
||||
|
||||
public Task StartContainerAsync(string name, CancellationToken ct)
|
||||
{
|
||||
RequireContainer(name).Start();
|
||||
try
|
||||
{
|
||||
RequireContainer(name).Start();
|
||||
}
|
||||
catch (Exception e) when (e is not WslcError)
|
||||
{
|
||||
throw Translate(e, WslcError.StartFailed);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopContainerAsync(string name, int signal, int graceMs, CancellationToken ct)
|
||||
{
|
||||
RequireContainer(name).Stop((Signal)signal, TimeSpan.FromMilliseconds(graceMs));
|
||||
RequireContainer(name).Stop(MapSignal(signal), TimeSpan.FromMilliseconds(graceMs));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteContainerAsync(string name, bool force, CancellationToken ct)
|
||||
{
|
||||
RequireContainer(name).Delete(force ? DeleteContainerFlags.Force : DeleteContainerFlags.None);
|
||||
var container = RequireContainer(name);
|
||||
container.Delete(force ? Sdk.DeleteContainerOption.Force : Sdk.DeleteContainerOption.None);
|
||||
lock (containersLock)
|
||||
{
|
||||
if (containers.Remove(name, out var entry)) entry.Container.Dispose();
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ContainerInfo>> ListContainersAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<ContainerInfo>>(
|
||||
RequireSession().GetContainers()
|
||||
.Select(c => new ContainerInfo(c.Name, c.Image, c.State.ToString().ToLowerInvariant()))
|
||||
.ToList());
|
||||
/// <summary>
|
||||
/// The broker's OWN roster, not the service's. The compat SDK has no enumeration call at all,
|
||||
/// so this cannot see a container this process did not create — which is why `enumerate` is
|
||||
/// absent from <see cref="Capabilities"/> and why §2.3's post-restart reconcile is a known
|
||||
/// gap rather than a silently empty answer.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<ContainerInfo>> ListContainersAsync(CancellationToken ct)
|
||||
{
|
||||
lock (containersLock)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<ContainerInfo>>(
|
||||
containers
|
||||
.Select(kv => new ContainerInfo(kv.Key, kv.Value.Image, Describe(kv.Value.Container.State)))
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
public Task<string> ContainerStateAsync(string name, CancellationToken ct)
|
||||
{
|
||||
var container = RequireSession().GetContainers().FirstOrDefault(c => c.Name == name);
|
||||
return Task.FromResult(container?.State switch
|
||||
lock (containersLock)
|
||||
{
|
||||
null => "absent",
|
||||
ContainerState.Running => "running",
|
||||
_ => "stopped",
|
||||
});
|
||||
return Task.FromResult(containers.TryGetValue(name, out var entry)
|
||||
? Describe(entry.Container.State)
|
||||
: "absent");
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ContainerStatsInfo?> ContainerStatsAsync(string name, CancellationToken ct)
|
||||
/// ContainerState is Invalid|Created|Running|Exited|Deleted; the Swift policy layer only
|
||||
/// distinguishes running/stopped/absent, and a Deleted handle is absent as far as it cares.
|
||||
private static string Describe(Sdk.ContainerState state) => state switch
|
||||
{
|
||||
// M1: confirm the stats surface (cgroup v2 counters are what the Swift resource
|
||||
// monitor folds into ContainerResourceSample).
|
||||
var stats = RequireContainer(name).GetStatistics();
|
||||
return Task.FromResult<ContainerStatsInfo?>(stats is null
|
||||
? null
|
||||
: new ContainerStatsInfo(stats.CpuUsageUsec, stats.MemoryUsedBytes, stats.MemoryLimitBytes, stats.OomKills));
|
||||
Sdk.ContainerState.Running => "running",
|
||||
Sdk.ContainerState.Deleted => "absent",
|
||||
_ => "stopped",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// There is no <c>GetStatistics()</c> on the compat surface — <c>Container</c> offers only
|
||||
/// <c>Id</c>, <c>State</c>, <c>InitProcess</c> and <c>Inspect()</c>. So the counters come from
|
||||
/// the guest's own cgroup v2 files, read the way a Linux-native engine would (§13.1 finding 2).
|
||||
///
|
||||
/// Deliberately `cat` and `echo` only: no awk, no sed. This runs in whatever image the user
|
||||
/// configured, and nash is `/bin/sh` in narOS — the smaller the tool surface, the fewer images
|
||||
/// this silently fails in.
|
||||
/// </summary>
|
||||
public async Task<ContainerStatsInfo?> ContainerStatsAsync(string name, CancellationToken ct)
|
||||
{
|
||||
var container = RequireContainer(name);
|
||||
if (container.State != Sdk.ContainerState.Running) return null;
|
||||
|
||||
const string script =
|
||||
"cat /sys/fs/cgroup/cpu.stat 2>/dev/null; echo ---; "
|
||||
+ "cat /sys/fs/cgroup/memory.current 2>/dev/null; echo ---; "
|
||||
+ "cat /sys/fs/cgroup/memory.max 2>/dev/null; echo ---; "
|
||||
+ "cat /sys/fs/cgroup/memory.events 2>/dev/null";
|
||||
|
||||
var (exitCode, output) = await RunCapturingAsync(
|
||||
container, ["/bin/sh", "-c", script], TimeSpan.FromSeconds(10), ct).ConfigureAwait(false);
|
||||
if (exitCode != 0) return null;
|
||||
|
||||
var sections = output.Split("---", StringSplitOptions.TrimEntries);
|
||||
if (sections.Length < 4) return null;
|
||||
|
||||
return new ContainerStatsInfo(
|
||||
CpuUsageUsec: FieldValue(sections[0], "usage_usec") ?? 0,
|
||||
MemoryUsedBytes: Number(sections[1]) ?? 0,
|
||||
// `memory.max` reads the literal "max" when the cgroup is unlimited — not a number.
|
||||
// -1 rather than 0 so "unlimited" stays distinguishable on the wire; the Swift
|
||||
// consumer clamps with max(0,…) today, which is its existing "unknown".
|
||||
MemoryLimitBytes: Number(sections[2]) ?? -1,
|
||||
OomKills: FieldValue(sections[3], "oom_kill"));
|
||||
}
|
||||
|
||||
/// A cgroup "flat keyed" file: `key value` per line.
|
||||
private static long? FieldValue(string section, string key)
|
||||
{
|
||||
foreach (var line in section.Split('\n', StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length == 2 && parts[0] == key && long.TryParse(parts[1], out var value))
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static long? Number(string section) =>
|
||||
long.TryParse(section.Trim(), out var value) ? value : null;
|
||||
|
||||
// MARK: - Processes
|
||||
|
||||
public Task<IWslcProcess> ExecAsync(long procId, ProcSpec spec, CancellationToken ct)
|
||||
{
|
||||
var container = RequireContainer(spec.Container);
|
||||
var settings = new ProcessSettings
|
||||
{
|
||||
CmdLine = spec.Argv.ToList(),
|
||||
WorkingDirectory = spec.Cwd,
|
||||
OutputMode = ProcessOutputMode.Event,
|
||||
Terminal = spec.Tty,
|
||||
};
|
||||
if (spec.Env is { } env)
|
||||
foreach (var (key, value) in env) settings.Environment.Add(key, value);
|
||||
if (spec.Uid is { } uid) settings.UserId = uid; // M1: verify uid semantics (§3.2);
|
||||
if (spec.Gid is { } gid) settings.GroupId = gid; // fall back to a setpriv wrapper argv.
|
||||
if (spec.Tty)
|
||||
// Not a failure to retry: ProcessSettings has no Terminal and Process has no resize.
|
||||
// §7's Terminal panel needs the internal COM interface (IWSLCProcess::ResizeTty).
|
||||
throw new WslcError(
|
||||
WslcError.Unsupported,
|
||||
"the wslc compat SDK cannot allocate a pty; proc.exec(tty:true) is unavailable");
|
||||
|
||||
var process = container.RunProcess(settings);
|
||||
process.OutputReceived += (stderr, data) => events?.ProcOutput(procId, stderr, data);
|
||||
var container = RequireContainer(spec.Container);
|
||||
var settings = new Sdk.ProcessSettings
|
||||
{
|
||||
CommandLine = WithPrivilegeDrop(spec).ToList(), // CommandLine, not CmdLine
|
||||
OutputMode = Sdk.ProcessOutputMode.Event,
|
||||
};
|
||||
if (spec.Cwd is { } cwd) settings.WorkingDirectory = cwd;
|
||||
if (spec.Env is { Count: > 0 } env)
|
||||
settings.EnvironmentVariables = new Dictionary<string, string>(env); // not Environment
|
||||
|
||||
Sdk.Process process;
|
||||
try
|
||||
{
|
||||
// CreateProcess then Start() — two steps, and the split is the point: handlers are
|
||||
// attached in between, so the first output chunk cannot be missed.
|
||||
process = container.CreateProcess(settings);
|
||||
}
|
||||
catch (Exception e) when (e is not WslcError)
|
||||
{
|
||||
throw Translate(e, WslcError.StartFailed);
|
||||
}
|
||||
|
||||
// OutputReceived and ErrorReceived are SEPARATE events, each carrying only bytes — there
|
||||
// is no stderr flag to read, so the stream is decided by which handler fired.
|
||||
process.OutputReceived += data => events?.ProcOutput(procId, stderr: false, data);
|
||||
process.ErrorReceived += data => events?.ProcOutput(procId, stderr: true, data);
|
||||
process.Exited += code => events?.ProcExited(procId, code);
|
||||
|
||||
try
|
||||
{
|
||||
process.Start();
|
||||
}
|
||||
catch (Exception e) when (e is not WslcError)
|
||||
{
|
||||
process.Dispose();
|
||||
throw Translate(e, WslcError.StartFailed);
|
||||
}
|
||||
return Task.FromResult<IWslcProcess>(new WslcProcess(process));
|
||||
}
|
||||
|
||||
private sealed class WslcProcess(Process process) : IWslcProcess
|
||||
/// <summary>
|
||||
/// <c>ProcessSettings</c> has no <c>UserId</c>/<c>GroupId</c>, so dropping to the agent uid is
|
||||
/// done in-guest by wrapping argv — the fallback §3.2 always named, which costs nothing
|
||||
/// because the interceptors and nash never look at the numeric uid.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<string> WithPrivilegeDrop(ProcSpec spec)
|
||||
{
|
||||
public Task WriteStdinAsync(ReadOnlyMemory<byte> data, CancellationToken ct)
|
||||
if (spec.Uid is not { } uid || uid == 0) return spec.Argv;
|
||||
var gid = spec.Gid ?? uid;
|
||||
return
|
||||
[
|
||||
"setpriv", $"--reuid={uid}", $"--regid={gid}", "--init-groups", "--",
|
||||
.. spec.Argv,
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>Run to completion and capture stdout+stderr. The in-guest half of what the macOS
|
||||
/// engine's `runCapturing` does, and the only way stats and shim re-seeding work without a
|
||||
/// second mechanism.</summary>
|
||||
private static async Task<(int ExitCode, string Output)> RunCapturingAsync(
|
||||
Sdk.Container container, IReadOnlyList<string> argv, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
var settings = new Sdk.ProcessSettings
|
||||
{
|
||||
process.WriteStdin(data.Span);
|
||||
return Task.CompletedTask;
|
||||
CommandLine = argv.ToList(),
|
||||
OutputMode = Sdk.ProcessOutputMode.Event,
|
||||
};
|
||||
var buffer = new MemoryStream();
|
||||
var exited = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
var process = container.CreateProcess(settings);
|
||||
try
|
||||
{
|
||||
process.OutputReceived += data => { lock (buffer) buffer.Write(data, 0, data.Length); };
|
||||
process.ErrorReceived += data => { lock (buffer) buffer.Write(data, 0, data.Length); };
|
||||
process.Exited += code => exited.TrySetResult(code);
|
||||
process.Start();
|
||||
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
exitCode = await exited.Task.WaitAsync(timeout, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
process.Signal(Sdk.Signal.SIGKILL);
|
||||
throw new WslcError(
|
||||
WslcError.NotRunning, $"`{string.Join(' ', argv)}` timed out in the container");
|
||||
}
|
||||
|
||||
// Exited and OutputReceived are independent event sources, so a final chunk can still
|
||||
// be in flight when the exit fires. Waiting a beat costs nothing here (this path is
|
||||
// never on the agent's hot stdio) and avoids truncating the last line.
|
||||
await Task.Delay(50, ct).ConfigureAwait(false);
|
||||
lock (buffer) return (exitCode, System.Text.Encoding.UTF8.GetString(buffer.ToArray()));
|
||||
}
|
||||
finally
|
||||
{
|
||||
process.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class WslcProcess(Sdk.Process process) : IWslcProcess
|
||||
{
|
||||
// stdin is a WinRT stream, not a WriteStdin call, and DataWriter is how you put bytes
|
||||
// into an IOutputStream. Held for the process lifetime and guarded, because hostd may
|
||||
// pipeline proc.stdin writes and StoreAsync is not reentrant.
|
||||
private readonly SemaphoreSlim stdinGate = new(1, 1);
|
||||
private IOutputStream? stdin;
|
||||
private DataWriter? writer;
|
||||
|
||||
public async Task WriteStdinAsync(ReadOnlyMemory<byte> data, CancellationToken ct)
|
||||
{
|
||||
await stdinGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (writer is null)
|
||||
{
|
||||
stdin = process.GetInputStream();
|
||||
writer = new DataWriter(stdin);
|
||||
}
|
||||
writer.WriteBytes(data.ToArray());
|
||||
await writer.StoreAsync().AsTask(ct).ConfigureAwait(false);
|
||||
await writer.FlushAsync().AsTask(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
stdinGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public Task CloseStdinAsync(CancellationToken ct)
|
||||
public async Task CloseStdinAsync(CancellationToken ct)
|
||||
{
|
||||
process.CloseStdin();
|
||||
return Task.CompletedTask;
|
||||
await stdinGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Detach before disposing the writer, or the writer takes the stream down with it
|
||||
// and the close races the last StoreAsync. Closing the STREAM is what the guest
|
||||
// observes as EOF on stdin.
|
||||
writer?.DetachStream();
|
||||
writer?.Dispose();
|
||||
writer = null;
|
||||
stdin?.Dispose();
|
||||
stdin = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
stdinGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public Task SignalAsync(int signal, CancellationToken ct)
|
||||
{
|
||||
process.Signal((Signal)signal);
|
||||
process.Signal(MapSignal(signal));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ResizeAsync(int cols, int rows, CancellationToken ct)
|
||||
public Task ResizeAsync(int cols, int rows, CancellationToken ct) =>
|
||||
Task.FromException(new WslcError(
|
||||
WslcError.Unsupported,
|
||||
"the wslc compat SDK has no pty, so there is no terminal to resize"));
|
||||
}
|
||||
|
||||
// MARK: - Error translation
|
||||
|
||||
/// <summary>
|
||||
/// POSIX int → the named <c>Signal</c> enum. The RPC carries POSIX numbers because every
|
||||
/// caller above it is platform-neutral, but wslc accepts only six, so anything else (SIGUSR1,
|
||||
/// SIGWINCH) is a permanent gap rather than a number to pass through.
|
||||
/// </summary>
|
||||
private static Sdk.Signal MapSignal(int signal) => signal switch
|
||||
{
|
||||
0 => Sdk.Signal.None,
|
||||
1 => Sdk.Signal.SIGHUP,
|
||||
2 => Sdk.Signal.SIGINT,
|
||||
3 => Sdk.Signal.SIGQUIT,
|
||||
9 => Sdk.Signal.SIGKILL,
|
||||
15 => Sdk.Signal.SIGTERM,
|
||||
_ => throw new WslcError(
|
||||
WslcError.Unsupported,
|
||||
$"wslc cannot deliver signal {signal} (only HUP, INT, QUIT, KILL and TERM)"),
|
||||
};
|
||||
|
||||
private const int ErrorAlreadyExists = unchecked((int)0x800700B7);
|
||||
|
||||
private static int HResultOf(Exception e) => e is COMException com ? com.HResult : e.HResult;
|
||||
|
||||
/// <summary>
|
||||
/// COM HRESULTs → the RPC's `data.kind`. Keyed on the number, never the message: these
|
||||
/// exceptions frequently arrive with an EMPTY message, so string matching would silently
|
||||
/// classify every one of them as the fallback.
|
||||
/// </summary>
|
||||
private static WslcError Translate(Exception e, string fallbackKind)
|
||||
{
|
||||
var code = HResultOf(e);
|
||||
var kind = (uint)code switch
|
||||
{
|
||||
process.ResizeTerminal(cols, rows);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
0x80040601 => WslcError.NotFound, // WSLC_E_IMAGE_NOT_FOUND
|
||||
0x80040603 => WslcError.NotFound, // WSLC_E_CONTAINER_NOT_FOUND
|
||||
0x80040605 => WslcError.NotRunning, // WSLC_E_CONTAINER_NOT_RUNNING
|
||||
0x8004060F => WslcError.NotFound, // WSLC_E_SESSION_NOT_FOUND
|
||||
0x80040607 => WslcError.SessionExists, // WSLC_E_SESSION_RESERVED
|
||||
0x800700B7 => WslcError.SessionExists, // ERROR_ALREADY_EXISTS
|
||||
// Nothing installed vs. installed-but-too-old: opposite diagnoses, and both mean the
|
||||
// sandbox is unusable rather than this call being wrong.
|
||||
0x80040154 => WslcError.Unavailable, // REGDB_E_CLASSNOTREG
|
||||
0x80070032 => WslcError.Unavailable, // ERROR_NOT_SUPPORTED
|
||||
0x8004060B => WslcError.Unavailable, // WSLC_E_SDK_UPDATE_NEEDED
|
||||
0x8004060D => WslcError.PullFailed, // WSLC_E_REGISTRY_BLOCKED_BY_POLICY
|
||||
_ => fallbackKind,
|
||||
};
|
||||
var message = string.IsNullOrWhiteSpace(e.Message) ? $"wslc failed (0x{code:X8})" : e.Message;
|
||||
return new WslcError(kind, message);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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,
|
||||
"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:<hex> 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 "
|
||||
|
||||
Reference in New Issue
Block a user