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

This commit is contained in:
2026-07-29 03:06:18 -07:00
parent 57fd65bfe1
commit 3e1f90bdac
3 changed files with 329 additions and 12 deletions
+37 -12
View File
@@ -36,6 +36,8 @@ dotnet run -- --probe # + GetMissingComponents / GetVersion
dotnet run -- --session # + create a session, a SECOND with the same name, identity-test, tear down dotnet run -- --session # + create a session, a SECOND with the same name, identity-test, tear down
dotnet run -- --session --keep # …and leave the sessions running afterwards dotnet run -- --session --keep # …and leave the sessions running afterwards
dotnet run -- --all-types # include the ABI/marshalling plumbing in the dump dotnet run -- --all-types # include the ABI/marshalling plumbing in the dump
dotnet run -- --internal # can the SERVICE-INTERNAL COM interface be reached? (QI only)
dotnet run -- --internal-call # …and call through it (can crash — that is the finding)
``` ```
**If it reports missing components**, the machine cannot run wslc yet, and the two components are **If it reports missing components**, the machine cannot run wslc yet, and the two components are
@@ -109,19 +111,40 @@ told you to clean it up with a command that doesn't exist. Pass `--keep` to leav
how you check the other half of the §2.3 question: whether session state outlives the process that how you check the other half of the §2.3 question: whether session state outlives the process that
created it. With `--keep`, `wsl --shutdown` clears everything. created it. With `--keep`, `wsl --shutdown` clears everything.
The gateway address is *not* what this probe is for any more — §13.1 established that no API The gateway address is *not* what this probe is for — §13.1 established that no API surfaces one,
surfaces one, and D13 moves the control plane to hvsocket via `IWSLCVirtualMachine::GetId`. so it comes from `GetAdaptersAddresses` over `vEthernet (WSL)` instead, which `WslcFacade` now does.
### The three answers that change the design ### `--internal` — is D13's internal arm even reachable?
The newest open question, and the one item 5 stopped at (docs/WINDOWS_PORT.md §13.2). D13 routes
enumeration, reattach and the Terminal panel's pty to the service-internal `IWSLCSessionManager`
(IID `82A7ABC8-…`). But **`wslc.idl` declares interfaces and no activatable class** — there is no
CLSID in it, so `CoCreateInstance` has nothing to name. The only registered coclasses in WSL's
IDLs belong to the compat surface (`WSLCCompatSessionManager` and its factory) and to the WSL
service proper (`LxssUserSession`, `LxssUserSessionInBox`).
The likely answer is that one of those objects also implements the internal interface — COM
objects routinely expose several — so `--internal` activates each and QIs for the internal IIDs.
It **never calls a method**, so a vtable mismatch cannot crash it, and QI alone answers the
question. `--internal-call` then calls `GetVersion` (slot 3) and `ListSessions` to confirm the
vtable really matches the IDL; *that* can take the process down, which is why it is opt-in.
It also QIs `IWSLCVirtualMachine`, which is **expected to fail**: per the IDL only
`IWSLCVirtualMachineFactory::CreateVirtualMachine` produces one, and the SYSTEM service owns the
factory. §13.1's hvsocket-primary decision was retracted on that reading, and a decision reversed
by reading deserves confirming against the machine.
### The answers that change the design
Most mismatches are a one-line edit in `WslcFacade.cs` — that is exactly what the `IWslc` seam is Most mismatches are a one-line edit in `WslcFacade.cs` — that is exactly what the `IWslc` seam is
for, and nothing above it should move. These three are different: for, and nothing above it should move. These three are different:
| Finding | Consequence | | Finding | Consequence |
| --- | --- | | --- | --- |
| No container enumeration, stats, pty or attach in the SDK | **Settled by D13.** `wslcsdk.dll` wraps `WSLCCompat.idl` (the stable SDK surface), which genuinely lacks them; they all exist on `wslc.idl`, the service-internal COM interface `wslc.exe` calls — `ListContainers`, `Stats`, `ResizeTty`, `OpenContainer`/`Attach`, `OpenSessionByName`, and `IWSLCVirtualMachine::GetId`. The broker binds both surfaces; no CLI. | | No container enumeration, stats, pty or attach in the SDK | **D13's answer, now gated.** They exist on `wslc.idl`, the service-internal interface `wslc.exe` calls — but that IDL declares no coclass, so reaching it at all is what `--internal` tests. Stats no longer wait on it: `WslcFacade` reads cgroup v2 in the guest instead. |
| No create-or-attach on `Session` | **Answered:** `IWSLCSessionManager::OpenSessionByName` / `EnterSession` / `ListSessions` on the internal interface. §2.3 reattach has its mechanism. | | `Container` has no `Name`, `Session` has no `GetContainers()` | Sharper than "no enumeration": a container is reachable ONLY through the handle `CreateContainer` returned, so the broker keeps its own name→handle roster — which dies with the process. A restarted broker sees an empty sandbox (§13.2). |
| No gateway address anywhere on the API | **Skip TCP:** `IWSLCVirtualMachine::GetId` returns the VM GUID, so §5's AF_HYPERV/AF_VSOCK path (true vsock parity with macOS) should become primary at M1 (b). Gateway TCP stays as fallback, its address from `GetAdaptersAddresses` over `vEthernet (WSL)`. | | No create-or-attach on `Session` | `IWSLCSessionManager::OpenSessionByName` / `EnterSession` — same gate as above. Until then `session.ensure` fails `session_exists` and names `wsl --shutdown` as the remedy. |
| No gateway address anywhere on the API | **Gateway TCP stays primary.** The hvsocket alternative needed a VMID from `IWSLCVirtualMachine::GetId`, and that interface is unreachable from a client — retracted in §13.1. The address comes from `GetAdaptersAddresses` over `vEthernet (WSL)`. |
| No uid on `ProcessSettings` | Recoverable, and already planned for: exec wraps argv in `setpriv`/`su agent -c`. Interceptors and nash don't care about the numeric uid (§3.2). | | No uid on `ProcessSettings` | Recoverable, and already planned for: exec wraps argv in `setpriv`/`su agent -c`. Interceptors and nash don't care about the numeric uid (§3.2). |
--- ---
@@ -134,9 +157,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 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 guessed names, it would not compile, and fixing it blind is the mistake this whole approach
exists to avoid. exists to avoid.
- **`HvSocketSpike`** — M1 (b), and now the *primary* control-plane spike rather than the - **`GatewaySpike`** — M1 (b), and the control-plane spike that actually matters now: can a
fallback one (D13): bind an AF_HYPERV listener on the VM GUID from `Bridged` wslc container reach the host at the `vEthernet (WSL)` address `WslcFacade` returns,
`IWSLCVirtualMachine::GetId` and dial AF_VSOCK from inside a wslc container. If vsock under the **default** Windows firewall, and does `control-bridge.js` complete an MCP round trip
traverses the container's namespaces, the Windows control plane gets true parity with macOS over it? This is the committed path, not a fallback.
and §5's firewall/NAT variance stops mattering. Measure gateway-TCP reachability in the same - **`HvSocketSpike`** — downgraded back to upside (§13.2). It needs a VM GUID to bind `AF_HYPERV`
run so the fallback stays evidenced. to, and `IWSLCVirtualMachine::GetId` turned out to be unreachable from a client, so the VMID
would have to come from outside wslc entirely — HCS enumeration, or the WSL VM's registry
identity. Worth doing only if the gateway path shows friction in the wild; not on the M2 path.
+282
View File
@@ -0,0 +1,282 @@
using System.Runtime.InteropServices;
namespace WslcApiDump;
/// <summary>
/// D13's open question, reduced to one run (docs/WINDOWS_PORT.md §13.2).
///
/// The plan routes container enumeration, session/container reattach and the Terminal panel's pty
/// to the service-internal COM interface `IWSLCSessionManager` (IID 82A7ABC8-…). Reading
/// `wslc.idl` turned up a problem the plan assumed away: **it declares interfaces and no
/// activatable class.** There is no CLSID in it, so `CoCreateInstance` has nothing to name, and
/// the only registered coclasses anywhere in WSL's IDLs belong to the *compat* surface and to the
/// WSL service proper.
///
/// The likely answer is that one of those coclasses also implements the internal interface — COM
/// objects routinely expose several — and `wslc.exe` simply QIs for it. That is a yes/no question,
/// and everything downstream depends on it:
///
/// * **yes** → write the internal arm (`ListContainers`, `OpenSessionByName`, `OpenContainer`,
/// `ResizeTty`), and a broker restart stops being fatal to a running sandbox.
/// * **no** → D13 needs reopening. The realistic alternatives are a durable host-side roster
/// plus `wsl --shutdown` on restart, or revisiting the rejected `wslc.exe` arm for enumeration.
///
/// Two stages, because they carry very different risk:
///
/// * <c>--internal</c> activates each candidate class and QIs for the internal IIDs. It never
/// calls a method, so **it cannot crash on a vtable mismatch** — and QI alone answers the
/// entry-point question.
/// * <c>--internal-call</c> additionally calls through the interface (`GetVersion`, then
/// `ListSessions`). This one CAN take the process down if the real vtable differs from the
/// IDL — which is itself a finding, and the reason it is opt-in rather than default.
/// </summary>
internal static class InternalComProbe
{
// The only registered coclasses in WSL's IDLs. wslc.idl contributes none — that is the
// problem — so every candidate here comes from WSLCCompat.idl or wslservice.idl.
private static readonly (string Name, Guid Clsid, string Source)[] Candidates =
[
("WSLCCompatSessionManager", new Guid("a9b7a1b9-0671-405c-95f1-e0612cb4ce8f"),
"WSLCCompat.idl — what the SDK itself activates, so the likeliest host"),
("WSLCCompatSessionManagerFactory", new Guid("9fcd2067-9fc6-4efa-9eb0-698169ebf7d3"),
"WSLCCompat.idl"),
("LxssUserSession", new Guid("a9b7a1b9-0671-405c-95f1-e0612cb4ce7e"),
"wslservice.idl — the WSL service proper; note it differs from the compat CLSID "
+ "only in the last byte (ce7e vs ce8f)"),
("LxssUserSessionInBox", new Guid("4f476546-b412-4579-b64c-123df331e3d6"),
"wslservice.idl"),
];
private static readonly (string Name, Guid Iid, string Why)[] Interfaces =
[
("IWSLCSessionManager", new Guid("82A7ABC8-6B50-43FC-AB96-15FBBE7E8760"),
"THE one that matters — OpenSessionByName/EnterSession/ListSessions, i.e. reattach"),
("IWSLCSession", new Guid("EF0661E4-6364-40EA-B433-E2FDF11F3519"),
"ListContainers/OpenContainer — enumeration and the container half of reattach"),
("IWSLCVirtualMachine", new Guid("B5E2D8F1-9A3C-4E6B-8D1F-7C4A2E9B6D3A"),
"GetId → the VM GUID an AF_HYPERV bind needs. Per the IDL only a factory the SYSTEM "
+ "service owns can produce one, so this is EXPECTED to fail — probed anyway, "
+ "because §13.2 retracted a decision on that reading and it deserves confirming"),
];
internal static void Run(bool callThrough)
{
Console.WriteLine();
Console.WriteLine("internal COM probe (docs/WINDOWS_PORT.md §13.2):");
Console.WriteLine(" wslc.idl declares no coclass, so the question is whether an EXISTING");
Console.WriteLine(" class answers a QI for the internal interfaces.");
Console.WriteLine();
var initialized = CoInitializeEx(IntPtr.Zero, CoinitMultithreaded);
// S_FALSE means already initialized on this thread; RPC_E_CHANGED_MODE means the runtime
// picked the other apartment. Neither is fatal for an out-of-proc server.
if (initialized < 0 && (uint)initialized != RpcEChangedMode)
{
Console.WriteLine($" CoInitializeEx failed: 0x{initialized:X8} — cannot probe");
return;
}
var bound = false;
foreach (var (name, clsid, source) in Candidates)
{
Console.WriteLine($" {name} {{{clsid}}}");
Console.WriteLine($" ({source})");
var iunknown = typeof(object).GUID; // IID_IUnknown
var hr = CoCreateInstance(in clsid, IntPtr.Zero, ClsctxAll, in iunknown, out var unknown);
if (hr < 0)
{
Console.WriteLine($" activation failed: {Hresult(hr)}");
Console.WriteLine();
continue;
}
Console.WriteLine(" activated");
try
{
foreach (var (interfaceName, iid, why) in Interfaces)
{
var iidLocal = iid;
var qi = Marshal.QueryInterface(unknown, in iidLocal, out var candidate);
if (qi >= 0)
{
Console.WriteLine($" QI {interfaceName}: **YES**");
if (interfaceName == "IWSLCSessionManager")
{
bound = true;
if (callThrough) CallThrough(candidate);
}
Marshal.Release(candidate);
}
else
{
Console.WriteLine($" QI {interfaceName}: no ({Hresult(qi)})");
foreach (var line in Wrap(why)) Console.WriteLine($" {line}");
}
}
}
finally
{
Marshal.Release(unknown);
}
Console.WriteLine();
}
Console.WriteLine(bound
? " RESULT: IWSLCSessionManager IS reachable. D13's internal arm has an entry point —\n"
+ " write it (ListContainers, OpenSessionByName, OpenContainer, ResizeTty)\n"
+ " and record the CLSID that answered in §13.2."
: " RESULT: IWSLCSessionManager is NOT reachable from any known coclass. D13 needs\n"
+ " reopening — see §13.2 for the two alternatives. Re-run with\n"
+ " --internal-call only if a QI above succeeded; it adds nothing here.");
if (!callThrough && bound)
Console.WriteLine(" (pass --internal-call to also CALL through it — confirms the vtable "
+ "matches the IDL, and can crash if it does not)");
}
/// <summary>
/// Prove the vtable is really the IDL's, not just that the IID is recognised. A QI can succeed
/// against an interface whose layout has since moved — `wslc.idl` says outright that breaking
/// changes to it are fine — and the first thing that would tell us is a crash here rather
/// than in the broker.
/// </summary>
private static void CallThrough(IntPtr manager)
{
Console.WriteLine(" calling through (vtable check):");
IWSLCSessionManager? proxy;
try
{
proxy = (IWSLCSessionManager)Marshal.GetObjectForIUnknown(manager);
}
catch (Exception e)
{
Console.WriteLine($" could not build the RCW: {e.GetType().Name}: {e.Message}");
return;
}
try
{
var hr = proxy.GetVersion(out var version);
Console.WriteLine(hr >= 0
? $" GetVersion() = {version.Major}.{version.Minor}.{version.Revision} "
+ "— slot 3 matches the IDL"
: $" GetVersion() failed: {Hresult(hr)}");
// Cross-check against the compat SDK's own GetVersion: the same service answering
// both is what says these are two faces of one object rather than a coincidence.
if (hr >= 0)
Console.WriteLine(" compare with WslcService.GetVersion() above — they "
+ "should agree");
}
catch (Exception e)
{
Console.WriteLine($" GetVersion() threw {e.GetType().Name}: {e.Message}");
return;
}
try
{
var hr = proxy.ListSessions(out var sessions, out var count);
if (hr < 0)
{
Console.WriteLine($" ListSessions() failed: {Hresult(hr)}");
return;
}
Console.WriteLine($" ListSessions() = {count} session(s)");
for (var i = 0; i < count; i++)
{
var entry = Marshal.PtrToStructure<WslcSessionListEntry>(
sessions + i * Marshal.SizeOf<WslcSessionListEntry>());
Console.WriteLine($" #{entry.SessionId} \"{entry.DisplayName}\" "
+ $"(creator pid {entry.CreatorPid})");
}
// The callee allocated it; nobody else will free it.
if (sessions != IntPtr.Zero) Marshal.FreeCoTaskMem(sessions);
Console.WriteLine(" → enumeration and reattach are both reachable from here.");
}
catch (Exception e)
{
Console.WriteLine($" ListSessions() threw {e.GetType().Name}: {e.Message}");
}
}
private static IEnumerable<string> Wrap(string text)
{
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var line = new System.Text.StringBuilder();
foreach (var word in words)
{
if (line.Length + word.Length + 1 > 88 && line.Length > 0)
{
yield return line.ToString();
line.Clear();
}
if (line.Length > 0) line.Append(' ');
line.Append(word);
}
if (line.Length > 0) yield return line.ToString();
}
private static string Hresult(int code) => (uint)code switch
{
0x80040154 => "REGDB_E_CLASSNOTREG — that class is not registered on this machine",
0x80004002 => "E_NOINTERFACE — the class does not implement it",
0x80070005 => "E_ACCESSDENIED — registered, but this token may not activate it",
0x800401F0 => "CO_E_NOTINITIALIZED",
_ => $"0x{code:X8}",
};
// MARK: - Interop
private const uint ClsctxAll = 0x17; // INPROC_SERVER|HANDLER|LOCAL_SERVER|REMOTE_SERVER
private const uint CoinitMultithreaded = 0;
private const uint RpcEChangedMode = 0x80010106;
[DllImport("ole32.dll")]
private static extern int CoInitializeEx(IntPtr reserved, uint coInit);
[DllImport("ole32.dll")]
private static extern int CoCreateInstance(
in Guid clsid, IntPtr outer, uint clsContext, in Guid iid, out IntPtr instance);
[StructLayout(LayoutKind.Sequential)]
private struct WslcVersion
{
public uint Major;
public uint Minor;
public uint Revision;
}
/// Matches `WSLCSessionListEntry` in wslc.idl: two 32-bit fields then two INLINE wide-char
/// buffers (not pointers), which is why these are ByValTStr and why the sizes must be exact.
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct WslcSessionListEntry
{
public uint SessionId;
public uint CreatorPid;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string DisplayName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 257)] public string Sid;
}
/// <summary>
/// Declared only as far as <c>ListSessions</c> — but every preceding method must still be
/// declared with the right parameter count, because a vtable is addressed by slot. The
/// unused ones take <c>IntPtr</c> placeholders for their struct and interface pointers.
/// <c>PreserveSig</c> throughout, so a failing HRESULT is a value to report rather than an
/// exception to decode.
/// </summary>
[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);
}
}
+10
View File
@@ -20,6 +20,10 @@ namespace WslcApiDump;
/// constructor is lazy and always succeeds; `Start()` is where the service is consulted, and it /// constructor is lazy and always succeeds; `Start()` is where the service is consulted, and it
/// refuses with `ERROR_ALREADY_EXISTS`. So reattach needs the internal COM interface, which is /// refuses with `ERROR_ALREADY_EXISTS`. So reattach needs the internal COM interface, which is
/// what D13 assumes (docs/WINDOWS_PORT.md §13.1). /// what D13 assumes (docs/WINDOWS_PORT.md §13.1).
///
/// `--internal` then asks the question D13 itself rests on and nobody has answered: is that
/// internal interface reachable? `wslc.idl` declares no activatable class, so there is no CLSID
/// to name — see <see cref="InternalComProbe"/>. `--internal-call` also calls through it.
/// </summary> /// </summary>
internal static class Program internal static class Program
{ {
@@ -96,6 +100,12 @@ internal static class Program
} }
} }
// Independent of the compat-surface checks above: this asks whether the SERVICE-INTERNAL
// interface can be reached at all, which is what D13's enumeration/reattach/pty arm
// hangs on (docs/WINDOWS_PORT.md §13.2).
if (args.Contains("--internal") || args.Contains("--internal-call"))
InternalComProbe.Run(callThrough: args.Contains("--internal-call"));
Console.WriteLine(); Console.WriteLine();
if (componentsMissing is { Count: > 0 }) if (componentsMissing is { Count: > 0 })
{ {