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]");