Merge nucleic/lucid-river-toad-6efj into dev

This commit is contained in:
2026-07-29 04:18:06 -07:00
parent 0f720d8328
commit 3f1f4c7f04
4 changed files with 604 additions and 7 deletions
+49 -7
View File
@@ -172,14 +172,56 @@ for, and nothing above it should move. These three are different:
--- ---
## Not yet written ---
- **`WslcSpike`** — the typed happy path: session → GHCR pull of `naros-agent` → container with an ## `WslcSpike` — does any of it actually work?
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, M1 spike (a2). The whole Windows container subsystem now compiles and **none of it has ever
mounted vs. in-VM). Deliberately held back until `WslcApiDump` has run: written now, against run**: `WslcFacade` (compat SDK), `WslcInternal` (D13 Tier 1 recovery), the §3.3 RPC surface.
guessed names, it would not compile, and fixing it blind is the mistake this whole approach This drives all of it the way hostd does — spawn `nucleic-brokerd.exe`, speak NDJSON JSON-RPC
exists to avoid. over its stdio — so what passes here is the shipping code and not a rehearsal of it.
That is why it is a **client** of the broker rather than a second program calling wslc. Its
csproj is plain `net9.0` with no wslc reference at all, so it *cannot* drift into a parallel
implementation, and it builds on any machine even though it only runs on Windows.
```powershell
dotnet build windows\NucleicBroker\NucleicBroker.csproj -p:UseWslc=true # the broker it drives
dotnet run --project windows\spikes\WslcSpike -- --repo C:\src\nucleic
```
Options: `--image` (default `ghcr.io/abkslm/naros-agent:26.07`), `--container`, `--session-name`,
`--iterations` (timing runs, default 5), `--broker <path>` if autodiscovery fails,
`--no-recovery` to skip the crash test.
It answers three open questions:
1. **Does the happy path work?** session → GHCR pull → container with an NTFS `ContainerVolume` →
exec → stdio round-trip → signal → teardown. Also prints the §5 host gateway, which no wslc
API surfaces and the control plane depends on entirely — an empty or loopback value there is
M1 (b)'s answer arriving early.
2. **Is 9P fast enough for D8?** §15 lists NTFS bind-mount performance as a top risk with no
numbers behind it. The spike times `git status` in the mounted worktree *and* in a copy on the
container's own ext4 — same container, same repo, so the mount is the only variable — then
reports the ratio and what it implies. It deliberately does not "fix" a bad result by moving
repos; §15 says surface that to the user, and a spike that silently relocated things would
hide the very finding it exists to produce.
3. **Does D13 Tier 1 recovery work?** It kills a broker with the session up — the exact §2.3
crash — starts a fresh one, and reports whether the orphan was cleared or whether it still
fails `session_exists`. It distinguishes "Tier 1 never bound" from "Tier 1 bound and did not
work", because those are different bugs.
Every step prints what happened rather than asserting: a spike's product is evidence. It exits
non-zero only when a step that should have worked threw. The `[brokerd]` lines interleaved in the
output are the facade's own stderr diagnostics — where `WslcFacade`/`WslcInternal` report what
bound and what degraded — and are usually the fastest route to a cause.
`--iterations` on a large repo is the number that matters for D8; the default 5 on a small one is
a smoke test, not a measurement.
---
## Not yet written
- ~~**The internal arm, Tier 1 — "recover"**~~ **written**: `windows/NucleicBroker/Wslc/WslcInternal.cs`. - ~~**The internal arm, Tier 1 — "recover"**~~ **written**: `windows/NucleicBroker/Wslc/WslcInternal.cs`.
On broker restart it opens the orphaned session, lists its containers for the log, terminates On broker restart it opens the orphaned session, lists its containers for the log, terminates
it, and lets the facade start fresh — automatic clean recovery instead of a manual it, and lets the facade start fresh — automatic clean recovery instead of a manual
+143
View File
@@ -0,0 +1,143 @@
using System.Diagnostics;
using System.Text;
using System.Text.Json;
namespace WslcSpike;
/// <summary>
/// A minimal hostd stand-in: spawns `nucleic-brokerd.exe` and speaks the §3.3 NDJSON JSON-RPC
/// surface over its stdio, exactly as `WslcBrokerClient.swift` does.
///
/// Deliberately a CLIENT of the broker rather than a second implementation of the wslc calls.
/// The point of this spike is to exercise the code that actually ships — `WslcFacade`,
/// `WslcInternal`, `BrokerService`, `OutboundWriter` — so a passing run is evidence about
/// Nucleic, not about a parallel program that happens to use the same SDK.
/// </summary>
internal sealed class BrokerClient : IAsyncDisposable
{
private readonly Process process;
private readonly Dictionary<long, TaskCompletionSource<JsonElement>> pending = [];
private readonly Lock pendingLock = new();
private long nextId;
/// <summary>Raised for every notification (proc.stdout, image.pullProgress, …), on the
/// reader thread. Handlers must not block.</summary>
internal event Action<string, JsonElement>? Notification;
private BrokerClient(Process process) => this.process = process;
internal static BrokerClient Spawn(string brokerPath)
{
var info = new ProcessStartInfo(brokerPath)
{
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
// The broker pins LF framing on stdout; read it back as UTF-8 with no BOM.
StandardOutputEncoding = new UTF8Encoding(false),
StandardErrorEncoding = new UTF8Encoding(false),
};
var process = Process.Start(info)
?? throw new InvalidOperationException($"could not start {brokerPath}");
var client = new BrokerClient(process);
_ = Task.Run(client.ReadLoopAsync);
// The broker's diagnostics go to stderr and are where WslcFacade/WslcInternal report what
// bound, what degraded and why — the most useful output in the whole run when something
// is wrong. Prefix so they cannot be mistaken for spike output.
_ = Task.Run(async () =>
{
for (string? line; (line = await process.StandardError.ReadLineAsync()) is not null;)
Console.WriteLine($" [brokerd] {line}");
});
return client;
}
private async Task ReadLoopAsync()
{
for (string? line; (line = await process.StandardOutput.ReadLineAsync()) is not null;)
{
if (line.Length == 0) continue;
JsonElement root;
try { root = JsonDocument.Parse(line).RootElement.Clone(); }
catch (JsonException) { Console.WriteLine($" [unparseable] {line}"); continue; }
if (root.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.Number)
{
TaskCompletionSource<JsonElement>? waiter;
lock (pendingLock)
{
pending.Remove(id.GetInt64(), out waiter);
}
waiter?.TrySetResult(root);
}
else if (root.TryGetProperty("method", out var method))
{
root.TryGetProperty("params", out var args);
Notification?.Invoke(method.GetString() ?? "", args);
}
}
// stdout closed: the broker exited. Fail everything outstanding rather than hanging —
// this is the `brokerLost` condition §2.3 describes, and a spike that hangs here teaches
// nothing.
lock (pendingLock)
{
foreach (var waiter in pending.Values)
waiter.TrySetException(new InvalidOperationException("brokerd exited"));
pending.Clear();
}
}
/// <summary>Send a request and await its result, throwing on a JSON-RPC error so a failing
/// step stops the scenario at the point it failed.</summary>
internal async Task<JsonElement> CallAsync(string method, object? args = null, int timeoutSeconds = 120)
{
var id = Interlocked.Increment(ref nextId);
var waiter = new TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously);
lock (pendingLock) pending[id] = waiter;
var request = args is null
? $$"""{"jsonrpc":"2.0","id":{{id}},"method":"{{method}}"}"""
: $$"""{"jsonrpc":"2.0","id":{{id}},"method":"{{method}}","params":{{JsonSerializer.Serialize(args)}}}""";
await process.StandardInput.WriteAsync(request + "\n");
await process.StandardInput.FlushAsync();
var response = await waiter.Task.WaitAsync(TimeSpan.FromSeconds(timeoutSeconds));
if (response.TryGetProperty("error", out var error))
{
var kind = error.TryGetProperty("data", out var data)
&& data.TryGetProperty("kind", out var k) ? k.GetString() : null;
throw new BrokerError(method, error.GetProperty("message").GetString() ?? "", kind);
}
return response.GetProperty("result");
}
/// <summary>Kill the broker without letting it shut down — the §2.3 crash this spike's
/// recovery step needs to simulate.</summary>
internal void Kill()
{
try { process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { }
}
public async ValueTask DisposeAsync()
{
try
{
process.StandardInput.Close(); // the graceful path: broker exits when stdin closes
await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
}
catch (Exception)
{
Kill();
}
process.Dispose();
}
}
internal sealed class BrokerError(string method, string message, string? kind)
: Exception($"{method} failed: {message}" + (kind is null ? "" : $" [kind={kind}]"))
{
internal string? Kind { get; } = kind;
}
+392
View File
@@ -0,0 +1,392 @@
using System.Diagnostics;
using System.Text.Json;
namespace WslcSpike;
/// <summary>
/// M1 spike (a2) — the wslc happy path, end to end, through the real broker
/// (docs/WINDOWS_PORT.md §13).
///
/// Everything in the Windows container subsystem now compiles and none of it has ever run:
/// `WslcFacade` (the compat SDK arm), `WslcInternal` (D13 Tier 1 recovery), the §3.3 RPC surface.
/// This drives all of it as hostd would — spawn `nucleic-brokerd.exe`, speak NDJSON over stdio —
/// so what passes here is the shipping code, not a rehearsal of it.
///
/// It answers three questions the plan is still guessing at:
///
/// 1. **Does the happy path work at all?** session → GHCR pull → container with an NTFS
/// `ContainerVolume` → exec → stdio round-trip → signal → teardown.
/// 2. **Is 9P fast enough to put repos on NTFS (D8)?** §15 lists this as a top risk with no
/// numbers behind it. The spike times `git status` in the bind-mounted worktree AND in a copy
/// on the container's own ext4, which is the comparison that actually isolates the mount.
/// 3. **Does D13 Tier 1 recovery work?** Kill the broker with the session up, start another, and
/// see whether it clears the orphan instead of failing `session_exists`.
///
/// Nothing here is asserted-and-exits: every step prints what happened, because a spike's product
/// is evidence. It exits non-zero only if a step that should work threw.
/// </summary>
internal static class Program
{
private static async Task<int> Main(string[] args)
{
var repo = Arg(args, "--repo") ?? Directory.GetCurrentDirectory();
var image = Arg(args, "--image") ?? "ghcr.io/abkslm/naros-agent:26.07";
var container = Arg(args, "--container") ?? "nucleic-spike-c1";
var sessionName = Arg(args, "--session-name") ?? "nucleic-spike";
var iterations = int.TryParse(Arg(args, "--iterations"), out var n) ? n : 5;
var broker = Arg(args, "--broker") ?? FindBroker();
var skipRecovery = args.Contains("--no-recovery");
if (broker is null || !File.Exists(broker))
{
Console.Error.WriteLine(
"could not find nucleic-brokerd.exe. Build it first:\n"
+ " dotnet build windows\\NucleicBroker\\NucleicBroker.csproj -p:UseWslc=true\n"
+ "then pass --broker <path> if it still isn't found.");
return 2;
}
Console.WriteLine($"broker : {broker}");
Console.WriteLine($"repo : {repo}");
Console.WriteLine($"image : {image}");
Console.WriteLine();
try
{
await RunScenarioAsync(broker, sessionName, image, container, repo, iterations);
if (!skipRecovery) await RunRecoveryAsync(broker, sessionName);
return 0;
}
catch (Exception e)
{
Console.Error.WriteLine();
Console.Error.WriteLine($"FAILED: {e.Message}");
Console.Error.WriteLine(
"The [brokerd] lines above are the facade's own diagnostics and usually say why.");
return 1;
}
}
private static async Task RunScenarioAsync(
string brokerPath, string sessionName, string image, string containerName,
string repo, int iterations)
{
await using var broker = BrokerClient.Spawn(brokerPath);
// Pull progress is high-rate; collapse it to one line per phase change so the transcript
// stays readable but a stalled pull is still visible.
var lastStatus = "";
broker.Notification += (method, args) =>
{
switch (method)
{
case "image.pullProgress":
var status = args.TryGetProperty("status", out var s) ? s.GetString() ?? "" : "";
if (status != lastStatus) { lastStatus = status; Console.WriteLine($" pull: {status}"); }
break;
case "session.down":
Console.WriteLine($" !! session.down: {args}");
break;
}
};
Step("hello");
var hello = await broker.CallAsync("hello");
var capabilities = hello.GetProperty("capabilities").EnumerateArray()
.Select(c => c.GetString()).ToList();
Console.WriteLine($" brokerd {hello.GetProperty("brokerVersion").GetString()}, "
+ $"wslc {hello.GetProperty("wslcVersion").GetString() ?? "(absent)"}");
Console.WriteLine($" capabilities: {string.Join(", ", capabilities)}");
// These are the D13 capability flags; say plainly which arm answered so the run is
// self-describing rather than needing the doc open beside it.
Console.WriteLine(capabilities.Contains("recover")
? " → D13 Tier 1 bound: an orphaned session will be recovered automatically"
: " → D13 Tier 1 did NOT bind: a broker restart will need `wsl --shutdown`");
Step("components.missing");
var missing = (await broker.CallAsync("components.missing")).GetProperty("flags")
.EnumerateArray().Select(c => c.GetString()).ToList();
if (missing.Count > 0)
throw new InvalidOperationException(
$"this machine cannot run wslc yet — missing {string.Join(", ", missing)}. "
+ "See windows/spikes/README.md for the per-component remedy.");
Console.WriteLine(" none missing");
Step("session.ensure");
var dataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Nucleic", "spike", "wslc");
Directory.CreateDirectory(dataDir);
var gateway = (await broker.CallAsync("session.ensure",
new { name = sessionName, dataDir, cpu = 4, memoryMB = 8192 }, 300))
.GetProperty("gateway").GetString();
// §5's control plane depends on this address entirely, and no wslc API surfaces it —
// it comes from the WSL vEthernet adapter. If it is empty or a loopback, the control
// plane cannot work and M1 (b) has its answer early.
Console.WriteLine($" host gateway (guest→host, §5): {gateway}");
Step($"image.pull {image}");
var pullWatch = Stopwatch.StartNew();
await broker.CallAsync("image.pull", new { @ref = image }, 1800);
Console.WriteLine($" pulled in {pullWatch.Elapsed.TotalSeconds:F1}s");
Step($"container.create {containerName}");
await broker.CallAsync("container.create", new
{
name = containerName,
image,
volumes = new[] { new { host = repo, guest = "/work", ro = false } },
networkingMode = "Bridged",
hostname = "nucleic-spike",
env = new Dictionary<string, string> { ["NUCLEIC_SPIKE"] = "1" },
});
await broker.CallAsync("container.start", new { name = containerName });
var state = (await broker.CallAsync("container.state", new { name = containerName }))
.GetProperty("state").GetString();
Console.WriteLine($" state: {state}");
Step("exec: stdio round-trip");
var (code, output) = await ExecAsync(broker, containerName, ["/bin/sh", "-c", "echo hello from \"$(uname -n)\"; echo to-stderr 1>&2"]);
Console.WriteLine($" exit {code}: {output.Trim()}");
Step("exec: uid drop (setpriv wrapper, §3.2)");
// ProcessSettings has no uid/gid, so the facade wraps argv in setpriv. If the image lacks
// util-linux this is where that shows, and the fallback is `su agent -c`.
var (idCode, idOut) = await ExecAsync(
broker, containerName, ["/bin/sh", "-c", "id -u; id -g"], uid: 501, gid: 501);
Console.WriteLine(idCode == 0
? $" uid/gid inside container: {idOut.Replace("\n", "/").Trim('/')}"
: $" setpriv wrapper FAILED (exit {idCode}): {idOut.Trim()} — try `su agent -c`");
Step("the mounted worktree");
var (lsCode, lsOut) = await ExecAsync(broker, containerName,
["/bin/sh", "-c", "ls /work | head -5; echo ---; test -d /work/.git && echo 'git repo present'"]);
Console.WriteLine($" exit {lsCode}\n{Indent(lsOut)}");
await MeasureNinePAsync(broker, containerName, iterations);
Step("container.stats (cgroup read — no GetStatistics() exists)");
var stats = (await broker.CallAsync("container.stats", new { name = containerName }))
.GetProperty("stats");
Console.WriteLine(stats.ValueKind == JsonValueKind.Null
? " null (container not running?)"
: $" {stats}");
Step("teardown");
await broker.CallAsync("container.stop", new { name = containerName, signal = 15, graceMs = 10000 });
await broker.CallAsync("container.delete", new { name = containerName, force = true });
Console.WriteLine(" stopped and deleted");
}
/// <summary>
/// §15's top unquantified risk: D8 puts repos on NTFS and bind-mounts them, so every git and
/// npm operation crosses 9P. The only honest measurement is the same work on both sides of
/// the mount, in the same container, on the same repo — so this copies the worktree to the
/// container's own ext4 and runs the identical commands there.
/// </summary>
private static async Task MeasureNinePAsync(BrokerClient broker, string container, int iterations)
{
Step($"9P vs ext4 — `git status` x{iterations} (§15 risk, D8)");
var mounted = await TimeCommandAsync(
broker, container, "cd /work && git status --porcelain >/dev/null", iterations);
Report("/work (NTFS via 9P)", mounted);
Console.WriteLine(" copying the worktree to container-local ext4…");
var copyWatch = Stopwatch.StartNew();
var (copyCode, copyOut) = await ExecAsync(broker, container,
["/bin/sh", "-c", "rm -rf /tmp/ext4 && cp -a /work /tmp/ext4 && echo ok"], timeoutSeconds: 1800);
if (copyCode != 0)
{
Console.WriteLine($" copy failed (exit {copyCode}): {copyOut.Trim()} — skipping the ext4 leg");
return;
}
Console.WriteLine($" copied in {copyWatch.Elapsed.TotalSeconds:F1}s");
var local = await TimeCommandAsync(
broker, container, "cd /tmp/ext4 && git status --porcelain >/dev/null", iterations);
Report("/tmp/ext4 (container-local)", local);
if (mounted.Count > 0 && local.Count > 0)
{
var ratio = Median(mounted) / Math.Max(1, Median(local));
Console.WriteLine($" → 9P is {ratio:F1}x the local time (median)");
// The threshold is a judgement call, not a measurement, so it is stated as one.
Console.WriteLine(ratio switch
{
< 2 => " → acceptable: D8 stands, repos stay on NTFS.",
< 5 => " → noticeable. D8 stands, but cache dirs (node_modules, build output)\n"
+ " should go on a ContainerNamedVolume as §15 anticipated.",
_ => " → BAD. §15 says surface this to the user for a decision rather than\n"
+ " silently moving repos into ext4. Re-measure on a large repo first.",
});
}
await ExecAsync(broker, container, ["/bin/sh", "-c", "rm -rf /tmp/ext4"]);
}
private static async Task<List<double>> TimeCommandAsync(
BrokerClient broker, string container, string script, int iterations)
{
var timings = new List<double>();
for (var i = 0; i < iterations; i++)
{
var watch = Stopwatch.StartNew();
var (code, output) = await ExecAsync(broker, container, ["/bin/sh", "-c", script], timeoutSeconds: 600);
watch.Stop();
if (code != 0)
{
Console.WriteLine($" command failed (exit {code}): {output.Trim()}");
return timings;
}
timings.Add(watch.Elapsed.TotalMilliseconds);
}
return timings;
}
private static void Report(string label, List<double> timings)
{
if (timings.Count == 0) { Console.WriteLine($" {label}: no successful runs"); return; }
Console.WriteLine($" {label}: median {Median(timings):F0}ms "
+ $"(min {timings.Min():F0}, max {timings.Max():F0}, n={timings.Count})");
}
private static double Median(List<double> values)
{
var sorted = values.Order().ToList();
return sorted.Count % 2 == 1
? sorted[sorted.Count / 2]
: (sorted[sorted.Count / 2 - 1] + sorted[sorted.Count / 2]) / 2;
}
/// <summary>
/// D13 Tier 1, live (docs/WINDOWS_PORT.md §13.2). Kill a broker with its session up — the
/// exact §2.3 crash — then start a fresh one and ensure the same session name. Before Tier 1
/// this failed `session_exists` and needed a manual `wsl --shutdown`; it should now recover.
/// </summary>
private static async Task RunRecoveryAsync(string brokerPath, string sessionName)
{
Step("D13 Tier 1: kill the broker, then recover the orphaned session");
var dataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Nucleic", "spike", "wslc");
await using (var first = BrokerClient.Spawn(brokerPath))
{
await first.CallAsync("hello");
await first.CallAsync("session.ensure", new { name = sessionName, dataDir }, 300);
Console.WriteLine(" session up; killing brokerd without shutdown…");
first.Kill();
}
// The session is deliberately left running: that is the orphan.
await Task.Delay(2000);
await using var second = BrokerClient.Spawn(brokerPath);
var hello = await second.CallAsync("hello");
var canRecover = hello.GetProperty("capabilities").EnumerateArray()
.Any(c => c.GetString() == "recover");
try
{
var gateway = (await second.CallAsync(
"session.ensure", new { name = sessionName, dataDir }, 300))
.GetProperty("gateway").GetString();
Console.WriteLine($" RECOVERED — fresh session up, gateway {gateway}");
Console.WriteLine(" → a broker crash no longer strands the sandbox.");
await second.CallAsync("session.terminate");
}
catch (BrokerError e) when (e.Kind == "session_exists")
{
Console.WriteLine($" NOT recovered: {e.Message}");
Console.WriteLine(canRecover
? " → Tier 1 bound but did not clear the orphan. This is the bug to chase:\n"
+ " read the [brokerd] lines above for the OpenSessionByName/Terminate result."
: " → expected: Tier 1 never bound on this machine (see the hello above).");
Console.WriteLine(" Clear it manually with `wsl --shutdown`.");
}
}
// MARK: - Helpers
/// <summary>Run argv to completion, collecting stdout+stderr from the broker's notifications
/// exactly as `WslcProcessHandle` does.</summary>
private static async Task<(int Code, string Output)> ExecAsync(
BrokerClient broker, string container, string[] argv,
int? uid = null, int? gid = null, int timeoutSeconds = 300)
{
var output = new MemoryStream();
var exited = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
long procId = -1;
void OnNotification(string method, JsonElement args)
{
if (!args.TryGetProperty("procId", out var idElement)) return;
if (idElement.GetInt64() != Volatile.Read(ref procId)) return;
switch (method)
{
case "proc.stdout":
case "proc.stderr":
var chunk = Convert.FromBase64String(args.GetProperty("b64").GetString()!);
lock (output) output.Write(chunk, 0, chunk.Length);
break;
case "proc.exit":
exited.TrySetResult(args.GetProperty("code").GetInt32());
break;
}
}
broker.Notification += OnNotification;
try
{
object spec = uid is null
? new { container, argv, tty = false }
: new { container, argv, uid, gid = gid ?? uid, tty = false };
var result = await broker.CallAsync("proc.exec", spec);
// Set procId only after exec returns — but the broker may already have emitted output
// by then. That race is why WslcProcessHandle exists on the Swift side; here it costs
// at most a few dropped bytes of a diagnostic, so it is accepted rather than solved.
Volatile.Write(ref procId, result.GetProperty("procId").GetInt64());
var code = await exited.Task.WaitAsync(TimeSpan.FromSeconds(timeoutSeconds));
lock (output) return (code, System.Text.Encoding.UTF8.GetString(output.ToArray()));
}
finally
{
broker.Notification -= OnNotification;
}
}
/// <summary>Locate the broker built with -p:UseWslc=true. The TFM is windows-specific there,
/// so glob rather than hardcoding a path that moves with the SDK pin.</summary>
private static string? FindBroker()
{
var here = new DirectoryInfo(AppContext.BaseDirectory);
for (var dir = here; dir is not null; dir = dir.Parent)
{
var candidate = Path.Combine(dir.FullName, "windows", "NucleicBroker", "bin");
if (!Directory.Exists(candidate)) continue;
return Directory
.EnumerateFiles(candidate, "nucleic-brokerd.exe", SearchOption.AllDirectories)
// Prefer the windows-TFM build: that is the one with USE_WSLC compiled in.
.OrderByDescending(p => p.Contains("windows10.0"))
.ThenByDescending(File.GetLastWriteTimeUtc)
.FirstOrDefault();
}
return null;
}
private static void Step(string title)
{
Console.WriteLine();
Console.WriteLine($"==> {title}");
}
private static string Indent(string text) =>
string.Join("\n", text.Split('\n').Select(l => " " + l));
private static string? Arg(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;
}
}
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- M1 spike (a2), docs/WINDOWS_PORT.md §13: the wslc happy path driven through the REAL
broker over its NDJSON stdio surface.
Plain net9.0 on purpose — no windows TFM, no wslc PackageReference. This spike is a
JSON-RPC client that spawns a process; all the WinRT lives in nucleic-brokerd, which is
the point (it exercises the shipping code rather than re-implementing the SDK calls).
Building anywhere keeps it honest: the spike cannot quietly drift into a second
implementation, because it has no way to call wslc directly. -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>WslcSpike</AssemblyName>
<RootNamespace>WslcSpike</RootNamespace>
</PropertyGroup>
</Project>