using System.Reflection; using System.Runtime.InteropServices; using System.Text; namespace WslcApiDump; /// /// M1 spike (a), phase 1 (docs/WINDOWS_PORT.md §13): what does Microsoft.WSL.Containers /// ACTUALLY look like, and where is WslcFacade.cs wrong? /// /// Everything the Windows container subsystem rests on — items 5, 6 and 7 — was written against /// documentation, on a machine with no WSL. This program is the cheapest possible way to convert /// that pile of assumptions into a worklist, and it is written entirely in reflection so that it /// 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 (`GetVersion`, `GetMissingComponents`). `--session` /// goes further and creates a real session, then a SECOND one with the same name, to settle /// whether the compat SDK can re-adopt a running session. Answered on 2.9.4: it cannot. The /// 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 /// 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 . `--internal-call` also calls through it. /// internal static class Program { /// The C#/WinRT PROJECTION assembly. The NuGet package is "Microsoft.WSL.Containers" but /// the managed assembly it ships is `lib/net8.0-windows10.0.19041.0/wslcsdkcs.dll`, so /// loading it by package name fails — which is exactly the first thing this tool found. private const string AssemblyName = "wslcsdkcs"; /// The API namespace. Filtering on it is not tidiness — it is correctness. A C#/WinRT /// projection also exports `ABI.Microsoft.WSL.Containers.*` marshalling plumbing whose types /// have the SAME short names (`Session`, `Container`, `ProcessSettings`), and `ABI.` sorts /// first. Matching assumptions by short name therefore checked every member against the /// marshalling struct and reported 42 false MISSINGs, with `CreateMarshaler` offered as the /// nearest name — which is the tell. private const string ApiNamespace = "Microsoft.WSL.Containers"; private static int Main(string[] args) { var probe = args.Contains("--probe") || args.Contains("--session"); var session = args.Contains("--session"); var outPath = ArgValue(args, "--out") ?? "wslc-api-dump.txt"; Assembly assembly; try { assembly = Assembly.Load(new AssemblyName(AssemblyName)); } catch (Exception e) { Console.Error.WriteLine($"could not load {AssemblyName}: {e.Message}"); Console.Error.WriteLine( "Is the preview NuGet restored? `dotnet restore windows/spikes/WslcApiDump`."); return 2; } var exported = assembly.GetExportedTypes().OrderBy(t => t.FullName, StringComparer.Ordinal).ToArray(); var types = exported.Where(t => t.Namespace == ApiNamespace).ToArray(); Console.WriteLine( $"{AssemblyName} {assembly.GetName().Version} — {types.Length} types in {ApiNamespace} " + $"({exported.Length - types.Length} more are ABI/marshalling plumbing)"); Console.WriteLine(); var report = new StringBuilder(); report.AppendLine($"# {AssemblyName} {assembly.GetName().Version}"); report.AppendLine($"# location: {assembly.Location}"); report.AppendLine(); // The dump defaults to the API namespace for the same reason the checks do; `--all-types` // includes the ABI plumbing for when the projection itself is what's being debugged. foreach (var type in args.Contains("--all-types") ? exported : types) DescribeType(type, report); File.WriteAllText(outPath, report.ToString()); Console.WriteLine($"full API dump → {Path.GetFullPath(outPath)}"); Console.WriteLine(); var missing = CheckAssumptions(types); // Components are probed BEFORE anything else that touches the service, because that one // call explains every other failure: nothing installed answers REGDB_E_CLASSNOTREG // (0x80040154), while an installed-but-too-old WSL answers ERROR_NOT_SUPPORTED // (0x80070032). Reporting those as unexplained COM errors buries the actual finding. var componentsMissing = probe ? ProbeStatics(types) : null; if (session) { if (componentsMissing is { Count: > 0 }) { Console.WriteLine(); Console.WriteLine("skipping --session: the components above are missing, so creating " + "a session would only fail the same way. Fix those first."); } else { ProbeSession( types, ArgValue(args, "--session-name") ?? "nucleic-spike", keep: args.Contains("--keep")); } } // 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"), // Shares --session's name so `--session --keep --internal-call` creates a session // and then re-adopts it through the internal interface in one run — which is the // only way to test the handoff, and the whole §2.3 reattach story in miniature. sessionName: args.Contains("--session") || args.Contains("--session-name") ? ArgValue(args, "--session-name") ?? "nucleic-spike" : null); Console.WriteLine(); if (componentsMissing is { Count: > 0 }) { Console.WriteLine($"RESULT: this machine cannot run wslc yet — missing " + $"{string.Join(", ", componentsMissing)}."); foreach (var component in componentsMissing) Console.WriteLine($" {component}: {Remedy(component)}"); Console.WriteLine(" Then run this again. This is the §8 onboarding condition, " + "not a defect."); } else if (missing == 0) { Console.WriteLine("RESULT: every WslcFacade assumption is present — the package matches " + "the surface recorded in docs/WINDOWS_PORT.md §13.1."); } else { Console.WriteLine($"RESULT: {missing} assumption(s) wrong. The recorded surface is 2.9.3, " + "so this means the package MOVED: reconcile §13.1, then fix " + "windows/NucleicBroker/Wslc/WslcFacade.cs — and nowhere else (that is what IWslc " + "is for)."); } // Exit 0 either way: a mismatch is this tool's PRODUCT, not its failure. Only a genuinely // broken run (assembly missing) is non-zero, so a wrapper script can tell them apart. return 0; } // MARK: - Type dump private static void DescribeType(Type type, StringBuilder report) { var kind = type.IsEnum ? "enum" : type.IsInterface ? "interface" : type.IsValueType ? "struct" : "class"; report.AppendLine($"{kind} {type.FullName}" + (type.BaseType is { } b && b != typeof(object) ? $" : {b.Name}" : "")); if (type.IsEnum) { foreach (var name in Enum.GetNames(type)) report.AppendLine($" .{name}"); report.AppendLine(); return; } // 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; private static string Parameters(MethodBase m) => string.Join(", ", m.GetParameters().Select(p => $"{Short(p.ParameterType)} {p.Name}")); private static string Short(Type? t) { if (t is null) return "void"; if (!t.IsGenericType) return t.Name; var tick = t.Name.IndexOf('`'); var name = tick < 0 ? t.Name : t.Name[..tick]; return $"{name}<{string.Join(", ", t.GetGenericArguments().Select(Short))}>"; } // MARK: - Assumption check (the actual product) private static int CheckAssumptions(Type[] types) { Console.WriteLine("WslcFacade.cs assumptions:"); Console.WriteLine(); var wrong = 0; foreach (var group in FacadeAssumptions.All.GroupBy(a => a.Type)) { var type = types.FirstOrDefault(t => t.Name == group.Key); if (type is null) { // Summarise rather than repeat: if a whole type is renamed or namespaced away, // one line naming it beats a paragraph per member. The per-member "why" only // earns its space when the type exists and a single member is wrong. wrong += group.Count(); Console.WriteLine($" MISSING TYPE {group.Key} " + $"({group.Count()} member(s): {string.Join(", ", group.Select(a => a.Member))})"); var near = Nearest(group.Key, types.Select(t => t.Name)); Console.WriteLine(near.Length > 0 ? $" nearest types: {string.Join(", ", near)}" : " no similarly-named type — check the dump file's namespaces"); continue; } foreach (var assumption in group) { if (assumption.MemberKind is FacadeAssumptions.Kind.Type || Has(type, assumption)) { Console.WriteLine($" ok {type.Name}.{assumption.Member}"); continue; } wrong++; Console.WriteLine($" MISSING {type.Name}.{assumption.Member} " + $"({assumption.MemberKind.ToString().ToLowerInvariant()})"); foreach (var line in Wrap(assumption.Why, 94)) Console.WriteLine($" {line}"); var near = Nearest(assumption.Member, MemberNames(type)); if (near.Length > 0) Console.WriteLine($" nearest: {string.Join(", ", near)}"); } } return wrong; } private static bool Has(Type type, FacadeAssumptions.Assumption a) => a.MemberKind switch { FacadeAssumptions.Kind.Constructor => type.GetConstructors().Length > 0, FacadeAssumptions.Kind.EnumValue => type.IsEnum && Enum.GetNames(type).Contains(a.Member), FacadeAssumptions.Kind.Event => type.GetEvent(a.Member) is not null, FacadeAssumptions.Kind.Property => type.GetProperty(a.Member) is not null || type.GetField(a.Member) is not null, FacadeAssumptions.Kind.Method => type.GetMethods(Public).Any(m => m.Name == a.Member), _ => true, }; private static IEnumerable MemberNames(Type type) => type.IsEnum ? Enum.GetNames(type) : type.GetMembers(Public).Where(m => !m.Name.StartsWith('.')).Select(m => m.Name).Distinct(); /// Cheap "did they just rename it" hint: shared prefix or containment, no edit /// distance. A three-name shortlist is enough to spot HostGateway vs /// HostGatewayAddress, which is the realistic failure mode. /// Hard-wrap so a long rationale can't be mangled into an unreadable fragment by the /// console. First line is prefixed "why:", continuations are indented under it. private static IEnumerable Wrap(string text, int width) { var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); var line = new StringBuilder("why: "); var any = false; foreach (var word in words) { if (line.Length + word.Length + 1 > width && any) { yield return line.ToString(); line = new StringBuilder(" "); any = false; } if (any) line.Append(' '); line.Append(word); any = true; } if (any) yield return line.ToString(); } private static string[] Nearest(string wanted, IEnumerable candidates) { var needle = wanted.TrimStart('.'); if (needle.Length == 0) return []; return candidates .Where(c => c.Contains(needle, StringComparison.OrdinalIgnoreCase) || needle.Contains(c, StringComparison.OrdinalIgnoreCase) || SharedPrefix(c, needle) >= 4) .Distinct() .Take(3) .ToArray(); } private static int SharedPrefix(string a, string b) { var n = 0; while (n < a.Length && n < b.Length && char.ToLowerInvariant(a[n]) == char.ToLowerInvariant(b[n])) n++; return n; } // MARK: - Live probes /// 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 IReadOnlyList? ProbeStatics(Type[] types) { Console.WriteLine(); Console.WriteLine("live probe (read-only):"); var service = types.FirstOrDefault(t => t.Name == "WslcService"); if (service is null) { Console.WriteLine(" no WslcService type — skipping"); return null; } List? componentsMissing = null; // GetMissingComponents first: it answers from OS feature state and works even when the // service class isn't registered, so it is the call that EXPLAINS the others. foreach (var name in new[] { "GetMissingComponents", "GetVersion" }) { var method = service.GetMethods(Public) .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 { var value = method.Invoke(null, null); Console.WriteLine($" {name}() = {Render(value)}"); if (name == "GetMissingComponents" && value is System.Collections.IEnumerable list) { componentsMissing = list.Cast().Select(v => v?.ToString() ?? "?").ToList(); if (componentsMissing.Count > 0) Console.WriteLine(" → wslc is not usable here yet. Calls that reach the " + "service will fail (0x80040154 when nothing is installed, " + "0x80070032 when WSL is present but too old)."); } } catch (TargetInvocationException e) { var inner = e.InnerException; // Any COM failure is expected while components are missing — pinning it to one // code was wrong: a too-old WSL answers ERROR_NOT_SUPPORTED, not CLASSNOTREG. var expected = componentsMissing is { Count: > 0 } && inner is COMException; Console.WriteLine($" {name}() threw {Describe(inner)}" + (expected ? " ← expected: the components above are missing" : "")); } } return componentsMissing; } /// Create a real session, then create a SECOND one with the same name. /// /// 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: 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, bool keep) { Console.WriteLine(); Console.WriteLine($"live probe (creates session '{name}'):"); var settingsType = types.FirstOrDefault(t => t.Name == "SessionSettings"); var sessionType = types.FirstOrDefault(t => t.Name == "Session"); if (settingsType is null || sessionType is null) { Console.WriteLine(" SessionSettings/Session absent — skipping"); return; } var dataDir = Path.Combine( 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; // Hand the live SDK-created session to the internal probe: comparing ITS underlying COM // identity against the one OpenSessionByName returns is what settles whether the WinRT // projection is the same object or a client-side wrapper (InternalComProbe). InternalComProbe.sdkSession = first; // 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"); if (second is null) { Console.WriteLine(" → REFUSED (see the HRESULT above). D13's premise holds: the compat " + "surface cannot re-adopt a session, so reattach needs " + "IWSLCSessionManager::OpenSessionByName."); } else { // The constructor is LAZY — it only captures settings, so its success says nothing. // `Start()` is where the service is actually asked for the session, and therefore // where exclusivity is enforced. Judge on that. var secondStarted = false; string? startFailure = null; if (start is null) { Console.WriteLine(" (no Session.Start() — cannot judge)"); } else { try { start.Invoke(second, null); secondStarted = true; } catch (TargetInvocationException e) { startFailure = Describe(e.InnerException); } } if (secondStarted) { Console.WriteLine(" second Start() ok — it did NOT refuse, so it may have re-adopted"); IdentityTest(sessionType, first, second); } else if (startFailure is not null) { Console.WriteLine($" second Start() threw {startFailure}"); Console.WriteLine(); Console.WriteLine(" → REFUSED at Start(). The constructor succeeding meant nothing:"); Console.WriteLine(" it is lazy, and the service is only consulted by Start()."); Console.WriteLine(" So the compat SDK CANNOT re-adopt a running session, and D13"); Console.WriteLine(" holds — §2.3 reattach needs IWSLCSessionManager::OpenSessionByName"); Console.WriteLine(" (or EnterSession) on the internal COM interface."); Console.WriteLine(" Note the code is ERROR_ALREADY_EXISTS, not WSLC_E_SESSION_RESERVED;"); Console.WriteLine(" the reserved code evidently means something narrower."); } } Console.WriteLine(); if (keep) { Console.WriteLine(" --keep: session(s) left running, so you can check whether they " + "outlive this process (the other half of the §2.3 question)."); Console.WriteLine(" Tear down with `wsl --shutdown` — note that `wslc.exe` is NOT on " + "PATH by default; it lives beside wsl.exe (try `C:\\Program Files\\WSL\\wslc.exe`)."); } else { Terminate(sessionType, first, "first"); Console.WriteLine(" (pass --keep to leave sessions running instead)"); } } /// Decide whether the second same-named `Session` re-adopted the first, using ONLY the compat /// SDK — no CLI, which matters because `wslc.exe` is not on PATH by default. /// /// The trick: terminate the FIRST session and see whether the SECOND dies with it. A read that /// worked before termination and fails after is the same underlying session answering; a read /// that keeps working means two independent sessions were running all along. `GetImages` is /// the cheapest read on the surface and mutates nothing. private static void IdentityTest(Type sessionType, object first, object second) { var getImages = sessionType.GetMethods(Public) .FirstOrDefault(m => !m.IsStatic && m.Name == "GetImages" && m.GetParameters().Length == 0); if (getImages is null) { Console.WriteLine(" (no GetImages — cannot test identity)"); return; } Console.WriteLine(); Console.WriteLine(" identity test: terminate the FIRST, then read from the SECOND"); if (!Reads(getImages, second, out var before)) { // Only reachable if Start() reported success but the session is unusable anyway. Console.WriteLine($" inconclusive — the second session reads fail already: {before}"); return; } Console.WriteLine(" second reads ok before termination"); if (!Terminate(sessionType, first, "first")) return; // The service tears a VM down asynchronously; give it a moment before concluding. Thread.Sleep(3000); if (Reads(getImages, second, out var after)) { Console.WriteLine(" second STILL reads ok after the first was terminated"); Console.WriteLine(" → SEPARATE SESSIONS. The name is not an identity: two VMs were " + "running. D13 stands — reattach needs IWSLCSessionManager::OpenSessionByName."); } else { Console.WriteLine($" second now fails: {after}"); Console.WriteLine(" → SAME SESSION. Constructing over an existing name RE-ADOPTS it, " + "so the session half of §2.3 reattach is reachable from the compat surface and " + "D13's reattach row softens (the container half is unaffected — still no " + "enumeration)."); } } private static bool Reads(MethodInfo getImages, object session, out string detail) { try { getImages.Invoke(session, null); detail = "ok"; return true; } catch (TargetInvocationException e) { detail = Describe(e.InnerException); return false; } catch (Exception e) { detail = Describe(e); return false; } } private static bool Terminate(Type sessionType, object session, string which) { var terminate = sessionType.GetMethods(Public) .FirstOrDefault(m => !m.IsStatic && m.Name == "Terminate" && m.GetParameters().Length == 0); if (terminate is null) { Console.WriteLine(" (no Session.Terminate)"); return false; } try { terminate.Invoke(session, null); Console.WriteLine($" {which} session terminated"); return true; } catch (TargetInvocationException e) { Console.WriteLine($" {which} Terminate() threw {Describe(e.InnerException)}"); return false; } } private static object? CreateSession( Type settingsType, Type sessionType, string name, string dataDir, string which) { object? settings; try { settings = Activator.CreateInstance(settingsType, name, dataDir); } catch (Exception e) { Console.WriteLine($" SessionSettings(name, storagePath) rejected: {Describe(Unwrap(e))}"); Console.WriteLine(" → constructor shape differs; see the .ctor lines in the dump file"); return null; } try { return Activator.CreateInstance(sessionType, settings); } catch (Exception e) { 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; } Console.WriteLine($"{heading}:"); foreach (var property in properties) { string rendered; try { rendered = Render(property.GetValue(instance)); } catch (Exception e) { rendered = $""; } Console.WriteLine($" {property.Name} = {rendered}"); } } 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; // COM messages from this API are often empty, which leaves a bare hex code and no clue — // and when they are NOT empty they tend to arrive twice, because a WinRT failure carries // the text in both the restricted-error-info and the HRESULT message. Collapse both cases. var message = string.IsNullOrWhiteSpace(e.Message) ? KnownHResult(code) : OneLine(e.Message); return $"{e.GetType().Name} (0x{code:X8}): {message}"; } /// Fold a multi-line, often-duplicated exception message onto one line. private static string OneLine(string message) => string.Join(" — ", message .Split('\r', '\n') .Select(line => line.Trim()) .Where(line => line.Length > 0) .Distinct()); /// The codes actually seen coming out of wslc, decoded. The WSLC_E_* range is documented in /// `wslc.idl`; the other two are ordinary Windows errors that mean very different things and /// are easy to confuse — "not registered" is *nothing installed*, "not supported" is /// *installed but too old*, which is a completely different fix. private static string KnownHResult(int code) => (uint)code switch { 0x80040154 => "REGDB_E_CLASSNOTREG — the WSLC service class is not registered " + "(nothing to talk to)", 0x80070032 => "ERROR_NOT_SUPPORTED — the installed WSL does not implement this call " + "(almost always: WSL is older than the SDK)", 0x80070005 => "E_ACCESSDENIED", 0x800700B7 => "ERROR_ALREADY_EXISTS — a session of that name is already running", 0x8000000E => "E_ILLEGAL_METHOD_CALL — the object is not in a state to serve this call " + "(e.g. Session.Start() was never called, or failed)", 0x80040601 => "WSLC_E_IMAGE_NOT_FOUND", 0x80040603 => "WSLC_E_CONTAINER_NOT_FOUND", 0x80040605 => "WSLC_E_CONTAINER_NOT_RUNNING", 0x80040607 => "WSLC_E_SESSION_RESERVED — that session name is already taken", 0x80040608 => "WSLC_E_INVALID_SESSION_NAME", 0x8004060B => "WSLC_E_SDK_UPDATE_NEEDED", 0x8004060C => "WSLC_E_CONTAINER_DISABLED", 0x8004060F => "WSLC_E_SESSION_NOT_FOUND", _ => "no message", }; /// What to actually DO about each missing component. These are not interchangeable, and the /// difference cost a round trip: `wsl --install` fixes VirtualMachinePlatform and does nothing /// for WslPackage, which needs an *update* — and specifically a pre-release one, because the /// SDK's 2.9.3 is ahead of the Store channel. private static string Remedy(string component) => component switch { "VirtualMachinePlatform" => "`wsl --install`, then REBOOT (this is an OS optional feature)", "WslPackage" => "`wsl --update --pre-release` then `wsl --shutdown` — WSL is installed but older than " + "the SDK. 2.9.3 is pre-release-only, so a plain `wsl --update` will NOT get there. " + "Confirm with `wsl --version` (need >= 2.9.3).", "SdkNeedsUpdate" => "the Microsoft.WSL.Containers pin is NEWER than the installed service — either update " + "WSL further or pin the package back", _ => "see `wsl --help`", }; private static string Render(object? value) => Render(value, depth: 0); /// Render a value for the console. /// /// The wrinkle worth knowing: a WinRT projection class does NOT override `ToString()`, so the /// default gives you its type name and nothing else — `GetVersion()` printed /// "Microsoft.WSL.Containers.ServiceVersion" instead of the version it had just fetched. When /// `ToString()` is that unhelpful, dump the readable properties instead. `depth` bounds the /// recursion, since a projection object graph can be cyclic. private static string Render(object? value, int depth) => value switch { null => "null", string s => $"\"{s}\"", System.Collections.IEnumerable e and not string => "[" + string.Join(", ", e.Cast().Select(v => Render(v, depth + 1))) + "]", _ => Structured(value, depth), }; private static string Structured(object value, int depth) { var type = value.GetType(); var text = value.ToString(); // A meaningful ToString() is one that isn't just the type's own name. if (!string.IsNullOrEmpty(text) && text != type.FullName && text != type.Name) return text; if (depth >= 2) return type.Name; PropertyInfo[] properties; try { properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.CanRead && p.GetIndexParameters().Length == 0) .OrderBy(p => p.Name, StringComparer.Ordinal) .ToArray(); } catch { return type.Name; } if (properties.Length == 0) return text ?? type.Name; var parts = properties.Select(p => { try { return $"{p.Name}={Render(p.GetValue(value), depth + 1)}"; } catch (Exception e) { return $"{p.Name}=<{Unwrap(e)?.GetType().Name}>"; } }); return $"{type.Name} {{ {string.Join(", ", parts)} }}"; } private static string? ArgValue(string[] args, string flag) { var i = Array.IndexOf(args, flag); return i >= 0 && i + 1 < args.Length && !args[i + 1].StartsWith("--") ? args[i + 1] : null; } }