Files
nucleic-windows/spikes/WslcApiDump/Program.cs
T

591 lines
28 KiB
C#

using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
namespace WslcApiDump;
/// <summary>
/// M1 spike (a), phase 1 (docs/WINDOWS_PORT.md §13): what does Microsoft.WSL.Containers
/// ACTUALLY look like, and where is <c>WslcFacade.cs</c> 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 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).
/// </summary>
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");
}
}
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($" <unreadable: {e.GetType().Name}: {e.Message}>"); }
}
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<string> MemberNames(Type type) =>
type.IsEnum
? Enum.GetNames(type)
: type.GetMembers(Public).Where(m => !m.Name.StartsWith('.')).Select(m => m.Name).Distinct();
/// <summary>Cheap "did they just rename it" hint: shared prefix or containment, no edit
/// distance. A three-name shortlist is enough to spot <c>HostGateway</c> vs
/// <c>HostGatewayAddress</c>, which is the realistic failure mode.</summary>
/// 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<string> 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<string> 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<string>? 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<string>? 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<object?>().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)
{
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;
// 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
{
// Starting it is one more signal: re-adopting a RUNNING session should be a no-op,
// whereas a genuinely separate session would be booting a second VM here.
if (start is not null)
{
try { start.Invoke(second, null); Console.WriteLine(" second Start() ok"); }
catch (TargetInvocationException e)
{
Console.WriteLine($" second Start() threw {Describe(e.InnerException)}");
}
}
Console.WriteLine(" → CONSTRUCTED — the constructor did NOT refuse.");
Console.WriteLine();
// Be honest about what this does and does not show. A constructed object is not proof
// of attachment: construction may simply be lazy, and WSLC_E_SESSION_RESERVED clearly
// fires under SOME condition or it would not exist in wslc.idl. The compat surface has
// no enumeration, so identity cannot be settled from inside this process.
Console.WriteLine(" This does NOT by itself prove it re-adopted the first session —");
Console.WriteLine(" construction may be lazy, and WSLC_E_SESSION_RESERVED exists in");
Console.WriteLine(" wslc.idl, so it fires under some condition. The compat surface has");
Console.WriteLine(" no enumeration, so identity cannot be settled from in here.");
Console.WriteLine();
Console.WriteLine(" DECISIVE CHECK — run this now: wslc session ls");
Console.WriteLine($" ONE session named '{name}' → it attached; the SESSION half of");
Console.WriteLine(" §2.3 reattach may not need the");
Console.WriteLine(" internal COM interface after all.");
Console.WriteLine($" TWO sessions named '{name}' → it did not; two VMs are running and");
Console.WriteLine(" the name is not an identity.");
Console.WriteLine(" Either way the CONTAINER half of D13 is unaffected: there is still");
Console.WriteLine(" no way to enumerate containers on the compat surface.");
}
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
{
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 = $"<threw {Describe(Unwrap(e))}>"; }
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.
var message = string.IsNullOrWhiteSpace(e.Message) ? KnownHResult(code) : e.Message.Trim();
return $"{e.GetType().Name} (0x{code:X8}): {message}";
}
/// 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",
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<object?>().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;
}
}