Merge nucleic/lucid-river-toad-6efj into dev
This commit is contained in:
+155
-9
@@ -146,7 +146,14 @@ internal static class Program
|
||||
["hostname"] = "nucleic-spike",
|
||||
["env"] = new Dictionary<string, string> { ["NUCLEIC_SPIKE"] = "1" },
|
||||
};
|
||||
if (sleepInit) create["initArgv"] = new[] { "/bin/sh", "-c", "sleep 3600" };
|
||||
// Mirror WslcContainerEngine: narOS images carry no default CMD (wslc answers "no command
|
||||
// specified"), so the engine always names init explicitly — `naros-init` is PID 1 per
|
||||
// docs/NAROS.md §5, doing zombie reaping, signal forwarding and, with NAROS_BRIDGE=1,
|
||||
// supervising control-bridge.js. `--sleep-init` swaps in a keepalive for stock images that
|
||||
// have no naros-init, which is the case the engine handles with a probe (see §13.3).
|
||||
create["initArgv"] = sleepInit
|
||||
? new[] { "/bin/sh", "-c", "sleep 3600" }
|
||||
: new[] { "/usr/sbin/naros-init" };
|
||||
await broker.CallAsync("container.create", create);
|
||||
await broker.CallAsync("container.start", new { name = containerName });
|
||||
var state = (await broker.CallAsync("container.state", new { name = containerName }))
|
||||
@@ -189,6 +196,8 @@ internal static class Program
|
||||
["/bin/sh", "-c", "ls /work | head -5; echo ---; test -d /work/.git && echo 'git repo present'"]);
|
||||
Console.WriteLine($" exit {lsCode}\n{Indent(lsOut)}");
|
||||
|
||||
await ProbeControlPlaneAsync(broker, containerName, gateway);
|
||||
|
||||
await MeasureNinePAsync(broker, containerName, iterations);
|
||||
|
||||
Step("container.stats (cgroup read — no GetStatistics() exists)");
|
||||
@@ -204,6 +213,117 @@ internal static class Program
|
||||
Console.WriteLine(" stopped and deleted");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M1 (b), and the last thing M2 waits on: can a container reach the host at the gateway?
|
||||
///
|
||||
/// Every agent session depends on this. `control-bridge.js` forwards guest loopback 9099 to
|
||||
/// `NUCLEIC_CONTROL_HOST/PORT`, which is where `MCPApprovalServer` serves approvals, the git
|
||||
/// and gh interceptor endpoints, and the nash shell reports (§5, §1.4). If the guest cannot
|
||||
/// open a TCP connection to the host on that address, none of it works and no agent can run.
|
||||
///
|
||||
/// This is the reachability question in isolation: a bare TCP round trip, bound **only** to
|
||||
/// the WSL-facing address and never `0.0.0.0`, which is the posture §5 requires and therefore
|
||||
/// the posture worth testing. It answers it under whatever firewall policy the machine
|
||||
/// actually has — the variable §5 step 5 flags and cannot predict.
|
||||
/// </summary>
|
||||
private static async Task ProbeControlPlaneAsync(
|
||||
BrokerClient broker, string container, string? gateway)
|
||||
{
|
||||
Step("§5 control plane: can the guest reach the host at the gateway?");
|
||||
if (!System.Net.IPAddress.TryParse(gateway, out var address))
|
||||
{
|
||||
Console.WriteLine($" no usable gateway address ('{gateway}') — cannot test");
|
||||
return;
|
||||
}
|
||||
|
||||
var listener = new System.Net.Sockets.TcpListener(address, 0);
|
||||
try
|
||||
{
|
||||
listener.Start();
|
||||
}
|
||||
catch (System.Net.Sockets.SocketException e)
|
||||
{
|
||||
// Binding the WSL-facing address is what MCPApprovalServer will do, so a failure here
|
||||
// is a finding about §5 rather than about this spike.
|
||||
Console.WriteLine($" could not bind {gateway}:0 — {e.SocketErrorCode}: {e.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||
Console.WriteLine($" host listening on {gateway}:{port} (interface-scoped, not 0.0.0.0)");
|
||||
|
||||
// Answer with a minimal HTTP response as well as reading the request, so both `nc` and
|
||||
// `wget` work as the guest client — the real bridge speaks HTTP to /mcp.
|
||||
var received = Task.Run(async () =>
|
||||
{
|
||||
using var client = await listener.AcceptTcpClientAsync();
|
||||
using var stream = client.GetStream();
|
||||
var buffer = new byte[512];
|
||||
var read = await stream.ReadAsync(buffer);
|
||||
const string body = "NUCLEIC-CONTROL-OK";
|
||||
var response = "HTTP/1.1 200 OK\r\n"
|
||||
+ $"Content-Length: {body.Length}\r\nConnection: close\r\n\r\n{body}";
|
||||
await stream.WriteAsync(System.Text.Encoding.UTF8.GetBytes(response));
|
||||
await stream.FlushAsync();
|
||||
return System.Text.Encoding.UTF8.GetString(buffer, 0, read);
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
// narOS has neither `nc` nor `wget` — it has **node**, since control-bridge.js is the
|
||||
// real client on this path. Passed as argv with no shell, so nothing here needs
|
||||
// quoting, and the payload is a bare "PING" because the host side replies to whatever
|
||||
// it reads (no CRLF handling to get wrong).
|
||||
var script =
|
||||
"const net=require('net');const s=net.connect(" + port + ",'" + gateway + "');"
|
||||
+ "let d='';s.setTimeout(5000,()=>{console.error('TIMEOUT');process.exit(4)});"
|
||||
+ "s.on('connect',()=>s.write('PING'));s.on('data',c=>d+=c);"
|
||||
+ "s.on('close',()=>{process.stdout.write(d);process.exit(0)});"
|
||||
+ "s.on('error',e=>{console.error('CONNECT-ERROR '+e.code);process.exit(3)});";
|
||||
string[] argv = ["node", "-e", script];
|
||||
Console.WriteLine($" guest: node -e <tcp connect to {gateway}:{port}>");
|
||||
|
||||
var (code, output) = await ExecAsync(broker, container, argv, timeoutSeconds: 60);
|
||||
|
||||
// A missing client tool is NOT a blocked connection, and reporting it as one sends the
|
||||
// reader off to inspect firewall rules for no reason. An earlier version of this probe
|
||||
// did exactly that on narOS (§13.3).
|
||||
if (code == 127 || output.Contains("command not found") || output.Contains("not found"))
|
||||
{
|
||||
Console.WriteLine($" INCONCLUSIVE — no usable client in this image (exit {code}): "
|
||||
+ output.Trim());
|
||||
Console.WriteLine(" → says nothing about reachability. Use an image with node, "
|
||||
+ "nc or wget.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (output.Contains("NUCLEIC-CONTROL-OK"))
|
||||
{
|
||||
var got = await received.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Console.WriteLine(" REACHABLE — the guest completed a TCP round trip to the host.");
|
||||
Console.WriteLine($" host saw: {got.Split('\n')[0].Trim()}");
|
||||
Console.WriteLine(" → §5's gateway-TCP control plane works on this machine under "
|
||||
+ "its current firewall policy. M2 is unblocked.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($" NOT REACHABLE (exit {code}): {output.Trim()}");
|
||||
Console.WriteLine(" → this is the §5 step-5 firewall case. The control plane "
|
||||
+ "cannot work until it is resolved, so no agent can run:");
|
||||
Console.WriteLine(" • check the Hyper-V firewall policy for the WSL vSwitch");
|
||||
Console.WriteLine(" • `New-NetFirewallRule -DisplayName 'Nucleic control' "
|
||||
+ $"-Direction Inbound -LocalAddress {gateway} -Protocol TCP -Action Allow`");
|
||||
Console.WriteLine(" • if it stays blocked, §5's AF_HYPERV fallback stops being "
|
||||
+ "upside and becomes required — but §13.2 found no client route to the VM GUID, "
|
||||
+ "so that needs a source outside wslc (HCS enumeration).");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
@@ -248,18 +368,44 @@ internal static class Program
|
||||
var local = await TimeCommandAsync(broker, container, $"cd /tmp/ext4 && {probe}", iterations);
|
||||
Report("/tmp/ext4 (container-local)", local);
|
||||
|
||||
// Reads are only half the story: `npm install` writes tens of thousands of small files,
|
||||
// which is the case §15 singles out and which a traversal does not exercise at all.
|
||||
Step("9P vs ext4 — writing 2000 small files (the `npm install` shape)");
|
||||
const string write =
|
||||
"rm -rf wbench && mkdir wbench && cd wbench && "
|
||||
+ "i=0; while [ $i -lt 2000 ]; do echo x > f$i; i=$((i+1)); done && cd .. && rm -rf wbench";
|
||||
var mountedWrite = await TimeCommandAsync(broker, container, $"cd /work && {write}", 1);
|
||||
Report("/work (NTFS via 9P)", mountedWrite);
|
||||
var localWrite = await TimeCommandAsync(broker, container, $"cd /tmp && {write}", 1);
|
||||
Report("/tmp (container-local)", localWrite);
|
||||
if (mountedWrite.Count > 0 && localWrite.Count > 0)
|
||||
Console.WriteLine($" → writes are {Median(mountedWrite) / Math.Max(1, Median(localWrite)):F1}x "
|
||||
+ $"slower across the mount ({Median(mountedWrite):F0}ms vs {Median(localWrite):F0}ms "
|
||||
+ "for 2000 files)");
|
||||
|
||||
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
|
||||
// Report BOTH, because they lead to different conclusions: a large ratio on a small
|
||||
// absolute time is tolerable, and that is the distinction the first `find`-based run
|
||||
// got wrong by having no absolute figure worth quoting.
|
||||
Console.WriteLine($" → reads are {ratio:F1}x the local time "
|
||||
+ $"({Median(mounted):F0}ms vs {Median(local):F0}ms per run)");
|
||||
// Judged on ABSOLUTE latency, not ratio: what matters to a session is how long a
|
||||
// status takes, and a big multiple of a tiny number is still a tiny number. §15 says
|
||||
// surface the result for a decision rather than silently relocating repos, so none of
|
||||
// these branches change anything.
|
||||
var absolute = Median(mounted);
|
||||
Console.WriteLine(absolute 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.",
|
||||
< 250 => " → tolerable: D8 stands as written.",
|
||||
< 2000 => " → a visible pause on every operation, but workable. D8 stands;\n"
|
||||
+ " cache dirs (node_modules, build output) on a ContainerNamedVolume\n"
|
||||
+ " per §15 recover most of it. Compare the write number above —\n"
|
||||
+ " writes, not reads, are what npm install pays.",
|
||||
_ => " → too slow to ignore. Surface to the user (§15): the options are hot-dir\n"
|
||||
+ " named volumes, or moving the clone into the session's ext4 with host\n"
|
||||
+ " access over \\\\wsl$. Do not relocate repos silently.",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user