diff --git a/spikes/README.md b/spikes/README.md new file mode 100644 index 0000000..b76c6cc --- /dev/null +++ b/spikes/README.md @@ -0,0 +1,77 @@ +# M1 spikes (docs/WINDOWS_PORT.md §13) + +Programs that answer questions the port is currently *guessing* at. They are checked in and +runnable on purpose: a spike whose answer nobody can reproduce six months later is a rumour. + +They are deliberately **not** in `windows/Nucleic.sln`. The solution's broker contract tests must +keep running on any machine against `FakeWslc`; these need the `Microsoft.WSL.Containers` preview +NuGet and a real WSL stack, so they are built by path. + +--- + +## `WslcApiDump` — what does the wslc API actually look like? + +**The problem it solves.** Items 5, 6 and 7 — the C# broker, the Swift wslc client, and the +control plane — were all built against Microsoft's *documentation*, on machines with no WSL. +`NucleicBroker/Wslc/WslcFacade.cs` is therefore a careful transcription that has never been +compiled against the real assembly, and `-p:UseWslc=true` is the only thing standing between it +and the build. Every line in it might be right. We have no idea which. + +A typed spike cannot tell us: if `Session.CreateOrOpen` is really `Session.Open`, the spike fails +to **compile**, which teaches us one name and stops. So this tool is written entirely in +reflection. It cannot fail to build, and a single run prints the complete list of what is wrong. + +```powershell +cd windows/spikes/WslcApiDump +dotnet run # dump the API + check every facade assumption +dotnet run -- --probe # + call the two read-only statics (service version, missing components) +dotnet run -- --session # + create a real session and print its live property VALUES +``` + +It writes the full public object model to `wslc-api-dump.txt` (`--out` to relocate) and prints an +`ok` / `MISSING` line per assumption, each with *why that member matters* and a nearest-name hint. +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. + +**What to send back:** the console output, and `wslc-api-dump.txt` if anything is MISSING. + +### 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. + +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. + +### The three answers that change the design + +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: + +| Finding | Consequence | +| --- | --- | +| No create-or-attach on `Session` | Broker-crash reattach (§2.3) has no mechanism; supervision needs redesigning before item 11. | +| No gateway address on a live `Session` | §5's primary transport loses its source; promote the AF_HYPERV/AF_VSOCK fallback and spike that instead. | +| 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). | + +--- + +## Not yet written + +- **`WslcSpike`** — the typed happy path: session → GHCR pull of `naros-agent` → container with an + NTFS `ContainerVolume` → `exec git status` in the bind-mounted worktree → stdio round-trip → + SIGTERM, plus the 9P latency numbers §15 wants (`git status` and `npm install` on a real repo, + 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 + exists to avoid. +- **`HvSocketSpike`** — M1 (b): AF_HYPERV host listener ↔ AF_VSOCK dial from inside a wslc + container, plus gateway-TCP reachability and default-firewall behaviour in NAT and mirrored + modes. Only worth building once a container can be started at all. diff --git a/spikes/WslcApiDump/FacadeAssumptions.cs b/spikes/WslcApiDump/FacadeAssumptions.cs new file mode 100644 index 0000000..462aba7 --- /dev/null +++ b/spikes/WslcApiDump/FacadeAssumptions.cs @@ -0,0 +1,105 @@ +namespace WslcApiDump; + +/// +/// Every member NucleicBroker/Wslc/WslcFacade.cs calls on the preview +/// Microsoft.WSL.Containers API, as plain strings. +/// +/// The facade was written from Microsoft's documentation without a machine to run it on +/// (docs/WINDOWS_PORT.md §1.5), so each line here is a *claim* — and this spike's job is to say, +/// for each one, whether the shipped assembly agrees. Strings rather than typed references is the +/// entire trick: a typed spike that names Session.CreateOrOpen fails to COMPILE if that +/// method has a different name, which teaches us nothing. Reflection turns every wrong guess into +/// a printed line instead of a build error, so one run produces the complete worklist. +/// +/// Keep in lockstep with WslcFacade.cs. A member that stops being used should leave this list; +/// a newly-used member should join it, so the next run of this tool still covers the real surface. +/// +internal static class FacadeAssumptions +{ + internal sealed record Assumption(string Type, string Member, Kind MemberKind, string Why); + + internal enum Kind { Method, Property, Event, Constructor, EnumValue, Type } + + internal static readonly Assumption[] All = + [ + // ---- Service entry point: onboarding (§8 step 2) ---- + new("WslcService", "GetServiceVersion", Kind.Method, + "hello capabilities — hostd degrades across preview→GA churn on this string"), + new("WslcService", "GetMissingComponents", Kind.Method, + "components.missing RPC; drives the guided-install onboarding page"), + new("WslcService", "InstallComponentsAsync", Kind.Method, + "components.install RPC"), + new("ComponentFlags", "None", Kind.EnumValue, + "the 'nothing missing' sentinel the facade filters on"), + + // ---- Session: one per channel, hosts every container (§3.2) ---- + new("SessionSettings", ".ctor", Kind.Constructor, + "SessionSettings(name, dataDir) — the two-arg shape the facade assumes"), + new("SessionSettings", "CpuCount", Kind.Property, "session.ensure cpu"), + new("SessionSettings", "MemoryMB", Kind.Property, + "session.ensure memoryMB; §3.2 notes a resize needs a sandbox restart"), + new("Session", "CreateOrOpen", Kind.Method, + "THE create-or-attach primitive. If this is absent, broker reattach after a crash " + + "(§2.3) has no mechanism and the whole supervision design changes"), + new("Session", "Terminate", Kind.Method, "session.terminate RPC"), + new("Session", "SessionTerminationHandler", Kind.Property, + "session.down notification → hostd's reconcile sweep"), + new("Session", "HostGatewayAddress", Kind.Property, + "THE control-plane address (§5). ensureRunning returns it to the Swift engine and " + + "control-bridge.js dials it. If this member does not exist, §5's primary transport " + + "needs another source (query the vNIC) or the hvsocket fallback gets promoted"), + + // ---- Images ---- + new("Session", "PullImageAsync", Kind.Method, "image.pull RPC (naros-agent from GHCR)"), + new("PullImageOptions", ".ctor", Kind.Constructor, "PullImageOptions(reference)"), + new("PullImageOptions", "Credentials", Kind.Property, "GHCR auth (registryAuth)"), + new("PullImageOptions", "Progress", Kind.Event, + "image.pullProgress → the existing controlDownloadProgress UI surface"), + new("RegistryCredentials", ".ctor", Kind.Constructor, "RegistryCredentials(user, password)"), + new("Session", "GetImages", Kind.Method, "image.list / image.inspect"), + new("Session", "DeleteImage", Kind.Method, "image.delete"), + + // ---- Containers ---- + new("Session", "CreateContainer", Kind.Method, "container.create"), + new("Session", "GetContainers", Kind.Method, "container.list; reattach re-enumeration"), + new("ContainerSettings", ".ctor", Kind.Constructor, "ContainerSettings(image)"), + new("ContainerSettings", "Name", Kind.Property, "channel-suffixed container naming"), + new("ContainerSettings", "Hostname", Kind.Property, "container.create hostname"), + new("ContainerSettings", "NetworkingMode", Kind.Property, + "NAT vs mirrored (§5 item 4) — determines how the guest reaches the host"), + new("ContainerSettings", "Volumes", Kind.Property, "the NTFS worktree bind mount (D8)"), + new("ContainerSettings", "InitProcess", Kind.Property, "naros-init as PID 1 (docs/NAROS.md)"), + new("ContainerVolume", ".ctor", Kind.Constructor, + "ContainerVolume(hostPath, guestPath, readOnly) — the 3-arg shape"), + new("ContainerNetworkingMode", "", Kind.Type, "enum parsed from the RPC's networkingMode"), + new("Container", "Start", Kind.Method, "container.start"), + new("Container", "Stop", Kind.Method, "container.stop(signal, grace)"), + new("Container", "Delete", Kind.Method, "container.delete"), + new("Container", "State", Kind.Property, "container.state → running/stopped/absent"), + new("Container", "GetStatistics", Kind.Method, + "container.stats → ContainerResourceSample; the Swift engine folds deltas from it"), + new("Container", "RunProcess", Kind.Method, "proc.exec — the agent's own exec path"), + new("ContainerState", "Running", Kind.EnumValue, "the one state the facade tests by name"), + new("DeleteContainerFlags", "Force", Kind.EnumValue, "container.delete force"), + + // ---- Processes: the agent stdio path (§3.2 WslcProcessHandle) ---- + new("ProcessSettings", "CmdLine", Kind.Property, "argv"), + new("ProcessSettings", "Environment", Kind.Property, "env"), + new("ProcessSettings", "WorkingDirectory", Kind.Property, "cwd"), + new("ProcessSettings", "OutputMode", Kind.Property, "event-mode stdio, not polling"), + new("ProcessSettings", "Terminal", Kind.Property, "tty for the Terminal panel (§7)"), + new("ProcessSettings", "UserId", Kind.Property, + "runAsUID 501 (§3.2). If absent, exec falls back to a setpriv/su wrapper argv — " + + "interceptors and nash don't care about the numeric uid, so this is recoverable"), + new("ProcessSettings", "GroupId", Kind.Property, "runAsGID"), + new("ProcessOutputMode", "Event", Kind.EnumValue, + "the mode that makes stdio push-based; polling would change the whole broker design"), + new("Signal", "", Kind.Type, "the enum Stop/Signal take; the RPC carries POSIX ints"), + new("Process", "OutputReceived", Kind.Event, "→ proc.stdout / proc.stderr notifications"), + new("Process", "Exited", Kind.Event, "→ proc.exit; must never overtake output (OutboundWriter)"), + new("Process", "WriteStdin", Kind.Method, "proc.stdin (NDJSON to the agent)"), + new("Process", "CloseStdin", Kind.Method, "proc.closeStdin"), + new("Process", "Signal", Kind.Method, "proc.signal — the agent's real POSIX Stop path"), + new("Process", "ResizeTerminal", Kind.Method, "proc.resize (tty mode)"), + ]; +} diff --git a/spikes/WslcApiDump/Program.cs b/spikes/WslcApiDump/Program.cs new file mode 100644 index 0000000..1ec99bc --- /dev/null +++ b/spikes/WslcApiDump/Program.cs @@ -0,0 +1,323 @@ +using System.Reflection; +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 (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. +/// +internal static class Program +{ + private const string AssemblyName = "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 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 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. + 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 statics that are safe to call on any machine: they only read state. + 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}"); + } + } + } + + /// + /// 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 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. + /// + /// 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. + /// + 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 = $""; } + 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().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; + } +} diff --git a/spikes/WslcApiDump/WslcApiDump.csproj b/spikes/WslcApiDump/WslcApiDump.csproj new file mode 100644 index 0000000..6d090be --- /dev/null +++ b/spikes/WslcApiDump/WslcApiDump.csproj @@ -0,0 +1,24 @@ + + + + + Exe + wslc-api-dump + WslcApiDump + net9.0-windows10.0.26100.0 + + + + + + + +