Merge nucleic/lucid-river-toad-6efj into dev
This commit is contained in:
+49
-7
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user