Merge nucleic/lucid-river-toad-6efj into dev
This commit is contained in:
@@ -129,6 +129,67 @@ public sealed class BrokerServiceTests
|
|||||||
Assert.Equal("session_exists", error.GetProperty("data").GetProperty("kind").GetString());
|
Assert.Equal("session_exists", error.GetProperty("data").GetProperty("kind").GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression for the bug the first live run hit (docs/WINDOWS_PORT.md §13.3): a command that
|
||||||
|
/// finishes during start — `echo` — used to put `proc.stdout`/`proc.exit` on the wire BEFORE
|
||||||
|
/// the response carrying the `procId` that names them. A client that registers interest when
|
||||||
|
/// it learns the procId then never sees the exit and waits forever, which is exactly how the
|
||||||
|
/// spike hung.
|
||||||
|
///
|
||||||
|
/// The fix is that BrokerService responds before calling StartAsync, so this asserts the
|
||||||
|
/// ORDER and not merely that the messages all arrived.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcExec_RespondsBeforeAnyOutputFromAnInstantProcess()
|
||||||
|
{
|
||||||
|
wslc.ContainerStates["box"] = "running";
|
||||||
|
// The fake emits during StartAsync, standing in for a process that has already exited by
|
||||||
|
// the time Start() returns.
|
||||||
|
wslc.NextProcessOnStart = () =>
|
||||||
|
{
|
||||||
|
wslc.Events!.ProcOutput(wslc.LastProcId, stderr: false, "done\n"u8);
|
||||||
|
wslc.Events!.ProcExited(wslc.LastProcId, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
var lines = await RoundTrip(
|
||||||
|
"""{"jsonrpc":"2.0","id":1,"method":"proc.exec","params":{"container":"box","argv":["echo","done"],"tty":false}}""");
|
||||||
|
|
||||||
|
Assert.True(wslc.LastProcess!.Started, "BrokerService must start the process");
|
||||||
|
// Line 0 must be the response: everything else is meaningless to a client that cannot yet
|
||||||
|
// attribute the notifications.
|
||||||
|
var procId = Result(lines[0]).GetProperty("procId").GetInt64();
|
||||||
|
Assert.Equal(wslc.LastProcId, procId);
|
||||||
|
var methods = lines.Skip(1)
|
||||||
|
.Select(l => l.TryGetProperty("method", out var m) ? m.GetString() : null).ToList();
|
||||||
|
Assert.Equal(["proc.stdout", "proc.exit"], methods);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The response is enqueued before the process is started, so a start failure can no longer be
|
||||||
|
/// a JSON-RPC error — that would put TWO responses under one id. Observed on hardware
|
||||||
|
/// (docs/WINDOWS_PORT.md §13.3), where the client then waited for an exit that never came.
|
||||||
|
/// It must arrive as the process's own stderr plus an exit instead.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcExec_StartFailure_ReportsAsExitNotSecondResponse()
|
||||||
|
{
|
||||||
|
wslc.ContainerStates["box"] = "running";
|
||||||
|
wslc.NextProcessOnStart = () => throw new WslcError(WslcError.StartFailed, "container is not running");
|
||||||
|
|
||||||
|
var lines = await RoundTrip(
|
||||||
|
"""{"jsonrpc":"2.0","id":1,"method":"proc.exec","params":{"container":"box","argv":["sh"],"tty":false}}""");
|
||||||
|
|
||||||
|
// Exactly one message carries id 1 — anything else is a protocol violation.
|
||||||
|
var responses = lines.Where(l => l.TryGetProperty("id", out var i) && i.GetInt32() == 1).ToList();
|
||||||
|
Assert.Single(responses);
|
||||||
|
Assert.False(responses[0].TryGetProperty("error", out _), "must be the success response");
|
||||||
|
|
||||||
|
var methods = lines.Skip(1)
|
||||||
|
.Select(l => l.TryGetProperty("method", out var m) ? m.GetString() : null).ToList();
|
||||||
|
Assert.Equal(["proc.stderr", "proc.exit"], methods);
|
||||||
|
Assert.Equal(126, lines.Last().GetProperty("params").GetProperty("code").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ImagePull_EmitsProgressNotificationsBeforeResult()
|
public async Task ImagePull_EmitsProgressNotificationsBeforeResult()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ public sealed class FakeWslc : IWslc
|
|||||||
public long LastProcId;
|
public long LastProcId;
|
||||||
public Exception? NextError;
|
public Exception? NextError;
|
||||||
|
|
||||||
|
/// <summary>Set before a proc.exec to make that process emit during StartAsync — i.e. behave
|
||||||
|
/// like a command that finishes instantly.</summary>
|
||||||
|
public Action? NextProcessOnStart;
|
||||||
|
|
||||||
public string? WslcVersion => "9.9-test";
|
public string? WslcVersion => "9.9-test";
|
||||||
|
|
||||||
/// The fake is deliberately fully-capable: it stands in for a facade with BOTH wslc surfaces
|
/// The fake is deliberately fully-capable: it stands in for a facade with BOTH wslc surfaces
|
||||||
@@ -157,7 +161,10 @@ public sealed class FakeWslc : IWslc
|
|||||||
if (ContainerStates.GetValueOrDefault(spec.Container) != "running")
|
if (ContainerStates.GetValueOrDefault(spec.Container) != "running")
|
||||||
throw new WslcError(WslcError.NotRunning, $"container {spec.Container} isn't running");
|
throw new WslcError(WslcError.NotRunning, $"container {spec.Container} isn't running");
|
||||||
LastProcId = procId;
|
LastProcId = procId;
|
||||||
LastProcess = new FakeProcess();
|
// Mirror the real facade: create and attach, but do NOT run — BrokerService runs it after
|
||||||
|
// the procId response is on the wire.
|
||||||
|
LastProcess = new FakeProcess { OnStart = NextProcessOnStart };
|
||||||
|
NextProcessOnStart = null;
|
||||||
return Task.FromResult<IWslcProcess>(LastProcess);
|
return Task.FromResult<IWslcProcess>(LastProcess);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +174,18 @@ public sealed class FakeWslc : IWslc
|
|||||||
public bool StdinClosed;
|
public bool StdinClosed;
|
||||||
public List<int> Signals = [];
|
public List<int> Signals = [];
|
||||||
public (int Cols, int Rows)? LastResize;
|
public (int Cols, int Rows)? LastResize;
|
||||||
|
public bool Started;
|
||||||
|
|
||||||
|
/// <summary>Lets a test stand in for a process that finishes DURING start — an `echo`,
|
||||||
|
/// which is what exposed the response-ordering bug on hardware.</summary>
|
||||||
|
public Action? OnStart;
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
Started = true;
|
||||||
|
OnStart?.Invoke();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
public Task WriteStdinAsync(ReadOnlyMemory<byte> data, CancellationToken ct)
|
public Task WriteStdinAsync(ReadOnlyMemory<byte> data, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -58,8 +58,11 @@ public sealed class BrokerService : IBrokerEvents
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await DispatchAsync(method, @params, ct).ConfigureAwait(false);
|
var result = await DispatchAsync(method, @params, id, ct).ConfigureAwait(false);
|
||||||
if (id is { } requestId)
|
// A null result means the handler already enqueued its own response because it had to
|
||||||
|
// send it before doing something else (proc.exec — see below). Everything else
|
||||||
|
// returns a value and is responded to here.
|
||||||
|
if (result is not null && id is { } requestId)
|
||||||
outbound.EnqueueJson(Rpc.Response(requestId, result));
|
outbound.EnqueueJson(Rpc.Response(requestId, result));
|
||||||
}
|
}
|
||||||
catch (JsonException e)
|
catch (JsonException e)
|
||||||
@@ -80,7 +83,10 @@ public sealed class BrokerService : IBrokerEvents
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<object> DispatchAsync(string method, JsonElement? p, CancellationToken ct)
|
/// <summary>Returns the result to respond with, or null when the handler has already
|
||||||
|
/// responded (it needed the response on the wire before continuing).</summary>
|
||||||
|
private async Task<object?> DispatchAsync(
|
||||||
|
string method, JsonElement? p, JsonElement? id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
switch (method)
|
switch (method)
|
||||||
{
|
{
|
||||||
@@ -176,7 +182,32 @@ public sealed class BrokerService : IBrokerEvents
|
|||||||
var procId = Interlocked.Increment(ref nextProcId);
|
var procId = Interlocked.Increment(ref nextProcId);
|
||||||
var proc = await wslc.ExecAsync(procId, spec, ct).ConfigureAwait(false);
|
var proc = await wslc.ExecAsync(procId, spec, ct).ConfigureAwait(false);
|
||||||
lock (procsLock) procs[procId] = proc;
|
lock (procsLock) procs[procId] = proc;
|
||||||
return new { procId };
|
// Respond BEFORE running it. Output and exit are enqueued on the same ordered
|
||||||
|
// outbound queue as this response, so a command that finishes fast (`echo`) would
|
||||||
|
// otherwise put `proc.exit` on the wire ahead of the `procId` naming it — and a
|
||||||
|
// client that registers interest when it learns the procId then waits forever.
|
||||||
|
// Hit on the first live run, hardware-confirmed (docs/WINDOWS_PORT.md §13.3).
|
||||||
|
if (id is { } requestId)
|
||||||
|
outbound.EnqueueJson(Rpc.Response(requestId, new { procId }));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await proc.StartAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
// The response is already on the wire, so this failure CANNOT be reported as a
|
||||||
|
// JSON-RPC error — that would put two responses under one id, which is a
|
||||||
|
// protocol violation and left the client waiting for an exit that never came.
|
||||||
|
// Report it the way the process itself would have: the reason on stderr, then
|
||||||
|
// an exit. 126 is the shell's "command found but not executable", which is
|
||||||
|
// what "could not start" means to every caller above.
|
||||||
|
lock (procsLock) procs.Remove(procId);
|
||||||
|
var reason = $"nucleic-brokerd: could not start process: {e.Message}\n";
|
||||||
|
ProcOutput(procId, stderr: true, System.Text.Encoding.UTF8.GetBytes(reason));
|
||||||
|
ProcExited(procId, 126);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
case "proc.stdin":
|
case "proc.stdin":
|
||||||
{
|
{
|
||||||
|
|||||||
+31
-2
@@ -64,14 +64,32 @@ public interface IWslc
|
|||||||
/// <summary>cgroup counters for the resource monitor; null when not running.</summary>
|
/// <summary>cgroup counters for the resource monitor; null when not running.</summary>
|
||||||
Task<ContainerStatsInfo?> ContainerStatsAsync(string name, CancellationToken ct);
|
Task<ContainerStatsInfo?> ContainerStatsAsync(string name, CancellationToken ct);
|
||||||
|
|
||||||
/// <summary>Start a process in a running container. `procId` is minted by the broker and
|
/// <summary>
|
||||||
/// keys every event this process emits through <see cref="IBrokerEvents"/>.</summary>
|
/// Create a process in a running container and attach its event handlers, but **do not run
|
||||||
|
/// it** — the caller runs it with <see cref="IWslcProcess.StartAsync"/> once it has sent the
|
||||||
|
/// `procId` downstream. `procId` is minted by the broker and keys every event this process
|
||||||
|
/// emits through <see cref="IBrokerEvents"/>.
|
||||||
|
///
|
||||||
|
/// The two-step split is not ceremony. See <see cref="IWslcProcess.StartAsync"/>.
|
||||||
|
/// </summary>
|
||||||
Task<IWslcProcess> ExecAsync(long procId, ProcSpec spec, CancellationToken ct);
|
Task<IWslcProcess> ExecAsync(long procId, ProcSpec spec, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Control half of a running in-container process (output arrives via events).</summary>
|
/// <summary>Control half of a running in-container process (output arrives via events).</summary>
|
||||||
public interface IWslcProcess
|
public interface IWslcProcess
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Actually run the process. Separate from <see cref="IWslc.ExecAsync"/> because a short
|
||||||
|
/// command can finish before the `proc.exec` RESPONSE has been written: output and exit ride
|
||||||
|
/// the same ordered outbound queue, so starting first puts `proc.exit` on the wire ahead of
|
||||||
|
/// the `procId` that identifies it, and a client that registers interest on receiving that
|
||||||
|
/// procId waits forever. Observed on hardware with `echo` (docs/WINDOWS_PORT.md §13.3).
|
||||||
|
///
|
||||||
|
/// This is the same reasoning that makes wslc itself split `CreateProcess` from `Start` — so
|
||||||
|
/// handlers can attach before output flows — applied one level up, to the RPC boundary.
|
||||||
|
/// </summary>
|
||||||
|
Task StartAsync(CancellationToken ct);
|
||||||
|
|
||||||
Task WriteStdinAsync(ReadOnlyMemory<byte> data, CancellationToken ct);
|
Task WriteStdinAsync(ReadOnlyMemory<byte> data, CancellationToken ct);
|
||||||
Task CloseStdinAsync(CancellationToken ct);
|
Task CloseStdinAsync(CancellationToken ct);
|
||||||
Task SignalAsync(int signal, CancellationToken ct);
|
Task SignalAsync(int signal, CancellationToken ct);
|
||||||
@@ -107,6 +125,17 @@ public sealed class WslcError(string kind, string message) : Exception(message)
|
|||||||
/// permanent capability gap, not a transient failure — hostd must not retry.</summary>
|
/// permanent capability gap, not a transient failure — hostd must not retry.</summary>
|
||||||
public const string Unsupported = "unsupported";
|
public const string Unsupported = "unsupported";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A CONTAINER of that name already exists in the session. Distinct from
|
||||||
|
/// <see cref="SessionExists"/> because wslc answers `ERROR_ALREADY_EXISTS` for both and the
|
||||||
|
/// remedies differ completely — remove one container, versus restart the whole WSL stack.
|
||||||
|
///
|
||||||
|
/// Reachable today because this broker's container roster is process-local (there is no
|
||||||
|
/// enumeration on the compat surface), so a container left behind by a crashed broker holds
|
||||||
|
/// its name against every later one. See docs/WINDOWS_PORT.md §13.3.
|
||||||
|
/// </summary>
|
||||||
|
public const string AlreadyExists = "already_exists";
|
||||||
|
|
||||||
/// <summary>A session of that name is already running and this facade cannot re-adopt it
|
/// <summary>A session of that name is already running and this facade cannot re-adopt it
|
||||||
/// (the compat SDK's `Start()` answers ERROR_ALREADY_EXISTS, and its constructor is lazy, so
|
/// (the compat SDK's `Start()` answers ERROR_ALREADY_EXISTS, and its constructor is lazy, so
|
||||||
/// a second handle is not a second session). Distinct from <see cref="StartFailed"/> because
|
/// a second handle is not a second session). Distinct from <see cref="StartFailed"/> because
|
||||||
|
|||||||
@@ -72,7 +72,17 @@ public sealed class WslcFacade : IWslc
|
|||||||
private readonly Dictionary<string, Entry> containers = [];
|
private readonly Dictionary<string, Entry> containers = [];
|
||||||
private readonly Lock containersLock = new();
|
private readonly Lock containersLock = new();
|
||||||
|
|
||||||
private sealed record Entry(Sdk.Container Container, string Image);
|
/// <summary>
|
||||||
|
/// A container handle plus what we have learned about it. <c>SetprivWorks</c> is resolved
|
||||||
|
/// lazily on the first uid-dropping exec and then cached — see
|
||||||
|
/// <see cref="ResolvePrivilegeDropAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class Entry(Sdk.Container container, string image)
|
||||||
|
{
|
||||||
|
internal Sdk.Container Container { get; } = container;
|
||||||
|
internal string Image { get; } = image;
|
||||||
|
internal bool? SetprivWorks { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public string? WslcVersion
|
public string? WslcVersion
|
||||||
{
|
{
|
||||||
@@ -541,7 +551,7 @@ public sealed class WslcFacade : IWslc
|
|||||||
|
|
||||||
// MARK: - Processes
|
// MARK: - Processes
|
||||||
|
|
||||||
public Task<IWslcProcess> ExecAsync(long procId, ProcSpec spec, CancellationToken ct)
|
public async Task<IWslcProcess> ExecAsync(long procId, ProcSpec spec, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (spec.Tty)
|
if (spec.Tty)
|
||||||
// Not a failure to retry: ProcessSettings has no Terminal and Process has no resize.
|
// Not a failure to retry: ProcessSettings has no Terminal and Process has no resize.
|
||||||
@@ -553,7 +563,7 @@ public sealed class WslcFacade : IWslc
|
|||||||
var container = RequireContainer(spec.Container);
|
var container = RequireContainer(spec.Container);
|
||||||
var settings = new Sdk.ProcessSettings
|
var settings = new Sdk.ProcessSettings
|
||||||
{
|
{
|
||||||
CommandLine = WithPrivilegeDrop(spec).ToList(), // CommandLine, not CmdLine
|
CommandLine = (await WithPrivilegeDropAsync(spec, ct).ConfigureAwait(false)).ToList(),
|
||||||
OutputMode = Sdk.ProcessOutputMode.Event,
|
OutputMode = Sdk.ProcessOutputMode.Event,
|
||||||
};
|
};
|
||||||
if (spec.Cwd is { } cwd) settings.WorkingDirectory = cwd;
|
if (spec.Cwd is { } cwd) settings.WorkingDirectory = cwd;
|
||||||
@@ -578,27 +588,35 @@ public sealed class WslcFacade : IWslc
|
|||||||
process.ErrorReceived += data => events?.ProcOutput(procId, stderr: true, data);
|
process.ErrorReceived += data => events?.ProcOutput(procId, stderr: true, data);
|
||||||
process.Exited += code => events?.ProcExited(procId, code);
|
process.Exited += code => events?.ProcExited(procId, code);
|
||||||
|
|
||||||
try
|
// NOT started here — BrokerService starts it after the `procId` response is on the wire.
|
||||||
{
|
// See IWslcProcess.StartAsync for why that ordering is load-bearing.
|
||||||
process.Start();
|
return new WslcProcess(process);
|
||||||
}
|
|
||||||
catch (Exception e) when (e is not WslcError)
|
|
||||||
{
|
|
||||||
process.Dispose();
|
|
||||||
throw Translate(e, WslcError.StartFailed);
|
|
||||||
}
|
|
||||||
return Task.FromResult<IWslcProcess>(new WslcProcess(process));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// <c>ProcessSettings</c> has no <c>UserId</c>/<c>GroupId</c>, so dropping to the agent uid is
|
/// <c>ProcessSettings</c> has no <c>UserId</c>/<c>GroupId</c>, so dropping to the agent uid is
|
||||||
/// done in-guest by wrapping argv — the fallback §3.2 always named, which costs nothing
|
/// done in-guest by wrapping argv — the fallback §3.2 always named, which costs nothing
|
||||||
/// because the interceptors and nash never look at the numeric uid.
|
/// because the interceptors and nash never look at the numeric uid.
|
||||||
|
///
|
||||||
|
/// argv is passed to `setpriv` directly rather than through a shell, so nothing here can be
|
||||||
|
/// quoted wrong or injected into.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IReadOnlyList<string> WithPrivilegeDrop(ProcSpec spec)
|
private async Task<IReadOnlyList<string>> WithPrivilegeDropAsync(
|
||||||
|
ProcSpec spec, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (spec.Uid is not { } uid || uid == 0) return spec.Argv;
|
if (spec.Uid is not { } uid || uid == 0) return spec.Argv;
|
||||||
var gid = spec.Gid ?? uid;
|
var gid = spec.Gid ?? uid;
|
||||||
|
|
||||||
|
if (!await ResolvePrivilegeDropAsync(spec.Container, ct).ConfigureAwait(false))
|
||||||
|
// Refuse rather than run the agent as root. This path exists because the alternative
|
||||||
|
// is a silent privilege escalation: the caller asked for uid 501 and got 0, in the
|
||||||
|
// one place the sandbox's user separation is enforced.
|
||||||
|
throw new WslcError(
|
||||||
|
WslcError.Unsupported,
|
||||||
|
$"cannot drop to uid {uid} in this image: it has no util-linux `setpriv` "
|
||||||
|
+ "(BusyBox ships a `setpriv` that does not support --reuid). Use an image with "
|
||||||
|
+ "util-linux, as the narOS agent image does — refusing to run as root instead.");
|
||||||
|
|
||||||
return
|
return
|
||||||
[
|
[
|
||||||
"setpriv", $"--reuid={uid}", $"--regid={gid}", "--init-groups", "--",
|
"setpriv", $"--reuid={uid}", $"--regid={gid}", "--init-groups", "--",
|
||||||
@@ -606,6 +624,48 @@ public sealed class WslcFacade : IWslc
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Does this image have a `setpriv` that can actually change uid? Probed once per container
|
||||||
|
/// and cached, because the answer is a property of the image and an extra exec per agent
|
||||||
|
/// command would not be free.
|
||||||
|
///
|
||||||
|
/// The probe is `setpriv --reuid=0 --regid=0 --init-groups -- true`: a no-op on util-linux,
|
||||||
|
/// and an "unrecognized option" failure on BusyBox's namesake, which accepts only capability
|
||||||
|
/// flags. Testing for the *binary* is not enough — BusyBox has one, it just cannot do this
|
||||||
|
/// (observed on hardware, docs/WINDOWS_PORT.md §13.3).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<bool> ResolvePrivilegeDropAsync(string name, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Entry entry;
|
||||||
|
lock (containersLock)
|
||||||
|
{
|
||||||
|
if (!containers.TryGetValue(name, out entry!))
|
||||||
|
throw new WslcError(WslcError.NotFound, $"no container named {name}");
|
||||||
|
if (entry.SetprivWorks is { } cached) return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool works;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (exitCode, _) = await RunCapturingAsync(
|
||||||
|
entry.Container,
|
||||||
|
["setpriv", "--reuid=0", "--regid=0", "--init-groups", "--", "true"],
|
||||||
|
TimeSpan.FromSeconds(15), ct).ConfigureAwait(false);
|
||||||
|
works = exitCode == 0;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
works = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (containersLock) entry.SetprivWorks = works;
|
||||||
|
if (!works)
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"wslc: container '{name}' has no usable setpriv — uid-dropping execs will be "
|
||||||
|
+ "refused rather than run as root");
|
||||||
|
return works;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Run to completion and capture stdout+stderr. The in-guest half of what the macOS
|
/// <summary>Run to completion and capture stdout+stderr. The in-guest half of what the macOS
|
||||||
/// engine's `runCapturing` does, and the only way stats and shim re-seeding work without a
|
/// engine's `runCapturing` does, and the only way stats and shim re-seeding work without a
|
||||||
/// second mechanism.</summary>
|
/// second mechanism.</summary>
|
||||||
@@ -654,6 +714,20 @@ public sealed class WslcFacade : IWslc
|
|||||||
|
|
||||||
private sealed class WslcProcess(Sdk.Process process) : IWslcProcess
|
private sealed class WslcProcess(Sdk.Process process) : IWslcProcess
|
||||||
{
|
{
|
||||||
|
public Task StartAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
process.Start();
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is not WslcError)
|
||||||
|
{
|
||||||
|
process.Dispose();
|
||||||
|
throw Translate(e, WslcError.StartFailed);
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
// stdin is a WinRT stream, not a WriteStdin call, and DataWriter is how you put bytes
|
// stdin is a WinRT stream, not a WriteStdin call, and DataWriter is how you put bytes
|
||||||
// into an IOutputStream. Held for the process lifetime and guarded, because hostd may
|
// into an IOutputStream. Held for the process lifetime and guarded, because hostd may
|
||||||
// pipeline proc.stdin writes and StoreAsync is not reentrant.
|
// pipeline proc.stdin writes and StoreAsync is not reentrant.
|
||||||
@@ -752,7 +826,13 @@ public sealed class WslcFacade : IWslc
|
|||||||
0x80040605 => WslcError.NotRunning, // WSLC_E_CONTAINER_NOT_RUNNING
|
0x80040605 => WslcError.NotRunning, // WSLC_E_CONTAINER_NOT_RUNNING
|
||||||
0x8004060F => WslcError.NotFound, // WSLC_E_SESSION_NOT_FOUND
|
0x8004060F => WslcError.NotFound, // WSLC_E_SESSION_NOT_FOUND
|
||||||
0x80040607 => WslcError.SessionExists, // WSLC_E_SESSION_RESERVED
|
0x80040607 => WslcError.SessionExists, // WSLC_E_SESSION_RESERVED
|
||||||
0x800700B7 => WslcError.SessionExists, // ERROR_ALREADY_EXISTS
|
// ERROR_ALREADY_EXISTS is CONTEXT-FREE: wslc returns it for a session name conflict
|
||||||
|
// AND a container name conflict. It used to map to session_exists here, which made a
|
||||||
|
// stale container report itself as a stuck session — a wrong diagnosis with a wrong
|
||||||
|
// remedy (`wsl --shutdown` instead of removing one container). The session paths catch
|
||||||
|
// this code by number before reaching Translate, so anything arriving here is the
|
||||||
|
// other kind.
|
||||||
|
0x800700B7 => WslcError.AlreadyExists, // ERROR_ALREADY_EXISTS (container name in use)
|
||||||
// Nothing installed vs. installed-but-too-old: opposite diagnoses, and both mean the
|
// Nothing installed vs. installed-but-too-old: opposite diagnoses, and both mean the
|
||||||
// sandbox is unusable rather than this call being wrong.
|
// sandbox is unusable rather than this call being wrong.
|
||||||
0x80040154 => WslcError.Unavailable, // REGDB_E_CLASSNOTREG
|
0x80040154 => WslcError.Unavailable, // REGDB_E_CLASSNOTREG
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ internal sealed class BrokerClient : IAsyncDisposable
|
|||||||
/// reader thread. Handlers must not block.</summary>
|
/// reader thread. Handlers must not block.</summary>
|
||||||
internal event Action<string, JsonElement>? Notification;
|
internal event Action<string, JsonElement>? Notification;
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
internal bool Verbose;
|
||||||
|
|
||||||
private BrokerClient(Process process) => this.process = process;
|
private BrokerClient(Process process) => this.process = process;
|
||||||
|
|
||||||
internal static BrokerClient Spawn(string brokerPath)
|
internal static BrokerClient Spawn(string brokerPath)
|
||||||
@@ -59,6 +64,8 @@ internal sealed class BrokerClient : IAsyncDisposable
|
|||||||
for (string? line; (line = await process.StandardOutput.ReadLineAsync()) is not null;)
|
for (string? line; (line = await process.StandardOutput.ReadLineAsync()) is not null;)
|
||||||
{
|
{
|
||||||
if (line.Length == 0) continue;
|
if (line.Length == 0) continue;
|
||||||
|
if (Verbose)
|
||||||
|
Console.WriteLine($" [<-] {(line.Length > 300 ? line[..300] + "…" : line)}");
|
||||||
JsonElement root;
|
JsonElement root;
|
||||||
try { root = JsonDocument.Parse(line).RootElement.Clone(); }
|
try { root = JsonDocument.Parse(line).RootElement.Clone(); }
|
||||||
catch (JsonException) { Console.WriteLine($" [unparseable] {line}"); continue; }
|
catch (JsonException) { Console.WriteLine($" [unparseable] {line}"); continue; }
|
||||||
@@ -101,6 +108,7 @@ internal sealed class BrokerClient : IAsyncDisposable
|
|||||||
var request = args is null
|
var request = args is null
|
||||||
? $$"""{"jsonrpc":"2.0","id":{{id}},"method":"{{method}}"}"""
|
? $$"""{"jsonrpc":"2.0","id":{{id}},"method":"{{method}}"}"""
|
||||||
: $$"""{"jsonrpc":"2.0","id":{{id}},"method":"{{method}}","params":{{JsonSerializer.Serialize(args)}}}""";
|
: $$"""{"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.WriteAsync(request + "\n");
|
||||||
await process.StandardInput.FlushAsync();
|
await process.StandardInput.FlushAsync();
|
||||||
|
|
||||||
|
|||||||
+51
-18
@@ -40,6 +40,7 @@ internal static class Program
|
|||||||
// does not: alpine's PID 1 is /bin/sh, which exits immediately with no tty, so the
|
// does not: alpine's PID 1 is /bin/sh, which exits immediately with no tty, so the
|
||||||
// container is `Exited` before the first exec and every later step fails `not_running`.
|
// container is `Exited` before the first exec and every later step fails `not_running`.
|
||||||
var sleepInit = args.Contains("--sleep-init");
|
var sleepInit = args.Contains("--sleep-init");
|
||||||
|
var verbose = args.Contains("--verbose");
|
||||||
|
|
||||||
if (broker is null || !File.Exists(broker))
|
if (broker is null || !File.Exists(broker))
|
||||||
{
|
{
|
||||||
@@ -57,7 +58,7 @@ internal static class Program
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await RunScenarioAsync(broker, sessionName, image, container, repo, iterations, sleepInit);
|
await RunScenarioAsync(broker, sessionName, image, container, repo, iterations, sleepInit, verbose);
|
||||||
if (!skipRecovery) await RunRecoveryAsync(broker, sessionName);
|
if (!skipRecovery) await RunRecoveryAsync(broker, sessionName);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -73,9 +74,10 @@ internal static class Program
|
|||||||
|
|
||||||
private static async Task RunScenarioAsync(
|
private static async Task RunScenarioAsync(
|
||||||
string brokerPath, string sessionName, string image, string containerName,
|
string brokerPath, string sessionName, string image, string containerName,
|
||||||
string repo, int iterations, bool sleepInit)
|
string repo, int iterations, bool sleepInit, bool verbose)
|
||||||
{
|
{
|
||||||
await using var broker = BrokerClient.Spawn(brokerPath);
|
await using var broker = BrokerClient.Spawn(brokerPath);
|
||||||
|
broker.Verbose = verbose;
|
||||||
|
|
||||||
// Pull progress is high-rate; collapse it to one line per phase change so the transcript
|
// 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.
|
// stays readable but a stalled pull is still visible.
|
||||||
@@ -164,13 +166,23 @@ internal static class Program
|
|||||||
Console.WriteLine($" exit {code}: {output.Trim()}");
|
Console.WriteLine($" exit {code}: {output.Trim()}");
|
||||||
|
|
||||||
Step("exec: uid drop (setpriv wrapper, §3.2)");
|
Step("exec: uid drop (setpriv wrapper, §3.2)");
|
||||||
// ProcessSettings has no uid/gid, so the facade wraps argv in setpriv. If the image lacks
|
// ProcessSettings has no uid/gid, so the facade wraps argv in setpriv. A BusyBox image has
|
||||||
// util-linux this is where that shows, and the fallback is `su agent -c`.
|
// a setpriv that cannot change uid, and the facade now REFUSES rather than silently
|
||||||
|
// running the agent as root — so `unsupported` here is correct behaviour on such an image,
|
||||||
|
// not a failure of the run.
|
||||||
|
try
|
||||||
|
{
|
||||||
var (idCode, idOut) = await ExecAsync(
|
var (idCode, idOut) = await ExecAsync(
|
||||||
broker, containerName, ["/bin/sh", "-c", "id -u; id -g"], uid: 501, gid: 501);
|
broker, containerName, ["/bin/sh", "-c", "id -u; id -g"], uid: 501, gid: 501);
|
||||||
Console.WriteLine(idCode == 0
|
Console.WriteLine(idCode == 0
|
||||||
? $" uid/gid inside container: {idOut.Replace("\n", "/").Trim('/')}"
|
? $" uid/gid inside container: {idOut.Replace("\n", "/").Trim('/')}"
|
||||||
: $" setpriv wrapper FAILED (exit {idCode}): {idOut.Trim()} — try `su agent -c`");
|
: $" ran but reported failure (exit {idCode}): {idOut.Trim()}");
|
||||||
|
}
|
||||||
|
catch (BrokerError e) when (e.Kind == "unsupported")
|
||||||
|
{
|
||||||
|
Console.WriteLine($" REFUSED (correctly): {e.Message}");
|
||||||
|
Console.WriteLine(" → expected on a BusyBox image; narOS ships util-linux.");
|
||||||
|
}
|
||||||
|
|
||||||
Step("the mounted worktree");
|
Step("the mounted worktree");
|
||||||
var (lsCode, lsOut) = await ExecAsync(broker, containerName,
|
var (lsCode, lsOut) = await ExecAsync(broker, containerName,
|
||||||
@@ -200,10 +212,26 @@ internal static class Program
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static async Task MeasureNinePAsync(BrokerClient broker, string container, int iterations)
|
private static async Task MeasureNinePAsync(BrokerClient broker, string container, int iterations)
|
||||||
{
|
{
|
||||||
Step($"9P vs ext4 — `git status` x{iterations} (§15 risk, D8)");
|
// `find -type f` rather than `git status`, because it needs only busybox and measures the
|
||||||
|
// same thing that makes git slow over a mount: a full lstat() traversal of the tree. Using
|
||||||
|
// git would make the number depend on the image having git, which is what derailed the
|
||||||
|
// first attempt at this measurement.
|
||||||
|
var probe = "find . -type f | wc -l";
|
||||||
|
var (gitCode, _) = await ExecAsync(broker, container, ["/bin/sh", "-c", "command -v git"]);
|
||||||
|
if (gitCode == 0)
|
||||||
|
{
|
||||||
|
probe = "git status --porcelain >/dev/null";
|
||||||
|
Console.WriteLine(" (git present — measuring `git status`)");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine(" (no git in this image — measuring `find -type f`, which is the "
|
||||||
|
+ "lstat traversal that dominates `git status`)");
|
||||||
|
}
|
||||||
|
|
||||||
var mounted = await TimeCommandAsync(
|
Step($"9P vs ext4 — `{probe}` x{iterations} (§15 risk, D8)");
|
||||||
broker, container, "cd /work && git status --porcelain >/dev/null", iterations);
|
|
||||||
|
var mounted = await TimeCommandAsync(broker, container, $"cd /work && {probe}", iterations);
|
||||||
Report("/work (NTFS via 9P)", mounted);
|
Report("/work (NTFS via 9P)", mounted);
|
||||||
|
|
||||||
Console.WriteLine(" copying the worktree to container-local ext4…");
|
Console.WriteLine(" copying the worktree to container-local ext4…");
|
||||||
@@ -217,8 +245,7 @@ internal static class Program
|
|||||||
}
|
}
|
||||||
Console.WriteLine($" copied in {copyWatch.Elapsed.TotalSeconds:F1}s");
|
Console.WriteLine($" copied in {copyWatch.Elapsed.TotalSeconds:F1}s");
|
||||||
|
|
||||||
var local = await TimeCommandAsync(
|
var local = await TimeCommandAsync(broker, container, $"cd /tmp/ext4 && {probe}", iterations);
|
||||||
broker, container, "cd /tmp/ext4 && git status --porcelain >/dev/null", iterations);
|
|
||||||
Report("/tmp/ext4 (container-local)", local);
|
Report("/tmp/ext4 (container-local)", local);
|
||||||
|
|
||||||
if (mounted.Count > 0 && local.Count > 0)
|
if (mounted.Count > 0 && local.Count > 0)
|
||||||
@@ -330,12 +357,22 @@ internal static class Program
|
|||||||
{
|
{
|
||||||
var output = new MemoryStream();
|
var output = new MemoryStream();
|
||||||
var exited = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
|
var exited = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
long procId = -1;
|
|
||||||
|
|
||||||
|
// Deliberately NOT filtered by procId.
|
||||||
|
//
|
||||||
|
// The broker now guarantees the `proc.exec` response precedes any notification, but that
|
||||||
|
// is not sufficient for a client: completing the response's TaskCompletionSource only
|
||||||
|
// SCHEDULES the awaiting continuation, so the reader thread can dispatch the very next
|
||||||
|
// line — proc.stdout, or proc.exit — before the continuation has recorded the procId.
|
||||||
|
// Filtering on a not-yet-assigned procId silently drops the exit and hangs forever, which
|
||||||
|
// is exactly how this spike hung twice (docs/WINDOWS_PORT.md §13.3).
|
||||||
|
//
|
||||||
|
// Safe here because the spike runs one process at a time and awaits each to completion.
|
||||||
|
// A real client cannot take this shortcut: it must buffer notifications for procIds it
|
||||||
|
// has not yet learned. Worth checking `WslcBrokerClient.swift` for the same race.
|
||||||
void OnNotification(string method, JsonElement args)
|
void OnNotification(string method, JsonElement args)
|
||||||
{
|
{
|
||||||
if (!args.TryGetProperty("procId", out var idElement)) return;
|
if (!args.TryGetProperty("procId", out _)) return;
|
||||||
if (idElement.GetInt64() != Volatile.Read(ref procId)) return;
|
|
||||||
switch (method)
|
switch (method)
|
||||||
{
|
{
|
||||||
case "proc.stdout":
|
case "proc.stdout":
|
||||||
@@ -355,11 +392,7 @@ internal static class Program
|
|||||||
object spec = uid is null
|
object spec = uid is null
|
||||||
? new { container, argv, tty = false }
|
? new { container, argv, tty = false }
|
||||||
: new { container, argv, uid, gid = gid ?? uid, tty = false };
|
: new { container, argv, uid, gid = gid ?? uid, tty = false };
|
||||||
var result = await broker.CallAsync("proc.exec", spec);
|
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));
|
var code = await exited.Task.WaitAsync(TimeSpan.FromSeconds(timeoutSeconds));
|
||||||
lock (output) return (code, System.Text.Encoding.UTF8.GetString(output.ToArray()));
|
lock (output) return (code, System.Text.Encoding.UTF8.GetString(output.ToArray()));
|
||||||
|
|||||||
Reference in New Issue
Block a user