diff --git a/NucleicBroker.Tests/BrokerServiceTests.cs b/NucleicBroker.Tests/BrokerServiceTests.cs
index 7bce5e8..3c08033 100644
--- a/NucleicBroker.Tests/BrokerServiceTests.cs
+++ b/NucleicBroker.Tests/BrokerServiceTests.cs
@@ -112,6 +112,23 @@ public sealed class BrokerServiceTests
Assert.Equal(new SessionSpec("nucleic-dev", @"C:\data", 4, 8192), wslc.LastSession);
}
+ ///
+ /// D13 Tier 1 (docs/WINDOWS_PORT.md §13.2): the facade auto-recovers a session orphaned by a
+ /// dead broker, but when the internal COM arm did not bind there is nothing it can do — and
+ /// hostd must be able to tell *that* apart from an ordinary start failure, because the remedy
+ /// is `wsl --shutdown` rather than a retry. This pins the kind the Swift side branches on.
+ ///
+ [Fact]
+ public async Task SessionEnsure_UnrecoverableConflict_SurfacesSessionExists()
+ {
+ wslc.NextError = new WslcError(WslcError.SessionExists, "already running, cannot re-adopt");
+ var lines = await RoundTrip(
+ """{"jsonrpc":"2.0","id":9,"method":"session.ensure","params":{"name":"nucleic-dev","dataDir":"C:\\data"}}""");
+ var error = Assert.Single(lines).GetProperty("error");
+ Assert.Equal(Rpc.FacadeError, error.GetProperty("code").GetInt32());
+ Assert.Equal("session_exists", error.GetProperty("data").GetProperty("kind").GetString());
+ }
+
[Fact]
public async Task ImagePull_EmitsProgressNotificationsBeforeResult()
{
diff --git a/NucleicBroker/Program.cs b/NucleicBroker/Program.cs
index 6830847..3c623db 100644
--- a/NucleicBroker/Program.cs
+++ b/NucleicBroker/Program.cs
@@ -12,7 +12,10 @@ Console.OutputEncoding = Encoding.UTF8;
IWslc wslc =
#if USE_WSLC
- new NucleicBroker.Wslc.WslcFacade();
+ // Create(), not `new`: it configures COM security first, and that MUST precede the process's
+ // first COM call (docs/WINDOWS_PORT.md §13.2). Constructing the facade directly compiles fine
+ // and silently loses session recovery to a 0x80070542 that reads as "not found".
+ NucleicBroker.Wslc.WslcFacade.Create();
#else
new UnavailableWslc();
#endif
diff --git a/NucleicBroker/Wslc/WslcFacade.cs b/NucleicBroker/Wslc/WslcFacade.cs
index 009f4a9..73001a6 100644
--- a/NucleicBroker/Wslc/WslcFacade.cs
+++ b/NucleicBroker/Wslc/WslcFacade.cs
@@ -48,6 +48,25 @@ public sealed class WslcFacade : IWslc
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 = [];
@@ -76,10 +95,18 @@ public sealed class WslcFacade : IWslc
}
}
- /// 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"];
+ ///
+ /// 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;
@@ -113,34 +140,19 @@ public sealed class WslcFacade : IWslc
{
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}");
-
+ var created = NewSession(spec);
try
{
created.Start();
}
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");
+ // 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)
{
@@ -158,6 +170,73 @@ public sealed class WslcFacade : IWslc
}
}
+ /// 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)
diff --git a/NucleicBroker/Wslc/WslcInternal.cs b/NucleicBroker/Wslc/WslcInternal.cs
new file mode 100644
index 0000000..a1abf2e
--- /dev/null
+++ b/NucleicBroker/Wslc/WslcInternal.cs
@@ -0,0 +1,384 @@
+#if USE_WSLC
+using System.Runtime.InteropServices;
+
+namespace NucleicBroker.Wslc;
+
+///
+/// D13's internal-COM arm, **Tier 1: recover** (docs/WINDOWS_PORT.md §13.2).
+///
+/// The compat SDK cannot re-adopt a running session — its `Session` constructor is lazy and
+/// `Start()` refuses an existing name with `ERROR_ALREADY_EXISTS`. So a broker that restarts
+/// while its session is up cannot reach the sandbox again, and today that costs the user a manual
+/// `wsl --shutdown`. This class fixes exactly that: it opens the orphaned session through the
+/// **service-internal** COM interface, reports what was running, terminates it, and lets the
+/// facade create a fresh one through the ordinary compat path.
+///
+/// It deliberately does NOT try to keep those containers alive — that is Tier 2, and it is
+/// blocked (§13.2): `Session.FromAbi()` throws on a service-side pointer, because the WinRT layer
+/// the C# projection wraps lives client-side in `wslcsdk.dll`. Everything here is confirmed on
+/// hardware; nothing here depends on that unresolved question.
+///
+/// **Four things that are not obvious and each cost a debugging round:**
+///
+/// 1. **There is no CLSID for `IWSLCSessionManager`.** `wslc.idl` declares interfaces and no
+/// activatable class. The entry point is the *compat* coclass — `WSLCCompatSessionManager`
+/// also implements the internal interface. One object, two faces.
+/// 2. **The proxy must grant IMPERSONATE.** Per-user calls like `OpenSessionByName` fail
+/// `0x80070542` (`ERROR_BAD_IMPERSONATION_LEVEL`) under .NET's default `IDENTIFY` — a security
+/// error that reads exactly like "not found". `GetVersion`/`ListSessions` don't impersonate,
+/// so they succeed and make it look like a per-method gap.
+/// handles this process-wide; the per-proxy blanket here is belt and braces.
+/// 3. **Vtable slots are fixed by declaration ORDER, not signature.** Only the methods actually
+/// called need accurate signatures, which is what makes reaching `ListContainers` (method #19,
+/// behind four methods taking a by-value `WSLCHandle` union) tractable at all.
+/// 4. **The ABI is explicitly unstable.** `wslc.idl` says breaking changes are fine because
+/// Microsoft ships both ends. We are not both ends, so every entry point here is probed and
+/// every failure degrades to "no recovery" rather than propagating.
+///
+internal sealed class WslcInternal : IDisposable
+{
+ private IWSLCSessionManager? manager;
+ private IntPtr managerPtr;
+
+ /// What a recovery found and did, for the host log and the `session.down` story.
+ internal sealed record Recovery(IReadOnlyList Containers, bool Terminated);
+
+ ///
+ /// Bind the internal interface, or return null. Called once at facade construction so the
+ /// result can be reported in the `capabilities` hello (§2.3) rather than discovered when a
+ /// user's broker restarts.
+ ///
+ internal static WslcInternal? TryBind()
+ {
+ var clsid = ClsidWslcCompatSessionManager;
+ var iid = IidWslcSessionManager;
+ // Ask for the internal interface directly. The compat coclass implements both, and going
+ // straight for it means a machine where this arm is unavailable fails here rather than
+ // half-way through a recovery.
+ var hr = CoCreateInstance(in clsid, IntPtr.Zero, ClsctxAll, in iid, out var ptr);
+ if (hr < 0)
+ {
+ // REGDB_E_CLASSNOTREG here means WSL simply isn't installed — the §8 onboarding
+ // state, not a defect. Saying "recovery unavailable" without that distinction reads
+ // as a broker fault on a machine that has not been set up yet.
+ Console.Error.WriteLine((uint)hr == RegdbEClassNotReg
+ ? "wslc: WSL is not installed — internal COM absent, as expected before onboarding"
+ : $"wslc: internal COM did not bind (0x{hr:X8}) — a broker restart will not "
+ + "auto-recover a running session; D13 Tier 1 is unavailable on this machine");
+ return null;
+ }
+
+ RaiseImpersonation(ptr);
+ try
+ {
+ return new WslcInternal
+ {
+ managerPtr = ptr,
+ manager = (IWSLCSessionManager)Marshal.GetObjectForIUnknown(ptr),
+ };
+ }
+ catch (Exception e)
+ {
+ Marshal.Release(ptr);
+ Console.Error.WriteLine($"wslc: internal COM bound but unusable: {e.Message}");
+ return null;
+ }
+ }
+
+ ///
+ /// Open the orphaned session named , note what was running in it, and
+ /// terminate it. Returns null when there is nothing to recover — which is the ordinary case
+ /// and not an error.
+ ///
+ /// Terminating rather than adopting is the deliberate Tier 1 choice: the containers are lost,
+ /// but they are lost today too, and `ContainerManager.reconcile` already copes with a sandbox
+ /// that came back empty. What it buys is that the *next* `Start()` succeeds.
+ ///
+ internal Recovery? RecoverSession(string name)
+ {
+ if (manager is null) return null;
+
+ int hr;
+ IntPtr sessionPtr;
+ try
+ {
+ hr = manager.OpenSessionByName(name, out sessionPtr);
+ }
+ catch (Exception e)
+ {
+ Console.Error.WriteLine($"wslc: OpenSessionByName('{name}') threw: {e.Message}");
+ return null;
+ }
+ if (hr < 0)
+ {
+ Console.Error.WriteLine($"wslc: no recoverable session '{name}' (0x{hr:X8})"
+ + ((uint)hr == ErrorBadImpersonationLevel
+ ? " — IMPERSONATE was not granted; CoInitializeSecurity must run before the "
+ + "first COM call in the process"
+ : ""));
+ return null;
+ }
+
+ // The session proxy is a separate object from the manager, so it needs its own blanket.
+ RaiseImpersonation(sessionPtr);
+ try
+ {
+ var session = (IWSLCSession)Marshal.GetObjectForIUnknown(sessionPtr);
+ var containers = ListContainers(session);
+ var terminated = Terminate(session);
+ Console.Error.WriteLine(
+ $"wslc: recovered orphaned session '{name}' — {containers.Count} container(s) "
+ + $"[{string.Join(", ", containers)}], terminated={terminated}");
+ return new Recovery(containers, terminated);
+ }
+ catch (Exception e)
+ {
+ Console.Error.WriteLine($"wslc: recovery of '{name}' failed: {e.Message}");
+ return null;
+ }
+ finally
+ {
+ Marshal.Release(sessionPtr);
+ }
+ }
+
+ ///
+ /// The container roster of a session, by name. Enumeration the compat SDK has no call for at
+ /// all — Session exposes no listing and Container carries no Name.
+ ///
+ private static IReadOnlyList ListContainers(IWSLCSession session)
+ {
+ // Flags=All, or the listing is running-containers-only and a stopped container silently
+ // vanishes from the recovery report.
+ var options = new WslcListContainersOptions
+ {
+ Flags = WslcListContainersFlagsAll,
+ Limit = 0,
+ Filters = IntPtr.Zero,
+ FiltersCount = 0,
+ };
+ var optionsPtr = Marshal.AllocCoTaskMem(Marshal.SizeOf());
+ var containers = IntPtr.Zero;
+ var ports = IntPtr.Zero;
+ try
+ {
+ Marshal.StructureToPtr(options, optionsPtr, fDeleteOld: false);
+ var hr = session.ListContainers(optionsPtr, out containers, out var count,
+ out ports, out _);
+ if (hr < 0)
+ {
+ Console.Error.WriteLine($"wslc: ListContainers failed (0x{hr:X8})");
+ return [];
+ }
+
+ var size = Marshal.SizeOf();
+ var names = new List((int)count);
+ for (var i = 0; i < count; i++)
+ {
+ var entry = Marshal.PtrToStructure(containers + i * size);
+ names.Add(string.IsNullOrEmpty(entry.Name) ? entry.Id : entry.Name);
+ }
+ return names;
+ }
+ finally
+ {
+ Marshal.FreeCoTaskMem(optionsPtr);
+ // Both out-arrays are callee-allocated; nobody else frees them.
+ if (containers != IntPtr.Zero) Marshal.FreeCoTaskMem(containers);
+ if (ports != IntPtr.Zero) Marshal.FreeCoTaskMem(ports);
+ }
+ }
+
+ private static bool Terminate(IWSLCSession session)
+ {
+ try
+ {
+ var hr = session.Terminate();
+ if (hr >= 0) return true;
+ Console.Error.WriteLine($"wslc: session Terminate failed (0x{hr:X8})");
+ return false;
+ }
+ catch (Exception e)
+ {
+ Console.Error.WriteLine($"wslc: session Terminate threw: {e.Message}");
+ return false;
+ }
+ }
+
+ public void Dispose()
+ {
+ manager = null;
+ if (managerPtr != IntPtr.Zero)
+ {
+ Marshal.Release(managerPtr);
+ managerPtr = IntPtr.Zero;
+ }
+ }
+
+ // MARK: - COM security
+
+ ///
+ /// Grant servers the right to impersonate this process, for **every** proxy it will hold.
+ ///
+ /// Must run before the first COM call in the process or it fails `RPC_E_TOO_LATE` — and the
+ /// compat SDK makes COM calls of its own, so this has to precede any `WslcService`/`Session`
+ /// use, not merely precede the internal arm. Failing is not fatal: only the per-user internal
+ /// calls need it, so the sandbox still runs and recovery is what degrades.
+ ///
+ internal static void InitializeSecurity()
+ {
+ var hr = CoInitializeSecurity(
+ IntPtr.Zero, -1, IntPtr.Zero, IntPtr.Zero,
+ RpcCAuthnLevelDefault, RpcCImpLevelImpersonate, IntPtr.Zero, EoacNone, IntPtr.Zero);
+ // RPC_E_TOO_LATE means something already initialised security — worth saying, because it
+ // silently removes session recovery and nothing else will mention it.
+ if (hr < 0)
+ Console.Error.WriteLine(
+ $"wslc: CoInitializeSecurity failed (0x{hr:X8})"
+ + ((uint)hr == RpcETooLate
+ ? " — RPC_E_TOO_LATE: a COM call ran first. Session recovery will fail "
+ + "0x80070542."
+ : ""));
+ }
+
+ private static void RaiseImpersonation(IntPtr proxy) =>
+ // Per-proxy, and harmless if CoInitializeSecurity already covered it. Kept because the
+ // process-wide call is order-dependent and this one is not.
+ CoSetProxyBlanket(
+ proxy, RpcCAuthnDefault, RpcCAuthzDefault, ColeDefaultPrincipal,
+ RpcCAuthnLevelDefault, RpcCImpLevelImpersonate, ColeDefaultAuthinfo, EoacNone);
+
+ // MARK: - Interop
+
+ /// `WSLCCompatSessionManager` from WSLCCompat.idl. Not a typo that this is the *compat*
+ /// class: `wslc.idl` declares no coclass, and this one answers a QI for the internal
+ /// interface (confirmed on hardware, §13.2).
+ private static readonly Guid ClsidWslcCompatSessionManager =
+ new("a9b7a1b9-0671-405c-95f1-e0612cb4ce8f");
+
+ private static readonly Guid IidWslcSessionManager = new("82A7ABC8-6B50-43FC-AB96-15FBBE7E8760");
+
+ private const uint ClsctxAll = 0x17;
+ private const uint RpcCAuthnDefault = 0xFFFFFFFF;
+ private const uint RpcCAuthzDefault = 0xFFFFFFFF;
+ private const uint RpcCAuthnLevelDefault = 0;
+ private const uint RpcCImpLevelImpersonate = 3;
+ private const uint EoacNone = 0;
+ private const uint RpcETooLate = 0x80010119;
+ private const uint RegdbEClassNotReg = 0x80040154;
+ private const uint ErrorBadImpersonationLevel = 0x80070542;
+ private const uint WslcListContainersFlagsAll = 1;
+ private static readonly IntPtr ColeDefaultPrincipal = new(-1);
+ private static readonly IntPtr ColeDefaultAuthinfo = new(-1);
+
+ [DllImport("ole32.dll")]
+ private static extern int CoCreateInstance(
+ in Guid clsid, IntPtr outer, uint clsContext, in Guid iid, out IntPtr instance);
+
+ [DllImport("ole32.dll")]
+ private static extern int CoSetProxyBlanket(
+ IntPtr proxy, uint authnService, uint authzService, IntPtr serverPrincipalName,
+ uint authnLevel, uint impersonationLevel, IntPtr authInfo, uint capabilities);
+
+ [DllImport("ole32.dll")]
+ private static extern int CoInitializeSecurity(
+ IntPtr securityDescriptor, int authSvcCount, IntPtr authSvc, IntPtr reserved1,
+ uint authnLevel, uint impersonationLevel, IntPtr authList, uint capabilities,
+ IntPtr reserved3);
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct WslcListContainersOptions
+ {
+ public uint Flags;
+ public int Limit;
+ public IntPtr Filters;
+ public uint FiltersCount;
+ }
+
+ ///
+ /// `WSLCContainerEntry` from wslc.idl. The three char arrays are **inline fixed buffers**,
+ /// not pointers — `ByValTStr`/`Ansi`, with the sizes straight from the IDL's `+ 1` constants
+ /// (255+1, 255+1, 64+1). Getting a size wrong here does not fail loudly; it silently shifts
+ /// every later field. The equivalent layout was validated on hardware via
+ /// `ListSessions`, whose entry struct has the same shape (§13.2).
+ ///
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
+ private struct WslcContainerEntry
+ {
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string Name;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string Image;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 65)] public string Id;
+ public ulong StateChangedAt;
+ public ulong CreatedAt;
+ public uint State;
+ }
+
+ [ComImport, Guid("82A7ABC8-6B50-43FC-AB96-15FBBE7E8760"),
+ InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ private interface IWSLCSessionManager
+ {
+ [PreserveSig] int GetVersion(out WslcVersion version);
+ [PreserveSig] int CreateSession(IntPtr settings, uint flags, IntPtr warningCallback, out IntPtr session);
+ [PreserveSig] int EnterSession(
+ [MarshalAs(UnmanagedType.LPWStr)] string displayName,
+ [MarshalAs(UnmanagedType.LPWStr)] string storagePath,
+ IntPtr warningCallback, out IntPtr session);
+ [PreserveSig] int ListSessions(out IntPtr sessions, out uint count);
+ [PreserveSig] int OpenSession(uint id, out IntPtr session);
+ [PreserveSig] int OpenSessionByName(
+ [MarshalAs(UnmanagedType.LPWStr)] string displayName, out IntPtr session);
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct WslcVersion
+ {
+ public uint Major;
+ public uint Minor;
+ public uint Revision;
+ }
+
+ ///
+ /// `IWSLCSession`, declared only as far as Terminate (method #23).
+ ///
+ /// **Every method ahead of the ones we call must still be declared**, because a COM vtable is
+ /// addressed by slot — but only the called ones need accurate signatures, since a method that
+ /// is never invoked is never marshalled. That is what makes this tractable: four of the
+ /// placeholders (`LoadImage`, `ImportImage`, `SaveImage`, `SaveImages`) take `WSLCHandle` — a
+ /// tagged union — **by value**, which would be genuinely awkward to marshal and does not have
+ /// to be. Parameter *counts* are kept faithful to the IDL purely as documentation.
+ ///
+ /// Do not reorder. Do not delete an unused entry. Either silently shifts every slot below it.
+ ///
+ [ComImport, Guid("EF0661E4-6364-40EA-B433-E2FDF11F3519"),
+ InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ private interface IWSLCSession
+ {
+ [PreserveSig] int GetId(out uint id); // 1
+ [PreserveSig] int GetDisplayName(out IntPtr displayName); // 2
+ [PreserveSig] int GetState(out uint state); // 3
+ [PreserveSig] int GetTerminationEvent(out IntPtr eventHandle); // 4
+ [PreserveSig] int GetTerminationReason(out uint reason, out IntPtr details); // 5
+ [PreserveSig] int PullImage(IntPtr a, IntPtr b, IntPtr c, IntPtr d); // 6
+ [PreserveSig] int BuildImage(IntPtr a, IntPtr b, IntPtr c); // 7
+ [PreserveSig] int LoadImage(IntPtr a, IntPtr b, IntPtr c, IntPtr d); // 8 (WSLCHandle by value)
+ [PreserveSig] int ImportImage(IntPtr a, IntPtr b, IntPtr c, IntPtr d, IntPtr e); // 9 (WSLCHandle by value)
+ [PreserveSig] int SaveImage(IntPtr a, IntPtr b, IntPtr c, IntPtr d); // 10 (WSLCHandle by value)
+ [PreserveSig] int SaveImages(IntPtr a, IntPtr b, IntPtr c, IntPtr d); // 11 (WSLCHandle by value)
+ [PreserveSig] int ListImages(IntPtr a, IntPtr b, IntPtr c); // 12
+ [PreserveSig] int DeleteImage(IntPtr a, IntPtr b, IntPtr c); // 13
+ [PreserveSig] int TagImage(IntPtr a); // 14
+ [PreserveSig] int InspectImage(IntPtr a, IntPtr b); // 15
+ [PreserveSig] int PruneImages(IntPtr a, IntPtr b, IntPtr c, IntPtr d, IntPtr e); // 16
+ [PreserveSig] int CreateContainer(IntPtr a, IntPtr b, IntPtr c); // 17
+ [PreserveSig] int OpenContainer(IntPtr a, IntPtr b); // 18
+ [PreserveSig] int ListContainers( // 19
+ IntPtr options, out IntPtr containers, out uint count,
+ out IntPtr ports, out uint portsCount);
+ [PreserveSig] int PruneContainers(IntPtr a, IntPtr b, IntPtr c); // 20
+ [PreserveSig] int CreateRootNamespaceProcess(
+ IntPtr a, IntPtr b, IntPtr c, IntPtr d, IntPtr e, IntPtr f); // 21
+ [PreserveSig] int FormatVirtualDisk(IntPtr a); // 22
+ [PreserveSig] int Terminate(); // 23
+ }
+}
+#endif
diff --git a/spikes/README.md b/spikes/README.md
index 47de79c..3d219d3 100644
--- a/spikes/README.md
+++ b/spikes/README.md
@@ -180,11 +180,11 @@ for, and nothing above it should move. These three are different:
mounted vs. in-VM). Deliberately held back until `WslcApiDump` has run: written now, against
guessed names, it would not compile, and fixing it blind is the mistake this whole approach
exists to avoid.
-- **The internal arm, Tier 1 — "recover"** (`WslcInternal.cs` in the broker, not a spike).
- Everything it needs is confirmed on hardware: entry point, vtable, impersonation, and
- `OpenSessionByName`. On broker restart, open the orphaned session, `ListContainers` for
- reporting, `Terminate` it, and create a fresh one through the compat SDK — automatic clean
- recovery instead of a manual `wsl --shutdown`. Enough for M2.
+- ~~**The internal arm, Tier 1 — "recover"**~~ **written**: `windows/NucleicBroker/Wslc/WslcInternal.cs`.
+ On broker restart it opens the orphaned session, lists its containers for the log, terminates
+ it, and lets the facade start fresh — automatic clean recovery instead of a manual
+ `wsl --shutdown`. Still needs one live test: kill a broker mid-session and confirm the next one
+ recovers (see below).
- **Tier 2 — "adopt"** (keep containers running across a broker restart) is **blocked**. The
`Session.FromAbi()` handoff throws `InvalidCastException` even though the QI to
`IWSLCCompatSession` succeeds: the WinRT layer appears to be a client-side wrapper in