Merge nucleic/olive-jade-civet-rznt into dev

This commit is contained in:
2026-07-29 00:40:38 -07:00
parent 6d06dcd4d0
commit dcb9f428bb
3 changed files with 158 additions and 72 deletions
+21 -11
View File
@@ -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
+130 -61
View File
@@ -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).
/// </summary>
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($" <unreadable: {e.GetType().Name}: {e.Message}>"); }
}
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
/// <summary>The two statics that are safe to call on any machine: they only read state.</summary>
/// 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)");
}
/// <summary>
/// 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 <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.
/// 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.
/// </summary>
/// 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 = $"<threw {e.InnerException?.GetType().Name ?? e.GetType().Name}>"; }
try { rendered = Render(property.GetValue(instance)); }
catch (Exception e) { rendered = $"<threw {Describe(Unwrap(e))}>"; }
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
+7
View File
@@ -11,7 +11,14 @@
<OutputType>Exe</OutputType>
<AssemblyName>wslc-api-dump</AssemblyName>
<RootNamespace>WslcApiDump</RootNamespace>
<!-- Windows SDK 19041 is not optional: it is the TFM the package ships for, and it is what
makes the SDK reference Microsoft.Windows.SDK.NET.Ref, which supplies the WinRT.Runtime
this projection needs. The package declares no dependencies, so nothing else pulls it. -->
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
<!-- The dev box has the .NET 10 SDK and may have no .NET 9 runtime; without this a built
net9.0 app fails to launch with "framework not found". A spike must not make anyone
install a runtime to answer one question. -->
<RollForward>LatestMajor</RollForward>
</PropertyGroup>
<ItemGroup>