e2e: drive each client CLI through one long-lived batch process

The cross-language e2e matrix spawned one CLI process per operation —
~250 per client — paying a process (and, for the Java CLI, a full JVM)
cold-start every time. The Java leg alone ran ~16 minutes.

Each client CLI (dotnet, go, rust, python, java) gains a `batch`
subcommand: a single process that reads one command line from stdin,
runs it through the normal subcommand dispatch, writes the JSON result,
then a line containing exactly `__MXGW_BATCH_EOR__`. A failing command
writes its `{"error":...}` envelope and the loop continues.

run-client-e2e-tests.ps1 now launches one batch process per client and
pings every operation through its stdin/stdout, so startup is paid once
per client. The orchestration and assertions are unchanged; the parity
and auth phases now read the `{"error":...}` envelope instead of a
process exit code.

Full 5-client matrix with -VerifyWrite: ~15 min, down from ~35; the Java
leg dropped from ~16 min to ~2-3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-21 06:20:13 -04:00
parent c1ff8c94e8
commit 6126099cdb
10 changed files with 970 additions and 47 deletions
@@ -25,7 +25,7 @@ public static class MxGatewayClientCli
TextWriter standardOutput,
TextWriter standardError)
{
return RunAsync(args, standardOutput, standardError)
return RunAsync(args, standardOutput, standardError, clientFactory: null, standardInput: null)
.GetAwaiter()
.GetResult();
}
@@ -35,11 +35,13 @@ public static class MxGatewayClientCli
/// <param name="standardOutput">TextWriter for command output.</param>
/// <param name="standardError">TextWriter for error messages.</param>
/// <param name="clientFactory">Optional factory to create the gateway client; defaults to MxGatewayClient.Create.</param>
/// <param name="standardInput">Optional TextReader for batch-mode stdin; defaults to <see cref="Console.In"/>.</param>
public static Task<int> RunAsync(
string[] args,
TextWriter standardOutput,
TextWriter standardError,
Func<MxGatewayClientOptions, IMxGatewayCliClient>? clientFactory = null)
Func<MxGatewayClientOptions, IMxGatewayCliClient>? clientFactory = null,
TextReader? standardInput = null)
{
ArgumentNullException.ThrowIfNull(args);
ArgumentNullException.ThrowIfNull(standardOutput);
@@ -49,14 +51,19 @@ public static class MxGatewayClientCli
args,
standardOutput,
standardError,
clientFactory ?? CreateDefaultClient);
clientFactory ?? CreateDefaultClient,
standardInput ?? Console.In);
}
private const string BatchEndOfRecord = "__MXGW_BATCH_EOR__";
private static async Task<int> RunCoreAsync(
string[] args,
TextWriter standardOutput,
TextWriter standardError,
Func<MxGatewayClientOptions, IMxGatewayCliClient> clientFactory)
Func<MxGatewayClientOptions, IMxGatewayCliClient> clientFactory,
TextReader standardInput,
bool forceJsonErrors = false)
{
if (args.Length is 0 || IsHelp(args[0]))
{
@@ -65,6 +72,12 @@ public static class MxGatewayClientCli
}
string command = args[0].ToLowerInvariant();
if (command is "batch")
{
return await RunBatchAsync(standardOutput, clientFactory, standardInput).ConfigureAwait(false);
}
CliArguments arguments = new(args.Skip(1));
try
@@ -142,7 +155,7 @@ public static class MxGatewayClientCli
string? apiKey = TryResolveApiKey(arguments);
string message = MxGatewayCliSecretRedactor.Redact(exception.Message, apiKey);
if (arguments.HasFlag("json"))
if (forceJsonErrors || arguments.HasFlag("json"))
{
standardError.WriteLine(JsonSerializer.Serialize(
new { error = message, type = exception.GetType().Name },
@@ -239,6 +252,82 @@ public static class MxGatewayClientCli
return cancellation;
}
/// <summary>
/// Runs the CLI in batch mode: reads one command line at a time from
/// <paramref name="standardInput"/>, dispatches it through the normal
/// routing, writes all output to <paramref name="standardOutput"/>, and
/// then appends <see cref="BatchEndOfRecord"/> as a sentinel so the
/// caller can delimit command results. Continues on failure; errors are
/// written as JSON to <paramref name="standardOutput"/> (not stderr) so
/// that the harness sees them inside the same delimited block. Exits 0
/// on EOF or empty line.
/// </summary>
private static async Task<int> RunBatchAsync(
TextWriter standardOutput,
Func<MxGatewayClientOptions, IMxGatewayCliClient> clientFactory,
TextReader standardInput)
{
while (true)
{
string? line = await standardInput.ReadLineAsync().ConfigureAwait(false);
// EOF or empty line signals clean exit.
if (line is null || line.Length is 0)
{
return 0;
}
// Split on runs of ASCII whitespace — no quoting support by design.
string[] lineArgs = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
// Per-command output is buffered so we can redirect errors to stdout.
using StringWriter commandOutput = new();
// Errors in batch mode go to stdout (same delimited block), formatted as JSON.
// We use a capturing error writer and re-emit through commandOutput after the
// command returns, so the EOR sentinel always follows the complete result.
using StringWriter commandError = new();
try
{
await RunCoreAsync(
lineArgs,
commandOutput,
commandError,
clientFactory,
standardInput,
forceJsonErrors: true)
.ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Unexpected exception that escaped RunCoreAsync (shouldn't happen, but be safe).
commandError.WriteLine(JsonSerializer.Serialize(
new { error = exception.Message, type = exception.GetType().Name },
JsonOptions));
}
// Write any buffered normal output first.
string commandOutputText = commandOutput.ToString();
if (commandOutputText.Length > 0)
{
standardOutput.Write(commandOutputText);
}
// Then any error output — in batch mode it belongs on stdout so the harness
// sees it inside the delimited record.
string commandErrorText = commandError.ToString();
if (commandErrorText.Length > 0)
{
standardOutput.Write(commandErrorText);
}
// Write the end-of-record sentinel and flush so the harness can unblock.
standardOutput.WriteLine(BatchEndOfRecord);
await standardOutput.FlushAsync().ConfigureAwait(false);
}
}
private static Task<int> OpenSessionAsync(
CliArguments arguments,
IMxGatewayCliClient client,
@@ -1861,6 +1950,7 @@ public static class MxGatewayClientCli
private static void WriteUsage(TextWriter writer)
{
writer.WriteLine("mxgw-dotnet batch (reads commands from stdin; writes output + __MXGW_BATCH_EOR__ after each)");
writer.WriteLine("mxgw-dotnet version [--json]");
writer.WriteLine("mxgw-dotnet ping --session-id <id> [--json]");
writer.WriteLine("mxgw-dotnet open-session [--client-name <name>] [--json]");
@@ -468,6 +468,141 @@ public sealed class MxGatewayClientCliTests
Assert.Contains("\"objectCount\": 99", text);
}
/// <summary>Verifies that batch mode executes a single no-gateway command and writes the EOR sentinel.</summary>
[Fact]
public async Task RunAsync_Batch_SingleVersionCommand_WritesOutputAndEorSentinel()
{
using var output = new StringWriter();
using var error = new StringWriter();
using var stdin = new StringReader("version --json\n");
int exitCode = await MxGatewayClientCli.RunAsync(
["batch"],
output,
error,
clientFactory: null,
standardInput: stdin);
Assert.Equal(0, exitCode);
string text = output.ToString();
Assert.Contains("\"gatewayProtocolVersion\"", text);
Assert.Contains("__MXGW_BATCH_EOR__", text);
// Sentinel must appear after the output, not before.
int outputIdx = text.IndexOf("gatewayProtocolVersion", StringComparison.Ordinal);
int eorIdx = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
Assert.True(outputIdx < eorIdx, "EOR sentinel must follow command output.");
Assert.Equal(string.Empty, error.ToString());
}
/// <summary>Verifies that batch mode processes two commands sequentially and writes two EOR sentinels.</summary>
[Fact]
public async Task RunAsync_Batch_TwoVersionCommands_WritesTwoEorSentinels()
{
using var output = new StringWriter();
using var error = new StringWriter();
// Two commands followed by EOF (end of string).
using var stdin = new StringReader("version\nversion --json\n");
int exitCode = await MxGatewayClientCli.RunAsync(
["batch"],
output,
error,
clientFactory: null,
standardInput: stdin);
Assert.Equal(0, exitCode);
string text = output.ToString();
int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
int secondEor = text.IndexOf("__MXGW_BATCH_EOR__", firstEor + 1, StringComparison.Ordinal);
Assert.True(firstEor >= 0, "First EOR sentinel must be present.");
Assert.True(secondEor > firstEor, "Second EOR sentinel must follow first.");
Assert.Equal(string.Empty, error.ToString());
}
/// <summary>Verifies that batch mode on EOF (empty stdin) exits 0 immediately without writing any sentinel.</summary>
[Fact]
public async Task RunAsync_Batch_EmptyStdin_ExitsZeroWithNoOutput()
{
using var output = new StringWriter();
using var error = new StringWriter();
using var stdin = new StringReader(string.Empty);
int exitCode = await MxGatewayClientCli.RunAsync(
["batch"],
output,
error,
clientFactory: null,
standardInput: stdin);
Assert.Equal(0, exitCode);
Assert.Equal(string.Empty, output.ToString());
Assert.Equal(string.Empty, error.ToString());
}
/// <summary>
/// Verifies that batch mode continues after a command failure and writes the error JSON
/// to stdout (not stderr), followed by the EOR sentinel.
/// </summary>
[Fact]
public async Task RunAsync_Batch_CommandFailure_WritesErrorJsonToStdoutAndContinues()
{
using var output = new StringWriter();
using var error = new StringWriter();
// First line: a gateway command with no API key (will fail).
// Second line: version (will succeed).
using var stdin = new StringReader("open-session --endpoint http://localhost:5000\nversion --json\n");
int exitCode = await MxGatewayClientCli.RunAsync(
["batch"],
output,
error,
clientFactory: _ => throw new InvalidOperationException("injected failure"),
standardInput: stdin);
Assert.Equal(0, exitCode);
string text = output.ToString();
// Error record: the error JSON must be on stdout, not stderr.
Assert.Contains("\"error\"", text);
Assert.Equal(string.Empty, error.ToString());
// Both records must be present.
int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
int secondEor = text.IndexOf("__MXGW_BATCH_EOR__", firstEor + 1, StringComparison.Ordinal);
Assert.True(firstEor >= 0, "EOR after failed command must be present.");
Assert.True(secondEor > firstEor, "EOR after successful command must follow first EOR.");
// Second record must contain the version output.
string afterFirstEor = text[(firstEor + "__MXGW_BATCH_EOR__".Length)..];
Assert.Contains("\"gatewayProtocolVersion\"", afterFirstEor);
}
/// <summary>Verifies that batch mode treats an empty (blank) line as EOF and exits 0.</summary>
[Fact]
public async Task RunAsync_Batch_EmptyLine_ExitsZeroAfterPreviousCommands()
{
using var output = new StringWriter();
using var error = new StringWriter();
// One command, then an empty line (stop signal), then another command that must NOT run.
using var stdin = new StringReader("version --json\n\nversion --json\n");
int exitCode = await MxGatewayClientCli.RunAsync(
["batch"],
output,
error,
clientFactory: null,
standardInput: stdin);
Assert.Equal(0, exitCode);
string text = output.ToString();
// Only one EOR sentinel — the second command after the empty line must not execute.
int firstEor = text.IndexOf("__MXGW_BATCH_EOR__", StringComparison.Ordinal);
int secondEor = text.IndexOf("__MXGW_BATCH_EOR__", firstEor + 1, StringComparison.Ordinal);
Assert.True(firstEor >= 0, "One EOR sentinel must be present.");
Assert.Equal(-1, secondEor);
Assert.Equal(string.Empty, error.ToString());
}
/// <summary>Fake CLI client for testing.</summary>
private sealed class FakeCliClient : IMxGatewayCliClient
{