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

327 lines
14 KiB
C#

using System.Reflection;
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 (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.
/// </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";
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 types = assembly.GetExportedTypes().OrderBy(t => t.FullName, StringComparer.Ordinal).ToArray();
Console.WriteLine($"{AssemblyName} {assembly.GetName().Version} — {types.Length} public types");
Console.WriteLine();
var report = new StringBuilder();
report.AppendLine($"# {AssemblyName} {assembly.GetName().Version}");
report.AppendLine($"# location: {assembly.Location}");
report.AppendLine();
foreach (var type in types) DescribeType(type, report);
File.WriteAllText(outPath, report.ToString());
Console.WriteLine($"full API dump → {Path.GetFullPath(outPath)}");
Console.WriteLine();
var missing = CheckAssumptions(types);
if (probe) ProbeStatics(types);
if (session) ProbeSession(types, ArgValue(args, "--session-name") ?? "nucleic-spike");
Console.WriteLine();
Console.WriteLine(missing == 0
? "RESULT: every WslcFacade assumption is present. Fix nothing; write the typed spike."
: $"RESULT: {missing} assumption(s) wrong — each one is a line to change in "
+ "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;
}
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)})");
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 name = t.Name[..t.Name.IndexOf('`')];
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()})");
Console.WriteLine($" why: {assumption.Why}");
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>
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
/// <summary>The two statics that are safe to call on any machine: they only read state.</summary>
private static void 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; }
foreach (var name in new[] { "GetServiceVersion", "GetMissingComponents" })
{
var method = service.GetMethods(Public)
.FirstOrDefault(m => m.Name == name && m.GetParameters().Length == 0);
if (method is null) { Console.WriteLine($" {name}: absent"); continue; }
try
{
Console.WriteLine($" {name}() = {Render(method.Invoke(null, null))}");
}
catch (TargetInvocationException e)
{
// 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}");
}
}
}
/// <summary>
/// Create a real session and print every readable property of it.
///
/// This is here for one question: §5's control plane needs the WSL-facing host address, the
/// facade guesses it is <c>Session.HostGatewayAddress</c>, 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.
///
/// 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.
/// </summary>
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);
object? settings;
try
{
settings = Activator.CreateInstance(settingsType, name, dataDir);
}
catch (Exception e)
{
Console.WriteLine($" SessionSettings(name, dataDir) rejected: {e.InnerException?.Message ?? e.Message}");
Console.WriteLine(" → constructor shape differs; see the .ctor lines in the dump file");
return;
}
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]);
}
catch (TargetInvocationException e)
{
Console.WriteLine($" session create threw {e.InnerException?.GetType().Name}: "
+ $"{e.InnerException?.Message}");
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))
{
string rendered;
try { rendered = Render(property.GetValue(live)); }
catch (Exception e) { rendered = $"<threw {e.InnerException?.GetType().Name ?? e.GetType().Name}>"; }
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 string Render(object? value) => value switch
{
null => "null",
string s => $"\"{s}\"",
System.Collections.IEnumerable e and not string =>
"[" + string.Join(", ", e.Cast<object?>().Select(Render)) + "]",
_ => value.ToString() ?? "?",
};
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;
}
}