From 3f1f4c7f042016a30da78c211bc27435467966de Mon Sep 17 00:00:00 2001 From: Nucleic Date: Wed, 29 Jul 2026 04:18:06 -0700 Subject: [PATCH] Merge nucleic/lucid-river-toad-6efj into dev --- spikes/README.md | 56 ++++- spikes/WslcSpike/BrokerClient.cs | 143 +++++++++++ spikes/WslcSpike/Program.cs | 392 ++++++++++++++++++++++++++++++ spikes/WslcSpike/WslcSpike.csproj | 20 ++ 4 files changed, 604 insertions(+), 7 deletions(-) create mode 100644 spikes/WslcSpike/BrokerClient.cs create mode 100644 spikes/WslcSpike/Program.cs create mode 100644 spikes/WslcSpike/WslcSpike.csproj diff --git a/spikes/README.md b/spikes/README.md index 3d219d3..c704c9b 100644 --- a/spikes/README.md +++ b/spikes/README.md @@ -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 - 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. +## `WslcSpike` — does any of it actually work? + +M1 spike (a2). The whole Windows container subsystem now compiles and **none of it has ever +run**: `WslcFacade` (compat SDK), `WslcInternal` (D13 Tier 1 recovery), the §3.3 RPC surface. +This drives all of it the way hostd does — spawn `nucleic-brokerd.exe`, speak NDJSON JSON-RPC +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 ` 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`. 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 diff --git a/spikes/WslcSpike/BrokerClient.cs b/spikes/WslcSpike/BrokerClient.cs new file mode 100644 index 0000000..98eaea6 --- /dev/null +++ b/spikes/WslcSpike/BrokerClient.cs @@ -0,0 +1,143 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; + +namespace WslcSpike; + +/// +/// 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. +/// +internal sealed class BrokerClient : IAsyncDisposable +{ + private readonly Process process; + private readonly Dictionary> pending = []; + private readonly Lock pendingLock = new(); + private long nextId; + + /// Raised for every notification (proc.stdout, image.pullProgress, …), on the + /// reader thread. Handlers must not block. + internal event Action? 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? 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(); + } + } + + /// 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. + internal async Task CallAsync(string method, object? args = null, int timeoutSeconds = 120) + { + var id = Interlocked.Increment(ref nextId); + var waiter = new TaskCompletionSource(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"); + } + + /// Kill the broker without letting it shut down — the §2.3 crash this spike's + /// recovery step needs to simulate. + 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; +} diff --git a/spikes/WslcSpike/Program.cs b/spikes/WslcSpike/Program.cs new file mode 100644 index 0000000..f177161 --- /dev/null +++ b/spikes/WslcSpike/Program.cs @@ -0,0 +1,392 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace WslcSpike; + +/// +/// 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. +/// +internal static class Program +{ + private static async Task 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 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 { ["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"); + } + + /// + /// §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. + /// + 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> TimeCommandAsync( + BrokerClient broker, string container, string script, int iterations) + { + var timings = new List(); + 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 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 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; + } + + /// + /// 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. + /// + 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 + + /// Run argv to completion, collecting stdout+stderr from the broker's notifications + /// exactly as `WslcProcessHandle` does. + 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(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; + } + } + + /// 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. + 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; + } +} diff --git a/spikes/WslcSpike/WslcSpike.csproj b/spikes/WslcSpike/WslcSpike.csproj new file mode 100644 index 0000000..8560018 --- /dev/null +++ b/spikes/WslcSpike/WslcSpike.csproj @@ -0,0 +1,20 @@ + + + + + Exe + net9.0 + enable + enable + WslcSpike + WslcSpike + + +