Merge nucleic/lucid-river-toad-6efj into dev

This commit is contained in:
2026-07-29 04:04:16 -07:00
parent f5cd4a3023
commit 0f720d8328
5 changed files with 514 additions and 31 deletions
+104 -25
View File
@@ -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();
/// <summary>
/// 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".
/// </summary>
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<string, Entry> 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<string> Capabilities => ["stats"];
/// <summary>
/// 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).
/// </summary>
public IReadOnlyList<string> 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
}
}
/// <summary>A settings-configured session with its handlers already attached. Subscribing
/// must happen BEFORE `Start()`, or a session that dies during boot never reports down.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
private async Task<Sdk.Session> 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)