diff --git a/spikes/README.md b/spikes/README.md index d1aefed..3b65681 100644 --- a/spikes/README.md +++ b/spikes/README.md @@ -41,24 +41,34 @@ It writes the full public object model to `wslc-api-dump.txt` (`--out` to reloca It exits 0 even when assumptions fail — a mismatch is the product, not an error. Only a genuinely broken run (the assembly won't load) exits non-zero. +Every path in it has been exercised against a stand-in assembly carrying the observed 2.9.3 type +and member names, so a failure on your machine is a finding about wslc, not about this tool. + **What to send back:** the console output, and `wslc-api-dump.txt` if anything is MISSING — which now means the package moved under us, not that we guessed wrong. ### Why `--session` earns its risk `--session` creates a real wslc session named `nucleic-spike` under -`%LOCALAPPDATA%\Nucleic\spike\wslc` and leaves it running. It exists for one question that type -metadata cannot answer: **where does the WSL-facing host gateway address come from?** §5 makes -gateway TCP the primary control-plane transport, `WslcContainerEngine.ensureRunning` returns that -address to the Swift side, and `control-bridge.js` dials it. The facade guesses -`Session.HostGatewayAddress`. Dumping the live *values* of every session property lets us -recognise a gateway IP whatever it is called — and if nothing on the session looks like one, -that is itself the finding, and §5's hvsocket fallback gets promoted from upside to dependency. +`%LOCALAPPDATA%\Nucleic\spike\wslc`, starts it, and then **creates a second one with the same +name**. That second construction is the whole point, and it is the last open question D13 turns +on: the compat SDK exposes a `Session` *constructor* and no attach, so does constructing over an +existing name re-adopt it, or refuse? -Leaving the session running is also the cheap version of a §2.3 question: broker supervision -assumes wslc state is **service-backed**, so that a crashed `nucleic-brokerd` can reattach and -re-enumerate rather than orphaning containers. If `wslc session ls` still shows `nucleic-spike` -after this process exits, that assumption holds. Tear it down with `wslc` when you're done. +- **Refused** (expect `WSLC_E_SESSION_RESERVED`, `0x80040607`) — D13's premise is confirmed on + hardware rather than inferred from an IDL, and session reattach genuinely requires + `IWSLCSessionManager::OpenSessionByName` on the internal interface. +- **Constructed** — the compat surface may re-adopt by name, and the session half of §2.3 reattach + may not need the internal interface at all. Check `wslc container ps` for the first session's + containers before believing it. + +It leaves the session running on purpose, because that is the other half of the same question: +broker supervision assumes wslc state is **service-backed**, so a crashed `nucleic-brokerd` can +re-adopt rather than orphaning containers. If `wslc session ls` still shows `nucleic-spike` after +this process exits, that holds. Tear it down with `wslc` when you're done. + +The gateway address is *not* what this probe is for any more — §13.1 established that no API +surfaces one, and D13 moves the control plane to hvsocket via `IWSLCVirtualMachine::GetId`. ### The three answers that change the design diff --git a/spikes/WslcApiDump/Program.cs b/spikes/WslcApiDump/Program.cs index a3700f5..e2ac6aa 100644 --- a/spikes/WslcApiDump/Program.cs +++ b/spikes/WslcApiDump/Program.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Runtime.InteropServices; using System.Text; namespace WslcApiDump; @@ -13,10 +14,11 @@ namespace WslcApiDump; /// cannot fail to build no matter how wrong the assumptions turn out to be. /// /// It never mutates anything unless asked: the default run only reads type metadata. `--probe` -/// additionally calls the two safe statics (service version, missing components). `--session` -/// goes one step further and creates a real session, because the single most important unknown — -/// which property carries the WSL gateway address (§5) — can only be answered by looking at a -/// live one. +/// additionally calls the two safe statics (`GetVersion`, `GetMissingComponents`). `--session` +/// goes further and creates a real session, then creates a SECOND one with the same name — which +/// is the open question D13 turns on: whether the compat SDK's constructor can re-adopt a session +/// by name, or refuses with `WSLC_E_SESSION_RESERVED` and leaves reattach to the internal COM +/// interface (docs/WINDOWS_PORT.md §13.1). /// internal static class Program { @@ -90,21 +92,38 @@ internal static class Program return; } - foreach (var ctor in type.GetConstructors()) - report.AppendLine($" .ctor({Parameters(ctor)})"); - foreach (var property in type.GetProperties(Public).OrderBy(p => p.Name, StringComparer.Ordinal)) - report.AppendLine( - $" {Short(property.PropertyType)} {property.Name} " - + $"{{ {(property.CanRead ? "get; " : "")}{(property.CanWrite ? "set; " : "")}}}"); - foreach (var evt in type.GetEvents(Public).OrderBy(e => e.Name, StringComparer.Ordinal)) - report.AppendLine($" event {Short(evt.EventHandlerType)} {evt.Name}"); - foreach (var method in type.GetMethods(Public) - .Where(m => !m.IsSpecialName) - .OrderBy(m => m.Name, StringComparer.Ordinal)) - report.AppendLine($" {Short(method.ReturnType)} {method.Name}({Parameters(method)})"); + // Every member walk is guarded. These are WinRT projection types: a signature can name a + // type from an assembly that is present at build time and not at runtime, and one such + // member must not cost us the other 60 types' worth of report. + Guarded(report, () => { + foreach (var ctor in type.GetConstructors()) + report.AppendLine($" .ctor({Parameters(ctor)})"); + }); + Guarded(report, () => { + foreach (var property in type.GetProperties(Public).OrderBy(p => p.Name, StringComparer.Ordinal)) + report.AppendLine( + $" {Short(property.PropertyType)} {property.Name} " + + $"{{ {(property.CanRead ? "get; " : "")}{(property.CanWrite ? "set; " : "")}}}"); + }); + Guarded(report, () => { + foreach (var evt in type.GetEvents(Public).OrderBy(e => e.Name, StringComparer.Ordinal)) + report.AppendLine($" event {Short(evt.EventHandlerType)} {evt.Name}"); + }); + Guarded(report, () => { + foreach (var method in type.GetMethods(Public) + .Where(m => !m.IsSpecialName) + .OrderBy(m => m.Name, StringComparer.Ordinal)) + report.AppendLine($" {Short(method.ReturnType)} {method.Name}({Parameters(method)})"); + }); report.AppendLine(); } + private static void Guarded(StringBuilder report, Action body) + { + try { body(); } + catch (Exception e) { report.AppendLine($" "); } + } + private const BindingFlags Public = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; @@ -115,7 +134,8 @@ internal static class Program { if (t is null) return "void"; if (!t.IsGenericType) return t.Name; - var name = t.Name[..t.Name.IndexOf('`')]; + var tick = t.Name.IndexOf('`'); + var name = tick < 0 ? t.Name : t.Name[..tick]; return $"{name}<{string.Join(", ", t.GetGenericArguments().Select(Short))}>"; } @@ -204,7 +224,11 @@ internal static class Program // MARK: - Live probes - /// The two statics that are safe to call on any machine: they only read state. + /// The two service statics, which only read state and are safe on any machine. + /// + /// Names come from the real 2.9.3 surface (`GetVersion`, `GetMissingComponents`) — the + /// documented `GetServiceVersion`/`ComponentFlags` shapes in Microsoft Learn's sample are + /// stale against the shipped package (docs/WINDOWS_PORT.md §13.1). private static void ProbeStatics(Type[] types) { Console.WriteLine(); @@ -212,11 +236,16 @@ internal static class Program var service = types.FirstOrDefault(t => t.Name == "WslcService"); if (service is null) { Console.WriteLine(" no WslcService type — skipping"); return; } - foreach (var name in new[] { "GetServiceVersion", "GetMissingComponents" }) + foreach (var name in new[] { "GetVersion", "GetMissingComponents" }) { var method = service.GetMethods(Public) - .FirstOrDefault(m => m.Name == name && m.GetParameters().Length == 0); - if (method is null) { Console.WriteLine($" {name}: absent"); continue; } + .FirstOrDefault(m => m.IsStatic && m.Name == name && m.GetParameters().Length == 0); + if (method is null) + { + var any = service.GetMethods(Public).Any(m => m.Name == name); + Console.WriteLine($" {name}: {(any ? "present but not static — see the dump" : "absent")}"); + continue; + } try { Console.WriteLine($" {name}() = {Render(method.Invoke(null, null))}"); @@ -225,24 +254,23 @@ internal static class Program { // The interesting failure: WSL not installed. That IS the onboarding condition // §8 step 2 exists to handle, so report it plainly rather than as a crash. - Console.WriteLine($" {name}() threw {e.InnerException?.GetType().Name}: " - + $"{e.InnerException?.Message}"); + Console.WriteLine($" {name}() threw {Describe(e.InnerException)}"); } } + Console.WriteLine(" (a non-empty GetMissingComponents is onboarding work, not a failure — §8)"); } - /// - /// Create a real session and print every readable property of it. + /// Create a real session, then create a SECOND one with the same name. /// - /// This is here for one question: §5's control plane needs the WSL-facing host address, the - /// facade guesses it is Session.HostGatewayAddress, and no amount of type-metadata - /// reading tells us whether that property holds what we need. Dumping the live VALUES of a - /// session lets us recognise a gateway IP when we see one, whatever it happens to be called. + /// The second one is the point. D13 puts session reattach on the internal COM interface + /// (`IWSLCSessionManager::OpenSessionByName`) precisely because the compat SDK exposes only a + /// constructor — but nobody has established what that constructor DOES when the name is + /// already taken. If it attaches, §2.3 broker reattach may not need the internal interface at + /// all for the session half; if it throws `WSLC_E_SESSION_RESERVED` (0x80040607), D13's + /// reasoning is confirmed on hardware rather than inferred from an IDL. /// - /// Leaves the session running on purpose — `wslc session ls` should show it, and whether it - /// survives this process exiting is itself one of the §2.3 questions (service-backed state is - /// what makes broker-crash reattach possible). Tear it down with `wslc` when done. - /// + /// Leaves the session running on purpose: whether it outlives this process is the other half + /// of the same question. Tear it down with `wslc` when you are done. private static void ProbeSession(Type[] types, string name) { Console.WriteLine(); @@ -259,7 +287,41 @@ internal static class Program Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Nucleic", "spike", "wslc"); Directory.CreateDirectory(dataDir); + Console.WriteLine($" storage: {dataDir}"); + var first = CreateSession(settingsType, sessionType, name, dataDir, "first"); + if (first is null) return; + + // Start it — construction alone may not boot the VM (`Session.Start()` is separate). + var start = sessionType.GetMethods(Public) + .FirstOrDefault(m => !m.IsStatic && m.Name == "Start" && m.GetParameters().Length == 0); + if (start is null) { Console.WriteLine(" no Session.Start() — see the dump"); } + else + { + try { start.Invoke(first, null); Console.WriteLine(" Start() ok"); } + catch (TargetInvocationException e) { Console.WriteLine($" Start() threw {Describe(e.InnerException)}"); } + } + + DumpProperties(sessionType, first, " session properties"); + + // THE question (see the doc comment). + Console.WriteLine(); + Console.WriteLine(" second Session with the SAME name — attach, or reserved?"); + var second = CreateSession(settingsType, sessionType, name, dataDir, "second"); + Console.WriteLine(second is not null + ? " → CONSTRUCTED. The compat SDK may attach by name; check `wslc container ps` for " + + "the first session's containers, and record it against D13 (§13.1)." + : " → REFUSED (see the HRESULT above). D13's premise holds: the compat surface cannot " + + "re-adopt a session, so reattach needs IWSLCSessionManager::OpenSessionByName."); + + Console.WriteLine(); + Console.WriteLine(" session left running — `wslc session ls`; whether it outlives this " + + "process is the other half of the §2.3 reattach question."); + } + + private static object? CreateSession( + Type settingsType, Type sessionType, string name, string dataDir, string which) + { object? settings; try { @@ -267,46 +329,53 @@ internal static class Program } catch (Exception e) { - Console.WriteLine($" SessionSettings(name, dataDir) rejected: {e.InnerException?.Message ?? e.Message}"); + Console.WriteLine($" SessionSettings(name, storagePath) rejected: {Describe(Unwrap(e))}"); Console.WriteLine(" → constructor shape differs; see the .ctor lines in the dump file"); - return; + return null; } - - var factory = sessionType.GetMethods(Public) - .FirstOrDefault(m => m.IsStatic && m.GetParameters().Length == 1 - && m.GetParameters()[0].ParameterType == settingsType); - if (factory is null) - { - Console.WriteLine(" no static Session factory taking SessionSettings — see the dump"); - return; - } - Console.WriteLine($" using {sessionType.Name}.{factory.Name}(SessionSettings)"); - - object? live; try { - live = factory.Invoke(null, [settings]); + return Activator.CreateInstance(sessionType, settings); } - catch (TargetInvocationException e) + catch (Exception e) { - Console.WriteLine($" session create threw {e.InnerException?.GetType().Name}: " - + $"{e.InnerException?.Message}"); + Console.WriteLine($" {which} Session(settings) threw {Describe(Unwrap(e))}"); + return null; + } + } + + private static void DumpProperties(Type type, object instance, string heading) + { + var properties = type.GetProperties(Public) + .Where(p => p.CanRead && p.GetIndexParameters().Length == 0) + .OrderBy(p => p.Name, StringComparer.Ordinal) + .ToArray(); + if (properties.Length == 0) + { + Console.WriteLine($"{heading}: none (this type is all methods and events)"); return; } - if (live is null) { Console.WriteLine(" factory returned null"); return; } - - Console.WriteLine(" live session properties (look for the gateway address — §5):"); - foreach (var property in sessionType.GetProperties(Public) - .Where(p => p.CanRead && p.GetIndexParameters().Length == 0) - .OrderBy(p => p.Name, StringComparer.Ordinal)) + Console.WriteLine($"{heading}:"); + foreach (var property in properties) { string rendered; - try { rendered = Render(property.GetValue(live)); } - catch (Exception e) { rendered = $""; } + try { rendered = Render(property.GetValue(instance)); } + catch (Exception e) { rendered = $""; } Console.WriteLine($" {property.Name} = {rendered}"); } - Console.WriteLine(" (session left running — `wslc session ls`; whether it outlives this " - + "process is the §2.3 reattach question)"); + } + + private static Exception? Unwrap(Exception e) => + e is TargetInvocationException { InnerException: { } inner } ? inner : e; + + /// Exceptions from this API are COM HRESULTs, and the number is the useful part: the WSLC + /// codes are documented in `wslc.idl` (0x80040601 image-not-found … 0x80040607 + /// session-reserved … 0x8004060F session-not-found). + private static string Describe(Exception? e) + { + if (e is null) return "an unknown error"; + var code = e is COMException com ? com.HResult : e.HResult; + return $"{e.GetType().Name} (0x{code:X8}): {e.Message}"; } private static string Render(object? value) => value switch diff --git a/spikes/WslcApiDump/WslcApiDump.csproj b/spikes/WslcApiDump/WslcApiDump.csproj index 3f07b1b..52fedfd 100644 --- a/spikes/WslcApiDump/WslcApiDump.csproj +++ b/spikes/WslcApiDump/WslcApiDump.csproj @@ -11,7 +11,14 @@ Exe wslc-api-dump WslcApiDump + net9.0-windows10.0.19041.0 + + LatestMajor