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; /// Dump every inbound line. When a step hangs, the wire trace says whether the /// broker answered at all, answered and then went quiet, or never saw the request — three /// very different bugs that look identical from a timeout. internal bool Verbose; 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; if (Verbose) Console.WriteLine($" [<-] {(line.Length > 300 ? line[..300] + "…" : line)}"); 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)}}}"""; if (Verbose) Console.WriteLine($" [->] {request}"); 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; }