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
@@ -14,7 +14,11 @@ import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
import com.google.protobuf.Message;
import com.google.protobuf.util.JsonFormat;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
@@ -128,9 +132,90 @@ public final class MxGatewayCli implements Callable<Integer> {
commandLine.addSubcommand("galaxy-deploy-time", new GalaxyDeployTimeCommand());
commandLine.addSubcommand("galaxy-discover", new GalaxyDiscoverCommand());
commandLine.addSubcommand("galaxy-watch", new GalaxyWatchCommand());
commandLine.addSubcommand("batch", new BatchCommand(clientFactory));
return commandLine;
}
/** Sentinel written to stdout after every command result in batch mode. */
static final String BATCH_EOR = "__MXGW_BATCH_EOR__";
/**
* Reads one CLI invocation per stdin line, executes each via a fresh
* {@link CommandLine}, and writes {@value #BATCH_EOR} to stdout after
* every result. Errors are written as JSON to stdout so the harness
* sees them in the same stream, delimited by the same sentinel. The
* loop never terminates on command failure; only stdin EOF (or an
* empty line) ends the session.
*/
@Command(name = "batch", description = "Reads CLI invocations from stdin and executes them sequentially.")
static final class BatchCommand implements Callable<Integer> {
private final MxGatewayCliClientFactory clientFactory;
@Spec
private CommandSpec spec;
BatchCommand(MxGatewayCliClientFactory clientFactory) {
this.clientFactory = clientFactory;
}
@Override
public Integer call() {
PrintWriter out = spec.commandLine().getOut();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(System.in, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.isEmpty()) {
break;
}
String[] args = line.trim().split("\\s+");
if (args.length == 0 || (args.length == 1 && args[0].isEmpty())) {
continue;
}
StringWriter cmdOut = new StringWriter();
StringWriter cmdErr = new StringWriter();
PrintWriter cmdOutWriter = new PrintWriter(cmdOut, true);
PrintWriter cmdErrWriter = new PrintWriter(cmdErr, true);
try {
CommandLine cmd = commandLine(clientFactory);
cmd.setOut(cmdOutWriter);
cmd.setErr(cmdErrWriter);
int exitCode = cmd.execute(args);
cmdOutWriter.flush();
cmdErrWriter.flush();
String cmdOutput = cmdOut.toString();
if (!cmdOutput.isEmpty()) {
out.print(cmdOutput);
}
if (exitCode != 0) {
// Non-zero exit: emit the stderr content (if any) as a JSON
// error object to stdout so the harness can parse it in the
// same delimited stream.
String errText = cmdErr.toString().trim();
if (errText.isEmpty()) {
errText = "command exited with code " + exitCode;
}
Map<String, Object> errorPayload = new LinkedHashMap<>();
errorPayload.put("error", errText);
errorPayload.put("type", "error");
out.println(jsonObject(errorPayload));
}
} catch (Exception ex) {
Map<String, Object> errorPayload = new LinkedHashMap<>();
errorPayload.put("error", ex.getMessage() != null ? ex.getMessage() : ex.getClass().getName());
errorPayload.put("type", "error");
out.println(jsonObject(errorPayload));
}
out.println(BATCH_EOR);
out.flush();
}
} catch (java.io.IOException ex) {
// Stdin closed unexpectedly — treat as EOF and exit normally.
}
return 0;
}
}
abstract static class GalaxyCommand implements Callable<Integer> {
@Mixin
CommonOptions common = new CommonOptions();
@@ -4,8 +4,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
@@ -386,6 +389,90 @@ final class MxGatewayCliTests {
assertTrue(output.contains("TestMachine_002.TestChangingInt"), output);
}
// ---- Client.Java-027: batch subcommand ----
@Test
void batchCommandExecutesTwoCommandsAndEmitsEorAfterEach() {
String stdin = "version --json\nversion --json\n";
CliRun run = executeBatch(new FakeClientFactory(), stdin);
assertEquals(0, run.exitCode());
String out = run.output();
// Two EOR sentinels — one per input line.
int firstEor = out.indexOf(MxGatewayCli.BATCH_EOR);
int lastEor = out.lastIndexOf(MxGatewayCli.BATCH_EOR);
assertTrue(firstEor >= 0, "expected at least one EOR sentinel");
assertTrue(lastEor > firstEor, "expected two distinct EOR sentinels");
// Both results contain version JSON.
assertTrue(out.contains("\"clientVersion\""), out);
}
@Test
void batchCommandEmitsEorOnFailedCommand() {
// "open-session" without --endpoint / --api-key-env will fail against
// the FakeClientFactory (missing required option --session-id for
// close-session, for example). Use an unknown subcommand to provoke a
// picocli parse error which produces a non-zero exit code without
// hitting the gateway.
String stdin = "no-such-subcommand\nversion --json\n";
CliRun run = executeBatch(new FakeClientFactory(), stdin);
assertEquals(0, run.exitCode());
String out = run.output();
// Two EOR sentinels even though the first command failed.
int firstEor = out.indexOf(MxGatewayCli.BATCH_EOR);
int lastEor = out.lastIndexOf(MxGatewayCli.BATCH_EOR);
assertTrue(firstEor >= 0, "expected EOR after failed command");
assertTrue(lastEor > firstEor, "expected EOR after second (successful) command");
// The second command's result is present.
assertTrue(out.contains("\"clientVersion\""), out);
}
@Test
void batchCommandExitsZeroOnEmptyLine() {
// An empty line signals EOF-equivalent; loop exits immediately.
CliRun run = executeBatch(new FakeClientFactory(), "\n");
assertEquals(0, run.exitCode());
}
@Test
void batchCommandExitsZeroOnActualEof() {
CliRun run = executeBatch(new FakeClientFactory(), "");
assertEquals(0, run.exitCode());
}
@Test
void batchCommandDoesNotTerminateAfterFailedCommand() {
// Three lines: good, bad, good — all three EORs must appear and the
// third command must produce its output.
String stdin = "version --json\nno-such-subcommand\nversion --json\n";
CliRun run = executeBatch(new FakeClientFactory(), stdin);
assertEquals(0, run.exitCode());
String out = run.output();
long eorCount = out.lines()
.filter(l -> l.equals(MxGatewayCli.BATCH_EOR))
.count();
assertEquals(3, eorCount, "expected exactly 3 EOR sentinels, got: " + eorCount + "\nOutput:\n" + out);
}
/**
* Runs the CLI with {@code batch} as the subcommand, using the provided
* string as standard input content. Temporarily replaces {@link System#in}
* for the duration of the call.
*/
private static CliRun executeBatch(MxGatewayCli.MxGatewayCliClientFactory factory, String stdinContent) {
InputStream originalIn = System.in;
try {
System.setIn(new ByteArrayInputStream(stdinContent.getBytes(StandardCharsets.UTF_8)));
return execute(factory, "batch");
} finally {
System.setIn(originalIn);
}
}
private static CliRun execute(MxGatewayCli.MxGatewayCliClientFactory factory, String... args) {
StringWriter output = new StringWriter();
StringWriter errors = new StringWriter();