target, String flag, Boolean enabled) {
if (Boolean.TRUE.equals(enabled)) {
diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliCancellationToken.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliCancellationToken.java
new file mode 100644
index 0000000..c90d2db
--- /dev/null
+++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliCancellationToken.java
@@ -0,0 +1,14 @@
+package io.github.easy4j.opencli.core;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/** Cooperative cancellation for one local invocation; cancellation never affects another invocation. */
+public final class OpenCliCancellationToken {
+ private final AtomicBoolean cancelled = new AtomicBoolean();
+
+ /** Request cancellation. This operation is idempotent. */
+ public void cancel() { cancelled.set(true); }
+
+ /** @return whether cancellation has been requested */
+ public boolean isCancelled() { return cancelled.get(); }
+}
diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutionDetails.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutionDetails.java
new file mode 100644
index 0000000..98530e3
--- /dev/null
+++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutionDetails.java
@@ -0,0 +1,33 @@
+package io.github.easy4j.opencli.core;
+
+import lombok.Builder;
+import lombok.Getter;
+
+/** Immutable execution evidence. Observed byte counts describe bytes read, not bytes produced remotely. */
+@Getter
+@Builder
+public final class OpenCliExecutionDetails {
+ /** First terminal condition selected by the invocation owner. */
+ public enum TerminationReason {
+ PROCESS_EXIT, QUEUE_TIMEOUT, EXECUTION_TIMEOUT, OUTPUT_LIMIT,
+ CANCELLED, SPAWN_FAILED, IO_FAILURE, CLEANUP_UNCONFIRMED, RUNTIME_UNAVAILABLE
+ }
+
+ /** Confirmation concerns the directly owned child, not an arbitrary process tree. */
+ public enum CleanupState { NOT_STARTED, ROOT_EXIT_CONFIRMED, UNCONFIRMED }
+
+ private final TerminationReason terminationReason;
+ private final CleanupState cleanupState;
+ private final boolean processStarted;
+ private final boolean streamsDrained;
+ private final long stdoutCapturedBytes;
+ private final long stdoutObservedBytes;
+ private final boolean stdoutTruncated;
+ private final long stderrCapturedBytes;
+ private final long stderrObservedBytes;
+ private final boolean stderrTruncated;
+ private final long elapsedMillis;
+ private final long queueWaitMillis;
+ /** This portable backend does not claim ownership/termination of detached daemon descendants. */
+ private final boolean descendantsExitConfirmed;
+}
diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutor.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutor.java
index f313578..ebb35af 100644
--- a/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutor.java
+++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutor.java
@@ -2,114 +2,101 @@
import io.github.easy4j.opencli.OpenCliExecutionTarget;
import io.github.easy4j.opencli.OpenCliProperties;
+import io.github.easy4j.opencli.core.OpenCliExecutionDetails.TerminationReason;
+import io.github.easy4j.opencli.core.support.SubprocessExecutionSupport;
import io.github.easy4j.opencli.exception.OpenCliException;
import io.github.easy4j.opencli.exception.OpenCliExecutableFailureException;
import io.github.easy4j.opencli.exception.OpenCliNonZeroExitException;
import io.github.easy4j.opencli.exception.OpenCliTimeoutException;
-import io.github.easy4j.opencli.parser.OpenCliParsedFields;
import io.github.easy4j.opencli.remote.OpenCliArgvToCollectParser;
import io.github.easy4j.opencli.remote.OpenCliCollectRequest;
import io.github.easy4j.opencli.remote.OpenCliRemoteAgentHttpClient;
-import io.github.easy4j.opencli.core.support.SubprocessExecutionSupport;
import io.github.easy4j.opencli.util.OpenCliStrings;
-import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
+import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import lombok.Getter;
-import lombok.extern.slf4j.Slf4j;
import org.apache.commons.exec.CommandLine;
-import org.apache.commons.exec.DefaultExecuteResultHandler;
-import org.apache.commons.exec.ExecuteException;
-import org.apache.commons.exec.ExecuteWatchdog;
/**
- * 基于 Apache Commons Exec 的 OpenCLI 子进程执行封装。
- *
- * {@link #invoke(List)} 接受的参数为「紧跟可执行名之后」的完整 token 列表,形如
- * {@code [adapter, subcommand, ...]};本地模式下会自动拼接 {@link OpenCliProperties} 的
- * {@code leadingArguments}。
- *
- *
- * 当 {@link OpenCliProperties} 的 {@code executionTarget} 为
- * {@link OpenCliExecutionTarget#REMOTE_AGENT_HTTP} 时,
- * 通过 {@link OpenCliRemoteAgentHttpClient} 调用远端 {@code POST /collect};此时 {@code leadingArguments} 不参与请求,
- * argv 会被解析为 {@link OpenCliCollectRequest}(与 opencli-admin {@code agent_server} 契约一致)。
- *
- */
-@Slf4j
-@Getter/**
-
- * OpenCLI subprocess execution wrapper based on Apache Commons Exec.
- *
- * {@link #invoke(List)} accepts a token list that follows the executable name,
- * typically {@code [adapter, subcommand, ...]}. In local mode, {@link OpenCliProperties}
- * {@code leadingArguments} are automatically prepended.
+ * Literal-argv executor with a stable capacity owner, total local deadline and
+ * bounded output. Existing synchronous entry points are retained. Raw HTTP
+ * collect remains a separate legacy protocol, not a lossless process transport.
*
- * When {@link OpenCliProperties#getExecutionTarget()} is
- * {@link OpenCliExecutionTarget#REMOTE_AGENT_HTTP}, the invocation is forwarded to a
- * remote Agent via {@link OpenCliRemoteAgentHttpClient#collect(OpenCliCollectRequest)}.
-
- *
-
* @author Loong Wan
-
* @since 3.0.0
-
*/
-
+@Getter
public class OpenCliExecutor {
-
private final OpenCliProperties properties;
-
- /**
- * 懒加载,仅远程模式使用。
- */
+ private final OpenCliProcessRuntime processRuntime;
private volatile OpenCliRemoteAgentHttpClient remoteAgentHttpClient;
+ /** @param properties configuration; process capacity is captured once */
+ public OpenCliExecutor(OpenCliProperties properties) {
+ this(properties, new OpenCliProcessRuntime(Objects.requireNonNull(properties, "properties")
+ .getMaxConcurrentExecutions()));
+ }
+
/**
- * @param properties 运行时配置,不得为 null
+ * @param properties configuration
+ * @param processRuntime an explicitly shared, stable local capacity owner
*/
- public OpenCliExecutor(OpenCliProperties properties) {
+ public OpenCliExecutor(OpenCliProperties properties, OpenCliProcessRuntime processRuntime) {
this.properties = Objects.requireNonNull(properties, "properties");
- SubprocessExecutionSupport.configureMaxConcurrentExecutions(properties.getMaxConcurrentExecutions());
+ this.processRuntime = Objects.requireNonNull(processRuntime, "processRuntime");
+ }
+
+ /** @param adapterAndRest command and literal values @return execution result */
+ public OpenCliResult invoke(List adapterAndRest) {
+ return invokeInternal(adapterAndRest, new OpenCliCancellationToken(), false);
}
/**
- * 执行 {@code opencli ...} 完整 argv(不含可执行文件本身)。
+ * Cancellable local invocation. The legacy HTTP protocol does not claim a
+ * cancellable remote process; explicit tokens are rejected in remote mode.
*
- * @param adapterAndRest 至少包含 adapter 名,后续为子命令与 flag;不得为 null
- * @return 包含成功标记的执行结果
+ * @param adapterAndRest command and literal values
+ * @param cancellationToken cancellation for this invocation only
+ * @return execution result
*/
- public OpenCliResult invoke(List adapterAndRest) {
- Objects.requireNonNull(adapterAndRest, "adapterAndRest");
+ public OpenCliResult invoke(List adapterAndRest, OpenCliCancellationToken cancellationToken) {
+ return invokeInternal(adapterAndRest, Objects.requireNonNull(cancellationToken, "cancellationToken"), true);
+ }
+
+ private OpenCliResult invokeInternal(List adapterAndRest,
+ OpenCliCancellationToken cancellationToken, boolean explicitCancellation) {
+ long submittedAtNanos = System.nanoTime();
+ long timeoutMillis = properties.getCommandTimeoutMillis();
+ List tokens = OpenCliArgSupport.snapshotValues(adapterAndRest, "adapterAndRest");
+ if (tokens.isEmpty()) {
+ throw new IllegalArgumentException("adapterAndRest must contain at least the command identifier");
+ }
+ if (OpenCliStrings.isBlank(tokens.get(0))) {
+ throw new IllegalArgumentException("adapterAndRest[0] command identifier must not be blank");
+ }
if (properties.getExecutionTarget() == OpenCliExecutionTarget.REMOTE_AGENT_HTTP) {
- log.debug("OpenCLI invoke remote agent argvSize={}", adapterAndRest.size());
- OpenCliCollectRequest req =
- OpenCliArgvToCollectParser.parse(
- adapterAndRest,
- properties.getRemoteOutputFormat(),
- properties.getRemoteCollectMode(),
- properties.getRemoteCdpEndpoint());
+ if (explicitCancellation) {
+ throw new UnsupportedOperationException("Explicit process cancellation is local-only for legacy collect");
+ }
+ OpenCliCollectRequest req = OpenCliArgvToCollectParser.parse(tokens,
+ properties.getRemoteOutputFormat(), properties.getRemoteCollectMode(), properties.getRemoteCdpEndpoint());
return remoteAgent().collect(req);
}
- log.debug("OpenCLI invoke local argvSize={}", adapterAndRest.size());
- CommandLine cmd = buildCommandLine(adapterAndRest);
- return run(cmd);
+ CommandLine commandLine = buildCommandLine(tokens);
+ return run(commandLine, timeoutMillis, submittedAtNanos, cancellationToken);
}
- /**
- * @return 远程 Agent HTTP 客户端(懒加载)
- */
private OpenCliRemoteAgentHttpClient remoteAgent() {
- if (Objects.isNull(remoteAgentHttpClient)) {
+ if (remoteAgentHttpClient == null) {
synchronized (this) {
- if (Objects.isNull(remoteAgentHttpClient)) {
+ if (remoteAgentHttpClient == null) {
remoteAgentHttpClient = new OpenCliRemoteAgentHttpClient(properties);
}
}
@@ -117,211 +104,107 @@ private OpenCliRemoteAgentHttpClient remoteAgent() {
return remoteAgentHttpClient;
}
- /**
- * 便捷重载:可变参数形式。
- *
- * @param adapterAndRest adapter 及后续 CLI token
- * @return 执行结果
- */
+ /** @param adapterAndRest command and literal values @return execution result */
public OpenCliResult invoke(String... adapterAndRest) {
- List list = new ArrayList<>();
- if (Objects.nonNull(adapterAndRest)) {
- for (String s : adapterAndRest) {
- if (OpenCliStrings.isNotBlank(s)) {
- list.add(s.trim());
- }
- }
- }
- return invoke(list);
+ Objects.requireNonNull(adapterAndRest, "adapterAndRest");
+ return invoke(Arrays.asList(adapterAndRest));
}
- /**
- * 拼装 {@link CommandLine}:executable + leading + tokens。
- */
- private CommandLine buildCommandLine(List adapterAndRest) {
- if (adapterAndRest.isEmpty()) {
- throw new IllegalArgumentException("adapterAndRest must contain at least the adapter id");
- }
- String exe = properties.getExecutable();
- if (OpenCliStrings.isBlank(exe)) {
+ private CommandLine buildCommandLine(List tokens) {
+ String executable = properties.getExecutable();
+ if (OpenCliStrings.isBlank(executable)) {
throw new IllegalStateException("opencli.executable must not be blank");
}
- CommandLine cmd = new CommandLine(exe.trim());
- appendCleanArgs(cmd, properties.getLeadingArguments());
- appendCleanArgs(cmd, adapterAndRest);
+ String normalized = executable.trim();
+ String lower = normalized.toLowerCase(Locale.ROOT);
+ if (System.getProperty("os.name").startsWith("Windows")
+ && (lower.endsWith(".cmd") || lower.endsWith(".bat"))) {
+ throw new UnsupportedOperationException("Use a native node executable plus the CLI JavaScript path; batch shims are not literal argv transports");
+ }
+ CommandLine cmd = new LiteralCommandLine(normalized);
+ appendLiteralArgs(cmd, properties.getLeadingArguments(), "leadingArguments");
+ appendLiteralArgs(cmd, tokens, "adapterAndRest");
return cmd;
}
- private static void appendCleanArgs(CommandLine cmd, List args) {
- if (Objects.isNull(args) || args.isEmpty()) {
- return;
- }
- for (String a : args) {
- if (OpenCliStrings.isNotBlank(a)) {
- cmd.addArgument(a.trim(), false);
- }
- }
+ private static void appendLiteralArgs(CommandLine cmd, List values, String field) {
+ if (values == null) { return; }
+ for (String value : OpenCliArgSupport.snapshotValues(values, field)) { cmd.addArgument(value, false); }
}
- /**
- * 将 {@code --key=value} 以句柄安全形式追加(含空格时由 Commons Exec 处理)。
- *
- * @param cmd 命令行
- * @param key 必须以 {@code --} 开头
- * @param value 非空值
- */
+ /** Legacy explicit-quoting helper; not used by the literal process path. */
public static void appendQuotedKeyValue(CommandLine cmd, String key, String value) {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(value, "value");
if (!key.startsWith("--")) {
- throw new IllegalArgumentException("CLI key must start with '--', got: " + key);
+ throw new IllegalArgumentException("CLI key must start with '--'");
}
String prefix = key.endsWith("=") ? key.substring(0, key.length() - 1) : key;
cmd.addArgument(prefix + "=" + value, true);
}
- private OpenCliResult run(CommandLine commandLine) {
- long timeoutMs = properties.getCommandTimeoutMillis();
- if (timeoutMs <= 0) {
+ private OpenCliResult run(CommandLine commandLine, long timeoutMillis, long submittedAtNanos,
+ OpenCliCancellationToken cancellationToken) {
+ if (timeoutMillis <= 0) {
throw new IllegalStateException("opencli.command-timeout-millis must be positive");
}
-
- File workingDirectory = resolveWorkingDirectory();
- Map environment = buildEnvironment();
- SubprocessExecutionSupport.ExecutionRequest request =
- new SubprocessExecutionSupport.ExecutionRequest(
- commandLine, workingDirectory, environment, timeoutMs);
-
+ SubprocessExecutionSupport.ExecutionRequest request = new SubprocessExecutionSupport.ExecutionRequest(
+ commandLine, resolveWorkingDirectory(), buildEnvironment(), timeoutMillis,
+ properties.getMaxStdoutBytes(), properties.getMaxStderrBytes(), properties.getCleanupGraceMillis(),
+ submittedAtNanos, cancellationToken);
try {
- SubprocessExecutionSupport.RunSession session = SubprocessExecutionSupport.execute(request);
- return completeAfterWait(
- commandLine,
- timeoutMs,
- session.getStdout(),
- session.getStderr(),
- session.getHandler(),
- session.getWatchdog(),
- session.isWaitTimedOut());
- } catch (IOException e) {
- log.warn("OpenCLI spawn failed commandLine={}, message={}", commandLine, e.getMessage());
- throw new OpenCliExecutableFailureException(
- "OpenCLI could not be started (check PATH or executable path): " + commandLine, e);
- } catch (InterruptedException e) {
+ return complete(processRuntime.execute(request));
+ } catch (IOException ex) {
+ throw new OpenCliExecutableFailureException("OpenCLI process could not be started", ex);
+ } catch (InterruptedException ex) {
Thread.currentThread().interrupt();
- log.warn("OpenCLI interrupted commandLine={}", commandLine);
- throw new OpenCliException("Interrupted while awaiting OpenCLI subprocess", e, null);
+ throw new OpenCliException("Interrupted while awaiting OpenCLI subprocess", ex, null);
}
}
+ private OpenCliResult complete(SubprocessExecutionSupport.RunSession session) {
+ String stdout = new String(session.getStdout().toByteArray(), StandardCharsets.UTF_8);
+ String stderr = new String(session.getStderr().toByteArray(), StandardCharsets.UTF_8);
+ OpenCliExecutionDetails details = session.getExecutionDetails();
+ TerminationReason reason = details.getTerminationReason();
+ Integer exit = session.getObservedExitCode();
+ boolean success = reason == TerminationReason.PROCESS_EXIT && Integer.valueOf(0).equals(exit)
+ && details.isStreamsDrained();
+ OpenCliResult result = OpenCliResult.builder().stdout(stdout).stderr(stderr).exitCode(exit)
+ .success(success).parsed(OpenCliOutputParser.parseBestEffort(stdout, stderr))
+ .executionDetails(details).build();
+ if (success) { return result; }
+ if (reason == TerminationReason.QUEUE_TIMEOUT || reason == TerminationReason.EXECUTION_TIMEOUT) {
+ throw new OpenCliTimeoutException("OpenCLI deadline exceeded: " + reason, result);
+ }
+ if (reason == TerminationReason.SPAWN_FAILED) {
+ throw new OpenCliExecutableFailureException("OpenCLI process could not be started", session.getIoFailure(), result);
+ }
+ if (reason == TerminationReason.PROCESS_EXIT && exit != null && exit != 0) {
+ throw new OpenCliNonZeroExitException("OpenCLI returned nonzero exitCode=" + exit, result);
+ }
+ throw new OpenCliException("OpenCLI execution ended: " + reason, session.getIoFailure(), result);
+ }
+
private File resolveWorkingDirectory() {
- String wdProperty = properties.getWorkingDirectory();
- if (OpenCliStrings.isNotBlank(wdProperty)) {
- File wd = new File(wdProperty.trim());
- if (!wd.isDirectory()) {
- throw new OpenCliExecutableFailureException(
- "opencli.working-directory is not an existing directory: " + wd.getAbsolutePath(), null);
+ String value = properties.getWorkingDirectory();
+ if (OpenCliStrings.isNotBlank(value)) {
+ File directory = new File(value.trim());
+ if (!directory.isDirectory()) {
+ throw new OpenCliExecutableFailureException("opencli.working-directory is not an existing directory", null);
}
- return wd;
+ return directory;
}
return null;
}
- private OpenCliResult completeAfterWait(
- CommandLine commandLine,
- long timeoutMs,
- ByteArrayOutputStream out,
- ByteArrayOutputStream err,
- DefaultExecuteResultHandler handler,
- ExecuteWatchdog watchdog,
- boolean waitTimedOut) {
- String stdoutStr = new String(out.toByteArray(), StandardCharsets.UTF_8);
- String stderrStr = new String(err.toByteArray(), StandardCharsets.UTF_8);
- OpenCliParsedFields parsed = OpenCliOutputParser.parseBestEffort(stdoutStr, stderrStr);
-
- if (waitTimedOut || watchdog.killedProcess()) {
- log.warn("OpenCLI timed out commandLine={} timeoutMs={}", commandLine, timeoutMs);
- OpenCliResult partial = snapshot(stdoutStr, stderrStr, readExitQuietly(handler), parsed);
- throw new OpenCliTimeoutException(
- "OpenCLI timed out after " + timeoutMs + " ms: " + commandLine, partial);
- }
-
- Exception asyncFailure = handler.getException();
- if (asyncFailure instanceof ExecuteException) {
- ExecuteException ex = (ExecuteException) asyncFailure;
- log.warn("OpenCLI failed exitCode={} commandLine={}", ex.getExitValue(), commandLine);
- OpenCliResult failed = snapshot(stdoutStr, stderrStr, normalizeExitValue(ex.getExitValue()), parsed);
- throw new OpenCliNonZeroExitException(
- "OpenCLI failed (exitCode=" + ex.getExitValue() + "): " + commandLine, failed);
- }
- if (Objects.nonNull(asyncFailure)) {
- log.error("OpenCLI async failure commandLine={}", commandLine, asyncFailure);
- OpenCliResult snapshot = snapshot(stdoutStr, stderrStr, readExitQuietly(handler), parsed);
- throw new OpenCliException(
- "OpenCLI async failure: " + commandLine + " cause=" + asyncFailure.getMessage(),
- asyncFailure, snapshot);
- }
-
- final int exit;
- try {
- exit = handler.getExitValue();
- } catch (IllegalStateException e) {
- throw new OpenCliException(
- "OpenCLI completed without observable exit code: " + commandLine,
- e,
- snapshot(stdoutStr, stderrStr, null, parsed));
- }
-
- if (exit != 0) {
- log.warn("OpenCLI non-zero exit exitCode={} commandLine={}", exit, commandLine);
- OpenCliResult failed = snapshot(stdoutStr, stderrStr, exit, parsed);
- throw new OpenCliNonZeroExitException(
- "OpenCLI non-zero exit (exitCode=" + exit + "): " + commandLine, failed);
- }
-
- return OpenCliResult.builder()
- .stdout(stdoutStr)
- .stderr(stderrStr)
- .exitCode(exit)
- .success(true)
- .parsed(parsed)
- .build();
- }
-
private Map buildEnvironment() {
Map env = new HashMap<>(System.getenv());
- if (Objects.nonNull(properties.getEnvironment())) {
- for (Map.Entry e : properties.getEnvironment().entrySet()) {
- if (Objects.nonNull(e.getKey()) && Objects.nonNull(e.getValue())) {
- env.put(e.getKey(), e.getValue());
- }
+ if (properties.getEnvironment() != null) {
+ for (Map.Entry entry : new HashMap<>(properties.getEnvironment()).entrySet()) {
+ if (entry.getKey() != null && entry.getValue() != null) { env.put(entry.getKey(), entry.getValue()); }
}
}
return env;
}
-
- private static Integer readExitQuietly(DefaultExecuteResultHandler handler) {
- try {
- return normalizeExitValue(handler.getExitValue());
- } catch (IllegalStateException e) {
- return null;
- }
- }
-
- private static Integer normalizeExitValue(int raw) {
- if (raw == org.apache.commons.exec.Executor.INVALID_EXITVALUE) {
- return null;
- }
- return raw;
- }
-
- private static OpenCliResult snapshot(
- String stdoutStr, String stderrStr, Integer exitCode, OpenCliParsedFields parsed) {
- return OpenCliResult.builder()
- .stdout(Objects.isNull(stdoutStr) ? "" : stdoutStr)
- .stderr(Objects.isNull(stderrStr) ? "" : stderrStr)
- .exitCode(exitCode)
- .success(false)
- .parsed(parsed)
- .build();
- }
}
diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliOption.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliOption.java
new file mode 100644
index 0000000..5e5277e
--- /dev/null
+++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliOption.java
@@ -0,0 +1,73 @@
+package io.github.easy4j.opencli.core;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * One immutable, schema-checked option occurrence. Preserve occurrence ordering
+ * by adding these to a request builder rather than using a Map for repeated flags.
+ *
+ * @author Loong Wan
+ * @since 3.0.0
+ */
+public final class OpenCliOption {
+ private final OpenCliOptionSchema schema;
+ private final String value;
+ private final boolean negated;
+
+ private OpenCliOption(OpenCliOptionSchema schema, String value, boolean negated) {
+ this.schema = schema;
+ this.value = value;
+ this.negated = negated;
+ }
+
+ /**
+ * @param schema a valued-option definition
+ * @param value non-null value; false is the literal value "false", not absence
+ * @return an occurrence capturing the value immediately
+ */
+ public static OpenCliOption value(OpenCliOptionSchema schema, Object value) {
+ Objects.requireNonNull(schema, "schema");
+ Objects.requireNonNull(value, "value");
+ if (schema.getKind() != OpenCliOptionSchema.Kind.VALUE) {
+ throw new IllegalArgumentException("A flag schema cannot consume a value");
+ }
+ return new OpenCliOption(schema, String.valueOf(value), false);
+ }
+
+ /** @param schema a flag definition @return explicit positive presence */
+ public static OpenCliOption present(OpenCliOptionSchema schema) {
+ Objects.requireNonNull(schema, "schema");
+ if (schema.getKind() == OpenCliOptionSchema.Kind.VALUE) {
+ throw new IllegalArgumentException("A valued option requires a value");
+ }
+ return new OpenCliOption(schema, null, false);
+ }
+
+ /** @param schema a negatable flag definition @return an explicit negative flag */
+ public static OpenCliOption negated(OpenCliOptionSchema schema) {
+ Objects.requireNonNull(schema, "schema");
+ if (schema.getKind() != OpenCliOptionSchema.Kind.NEGATABLE_FLAG) {
+ throw new IllegalArgumentException("This option schema does not declare negation");
+ }
+ return new OpenCliOption(schema, null, true);
+ }
+
+ /** @return immutable input definition */
+ public OpenCliOptionSchema getSchema() { return schema; }
+
+ /** @return the captured value, or null for a flag */
+ public String getValue() { return value; }
+
+ /** @return whether this is explicit negative presence */
+ public boolean isNegated() { return negated; }
+
+ /** @return immutable literal tokens; no quoting or trimming is applied */
+ public List toTokens() {
+ String flag = negated ? "--no-" + schema.getName().substring(2) : schema.getName();
+ return value == null ? Collections.singletonList(flag)
+ : Collections.unmodifiableList(Arrays.asList(flag, value));
+ }
+}
diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliOptionSchema.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliOptionSchema.java
new file mode 100644
index 0000000..3289973
--- /dev/null
+++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliOptionSchema.java
@@ -0,0 +1,73 @@
+package io.github.easy4j.opencli.core;
+
+import java.util.Objects;
+import java.util.regex.Pattern;
+
+/**
+ * Explicit option input semantics. Definitions come from a known command contract,
+ * not guesses about a raw argument vector. This is not an output schema.
+ *
+ * @author Loong Wan
+ * @since 3.0.0
+ */
+public final class OpenCliOptionSchema {
+ /** Input arity and negation semantics. */
+ public enum Kind { FLAG, VALUE, NEGATABLE_FLAG }
+
+ private static final Pattern NAME = Pattern.compile("(?:--[A-Za-z0-9][A-Za-z0-9-]*|-[A-Za-z0-9])");
+ private final String name;
+ private final Kind kind;
+ private final boolean repeatable;
+
+ private OpenCliOptionSchema(String name, Kind kind, boolean repeatable) {
+ Objects.requireNonNull(name, "name");
+ if (!NAME.matcher(name).matches()) {
+ throw new IllegalArgumentException("Option schema requires a valid flag identifier");
+ }
+ if (kind == Kind.NEGATABLE_FLAG && (!name.startsWith("--") || name.startsWith("--no-"))) {
+ throw new IllegalArgumentException("Negatable schema requires a positive long flag identifier");
+ }
+ this.name = name;
+ this.kind = kind;
+ this.repeatable = repeatable;
+ }
+
+ /** @param name flag identifier @return a presence-only, nonrepeatable flag */
+ public static OpenCliOptionSchema flag(String name) {
+ return new OpenCliOptionSchema(name, Kind.FLAG, false);
+ }
+
+ /**
+ * @param name option identifier
+ * @param repeatable whether repeated occurrences are accepted by the command
+ * @return an option taking one literal value per occurrence
+ */
+ public static OpenCliOptionSchema value(String name, boolean repeatable) {
+ return new OpenCliOptionSchema(name, Kind.VALUE, repeatable);
+ }
+
+ /** @param name positive long flag identifier @return a flag supporting explicit negation */
+ public static OpenCliOptionSchema negatableFlag(String name) {
+ return new OpenCliOptionSchema(name, Kind.NEGATABLE_FLAG, false);
+ }
+
+ /** @return canonical flag identifier */
+ public String getName() { return name; }
+
+ /** @return declared input kind */
+ public Kind getKind() { return kind; }
+
+ /** @return whether the command accepts repeated occurrences */
+ public boolean isRepeatable() { return repeatable; }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) { return true; }
+ if (!(other instanceof OpenCliOptionSchema)) { return false; }
+ OpenCliOptionSchema that = (OpenCliOptionSchema) other;
+ return name.equals(that.name) && kind == that.kind && repeatable == that.repeatable;
+ }
+
+ @Override
+ public int hashCode() { return Objects.hash(name, kind, repeatable); }
+}
diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliProcessRuntime.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliProcessRuntime.java
new file mode 100644
index 0000000..d373f64
--- /dev/null
+++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliProcessRuntime.java
@@ -0,0 +1,37 @@
+package io.github.easy4j.opencli.core;
+
+import io.github.easy4j.opencli.core.support.SubprocessExecutionSupport;
+import java.io.IOException;
+
+/**
+ * Stable process-capacity owner. Pass the same instance to multiple executors to
+ * share a limit deliberately; constructing an unrelated executor cannot replace it.
+ */
+public final class OpenCliProcessRuntime {
+ private final SubprocessExecutionSupport.Runtime runtime;
+
+ /** @param maxConcurrent positive limit, or zero for the CPU-derived default; negative is invalid */
+ public OpenCliProcessRuntime(int maxConcurrent) {
+ runtime = new SubprocessExecutionSupport.Runtime(maxConcurrent);
+ }
+
+ /** @return fixed capacity for this runtime */
+ public int getMaxConcurrentExecutions() { return runtime.getMaxConcurrentExecutions(); }
+
+ /** @return whether unconfirmed resource cleanup has quarantined this runtime */
+ public boolean isQuarantined() { return runtime.isQuarantined(); }
+
+ /**
+ * Low-level bridge used by the SDK executor. Results retain bounded process evidence;
+ * the executor maps terminal conditions to the existing SDK exception hierarchy.
+ *
+ * @param request immutable submission snapshot
+ * @return terminal execution evidence
+ * @throws IOException retained for compatibility with the low-level execution API
+ * @throws InterruptedException retained for compatibility; observed interruption is normally returned as CANCELLED
+ */
+ public SubprocessExecutionSupport.RunSession execute(SubprocessExecutionSupport.ExecutionRequest request)
+ throws IOException, InterruptedException {
+ return runtime.execute(request);
+ }
+}
diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliResult.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliResult.java
index 2dbfd7a..b1fd2e4 100644
--- a/src/main/java/io/github/easy4j/opencli/core/OpenCliResult.java
+++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliResult.java
@@ -5,45 +5,23 @@
import lombok.Getter;
/**
- * 单次 OpenCLI 调用的原始结果载体。
- *
- * {@link #remoteRawHttpBody} 仅在 {@link io.github.easy4j.opencli.OpenCliExecutionTarget#REMOTE_AGENT_HTTP}
- * 且 {@link io.github.easy4j.opencli.OpenCliProperties} 的 {@code remoteCaptureRawHttpResponse} 为 true 时填充,
- * 为 Agent 返回的完整 HTTP 响应体,便于审计或与 {@code stdout}(由 {@code items} 重组)对照。
- *
- */
-@Getter
-@Builder/**
-
- * Raw result carrier for a single OpenCLI invocation.
- *
- * {@link #remoteRawHttpBody} is only populated when using
- * {@link io.github.easy4j.opencli.OpenCliExecutionTarget#REMOTE_AGENT_HTTP}
- * and {@link io.github.easy4j.opencli.OpenCliProperties#isRemoteCaptureRawHttpResponse()}
- * is {@code true}.
-
+ * Raw result for one OpenCLI invocation. Raw output is business data, not a safe
+ * diagnostic string. Local execution adds bounded lifecycle evidence; legacy
+ * remote responses do not acquire an invented process exit or cleanup state.
*
-
* @author Loong Wan
-
* @since 3.0.0
-
*/
-
+@Getter
+@Builder
public class OpenCliResult {
-
private final String stdout;
-
private final String stderr;
-
private final Integer exitCode;
-
private final boolean success;
-
private final OpenCliParsedFields parsed;
-
- /**
- * 远端 Agent HTTP 响应全文;本地子进程模式或非调试场景下为 null。
- */
+ /** Only populated by explicit remote HTTP raw capture. */
private final String remoteRawHttpBody;
+ /** Observed local execution metadata; null for a legacy remote result. */
+ private final OpenCliExecutionDetails executionDetails;
}
diff --git a/src/main/java/io/github/easy4j/opencli/core/support/SubprocessExecutionSupport.java b/src/main/java/io/github/easy4j/opencli/core/support/SubprocessExecutionSupport.java
index f6d77bb..5334915 100644
--- a/src/main/java/io/github/easy4j/opencli/core/support/SubprocessExecutionSupport.java
+++ b/src/main/java/io/github/easy4j/opencli/core/support/SubprocessExecutionSupport.java
@@ -1,167 +1,386 @@
package io.github.easy4j.opencli.core.support;
-import lombok.Getter;
-import org.apache.commons.exec.CommandLine;
-import org.apache.commons.exec.DefaultExecuteResultHandler;
-import org.apache.commons.exec.DefaultExecutor;
-import org.apache.commons.exec.ExecuteWatchdog;
-import org.apache.commons.exec.PumpStreamHandler;
-
+import io.github.easy4j.opencli.core.OpenCliCancellationToken;
+import io.github.easy4j.opencli.core.OpenCliExecutionDetails;
+import io.github.easy4j.opencli.core.OpenCliExecutionDetails.CleanupState;
+import io.github.easy4j.opencli.core.OpenCliExecutionDetails.TerminationReason;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
-import java.time.Duration;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.Semaphore;
-import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.TimeUnit;
+import lombok.Getter;
+import org.apache.commons.exec.CommandLine;
+import org.apache.commons.exec.DefaultExecuteResultHandler;
+import org.apache.commons.exec.ExecuteException;
+import org.apache.commons.exec.ExecuteWatchdog;
+import java.time.Duration;
/**
- * Subprocess execution support based on Apache Commons Exec: watchdog timeout,
- * bounded {@code waitFor}, and concurrency throttling.
- *
- * @author Loong Wan
- * @since 3.0.0
- */public final class SubprocessExecutionSupport {
-
- /** Watchdog 触发后,handler 收尾等待的上限(毫秒)。 */
+ * Bounded, owned native-process execution. ProcessBuilder receives an exact argv
+ * vector, never a shell string. Commons Exec handler/watchdog views remain for
+ * compatibility with the earlier low-level RunSession API.
+ */
+public final class SubprocessExecutionSupport {
public static final long WAIT_GRACE_MILLIS = 5_000L;
+ public static final int DEFAULT_STDOUT_LIMIT = 8 * 1024 * 1024;
+ public static final int DEFAULT_STDERR_LIMIT = 2 * 1024 * 1024;
+ private static final long POLL_NANOS = TimeUnit.MILLISECONDS.toNanos(10L);
+ private static final int DEFAULT_MAX_CONCURRENT = Math.max(2, java.lang.Runtime.getRuntime().availableProcessors());
+ private static final Object LEGACY_LOCK = new Object();
+ private static Runtime legacyRuntime = new Runtime(0);
+ private static int legacySubmissions;
- private static final int DEFAULT_MAX_CONCURRENT = Math.max(2, Runtime.getRuntime().availableProcessors());
-
- private static final AtomicReference CONCURRENCY_LIMIT =
- new AtomicReference<>(new Semaphore(DEFAULT_MAX_CONCURRENT));
-
- private SubprocessExecutionSupport() {
- }
+ private SubprocessExecutionSupport() { }
/**
- * 配置本机 CLI 子进程全局并发上限;{@code maxConcurrent <= 0} 时恢复为默认值。
+ * Configure only the deprecated static bridge, never an existing SDK client.
+ * Reconfiguration with active or queued submissions is rejected rather than
+ * creating a second live permit pool.
*
- * @param maxConcurrent 允许同时运行的子进程数
+ * @param maxConcurrent positive capacity or zero for the default
+ * @deprecated pass an explicit OpenCliProcessRuntime to executors instead
*/
+ @Deprecated
public static void configureMaxConcurrentExecutions(int maxConcurrent) {
- if (maxConcurrent <= 0) {
- CONCURRENCY_LIMIT.set(new Semaphore(DEFAULT_MAX_CONCURRENT));
- return;
+ synchronized (LEGACY_LOCK) {
+ if (legacySubmissions != 0) {
+ throw new IllegalStateException("Cannot reconfigure the legacy runtime while submissions exist");
+ }
+ legacyRuntime = new Runtime(maxConcurrent);
}
- CONCURRENCY_LIMIT.set(new Semaphore(maxConcurrent));
}
- /**
- * @return 未显式配置时的默认并发上限
- */
- public static int defaultMaxConcurrentExecutions() {
- return DEFAULT_MAX_CONCURRENT;
- }
+ public static int defaultMaxConcurrentExecutions() { return DEFAULT_MAX_CONCURRENT; }
- /**
- * 在并发许可内启动子进程并阻塞至结束、超时或被强制销毁。
- */
+ /** Legacy entry point using one stable, explicitly configured runtime. */
public static RunSession execute(ExecutionRequest request) throws IOException, InterruptedException {
Objects.requireNonNull(request, "request");
- Semaphore limit = CONCURRENCY_LIMIT.get();
- limit.acquire();
+ Runtime selected;
+ synchronized (LEGACY_LOCK) {
+ selected = legacyRuntime;
+ legacySubmissions++;
+ }
try {
- return executeWithinLimit(request);
+ return selected.execute(request);
} finally {
- limit.release();
+ synchronized (LEGACY_LOCK) { legacySubmissions--; }
}
}
- private static RunSession executeWithinLimit(ExecutionRequest request) throws IOException, InterruptedException {
- long timeoutMs = Math.max(1L, request.getTimeoutMillis());
- ByteArrayOutputStream out = new ByteArrayOutputStream();
- ByteArrayOutputStream err = new ByteArrayOutputStream();
+ /** Internal capacity/cleanup owner exposed through OpenCliProcessRuntime. */
+ public static final class Runtime {
+ private final int maxConcurrentExecutions;
+ private final Semaphore permits;
+ private volatile boolean quarantined;
- DefaultExecutor.Builder builder = DefaultExecutor.builder();
- if (request.getWorkingDirectory() != null) {
- builder.setWorkingDirectory(request.getWorkingDirectory());
+ public Runtime(int maxConcurrent) {
+ if (maxConcurrent < 0) {
+ throw new IllegalArgumentException("maxConcurrentExecutions must not be negative");
+ }
+ maxConcurrentExecutions = maxConcurrent == 0 ? DEFAULT_MAX_CONCURRENT : maxConcurrent;
+ permits = new Semaphore(maxConcurrentExecutions, true);
}
- DefaultExecutor executor = builder.get();
- executor.setStreamHandler(new PumpStreamHandler(out, err));
-
- ExecuteWatchdog watchdog =
- ExecuteWatchdog.builder().setTimeout(Duration.ofMillis(timeoutMs)).get();
- executor.setWatchdog(watchdog);
-
- DefaultExecuteResultHandler handler = new DefaultExecuteResultHandler();
- Map environment = request.getEnvironment();
- if (environment != null) {
- executor.execute(request.getCommandLine(), environment, handler);
- } else {
- executor.execute(request.getCommandLine(), handler);
+
+ public int getMaxConcurrentExecutions() { return maxConcurrentExecutions; }
+ public boolean isQuarantined() { return quarantined; }
+
+ public RunSession execute(ExecutionRequest request) throws IOException, InterruptedException {
+ Objects.requireNonNull(request, "request");
+ long budget = nanos(request.timeoutMillis, "timeoutMillis");
+ BoundedCapture out = new BoundedCapture(request.stdoutLimitBytes);
+ BoundedCapture err = new BoundedCapture(request.stderrLimitBytes);
+ Process process = null;
+ Reader stdoutReader = null;
+ Reader stderrReader = null;
+ ExecuteWatchdog watchdog = ExecuteWatchdog.builder()
+ .setTimeout(Duration.ofMillis(request.timeoutMillis)).get();
+ TerminationReason reason = null;
+ IOException ioFailure = null;
+ boolean acquired = false;
+ boolean interrupted = false;
+ long queueWaitMillis = 0L;
+ try {
+ while (!acquired && reason == null) {
+ if (Thread.interrupted()) {
+ interrupted = true;
+ reason = TerminationReason.CANCELLED;
+ } else if (request.cancellationToken.isCancelled()) {
+ reason = TerminationReason.CANCELLED;
+ } else if (quarantined) {
+ reason = TerminationReason.RUNTIME_UNAVAILABLE;
+ } else {
+ long remaining = remaining(request.submittedAtNanos, budget);
+ if (remaining <= 0) {
+ reason = TerminationReason.QUEUE_TIMEOUT;
+ } else {
+ acquired = permits.tryAcquire(Math.min(POLL_NANOS, remaining), TimeUnit.NANOSECONDS);
+ }
+ }
+ }
+ queueWaitMillis = elapsedMillis(request.submittedAtNanos);
+ if (acquired && reason == null) {
+ if (Thread.interrupted()) {
+ interrupted = true;
+ reason = TerminationReason.CANCELLED;
+ } else if (request.cancellationToken.isCancelled()) {
+ reason = TerminationReason.CANCELLED;
+ } else if (quarantined) {
+ reason = TerminationReason.RUNTIME_UNAVAILABLE;
+ } else if (remaining(request.submittedAtNanos, budget) <= 0) {
+ reason = TerminationReason.QUEUE_TIMEOUT;
+ }
+ }
+ if (acquired && reason == null) {
+ ProcessBuilder builder = new ProcessBuilder(request.nativeArgv);
+ if (request.workingDirectory != null) { builder.directory(request.workingDirectory); }
+ if (request.environment != null) {
+ builder.environment().clear();
+ builder.environment().putAll(request.environment);
+ }
+ process = builder.start();
+ process.getOutputStream().close();
+ stdoutReader = new Reader(process.getInputStream(), out, "opencli-stdout");
+ stderrReader = new Reader(process.getErrorStream(), err, "opencli-stderr");
+ stdoutReader.start();
+ stderrReader.start();
+ long remaining = remaining(request.submittedAtNanos, budget);
+ watchdog = ExecuteWatchdog.builder().setTimeout(Duration.ofMillis(
+ Math.max(1L, TimeUnit.NANOSECONDS.toMillis(Math.max(0L, remaining))))).get();
+ watchdog.start(process);
+ while (reason == null) {
+ if (out.isTruncated() || err.isTruncated()) {
+ reason = TerminationReason.OUTPUT_LIMIT;
+ } else if (Thread.interrupted()) {
+ interrupted = true;
+ reason = TerminationReason.CANCELLED;
+ } else if (request.cancellationToken.isCancelled()) {
+ reason = TerminationReason.CANCELLED;
+ } else if (stdoutReader.failure != null || stderrReader.failure != null) {
+ reason = TerminationReason.IO_FAILURE;
+ } else if (!process.isAlive() && !stdoutReader.isAlive() && !stderrReader.isAlive()) {
+ reason = watchdog.killedProcess() ? TerminationReason.EXECUTION_TIMEOUT : TerminationReason.PROCESS_EXIT;
+ } else if (watchdog.killedProcess() || remaining(request.submittedAtNanos, budget) <= 0) {
+ reason = TerminationReason.EXECUTION_TIMEOUT;
+ } else {
+ TimeUnit.NANOSECONDS.sleep(Math.min(POLL_NANOS, Math.max(1L,
+ remaining(request.submittedAtNanos, budget))));
+ }
+ }
+ }
+ } catch (InterruptedException ex) {
+ interrupted = true;
+ reason = TerminationReason.CANCELLED;
+ } catch (IOException ex) {
+ ioFailure = ex;
+ reason = process == null ? TerminationReason.SPAWN_FAILED : TerminationReason.IO_FAILURE;
+ } finally {
+ watchdog.stop();
+ long cleanupStart = System.nanoTime();
+ long cleanupBudget = nanos(request.cleanupGraceMillis, "cleanupGraceMillis");
+ if (process != null) {
+ if (process.isAlive()) { process.destroy(); }
+ boolean forceSent = false;
+ while (remaining(cleanupStart, cleanupBudget) > 0
+ && (process.isAlive() || alive(stdoutReader) || alive(stderrReader))) {
+ if (Thread.interrupted()) { interrupted = true; }
+ if (process.isAlive() && !forceSent
+ && System.nanoTime() - cleanupStart >= Math.min(TimeUnit.MILLISECONDS.toNanos(100L), cleanupBudget / 2)) {
+ process.destroyForcibly();
+ forceSent = true;
+ }
+ try {
+ TimeUnit.NANOSECONDS.sleep(Math.min(POLL_NANOS,
+ Math.max(1L, remaining(cleanupStart, cleanupBudget))));
+ } catch (InterruptedException ex) {
+ interrupted = true;
+ }
+ }
+ if (process.isAlive()) { process.destroyForcibly(); }
+ if (process.isAlive() || alive(stdoutReader) || alive(stderrReader)) {
+ // A fixed runtime cannot accumulate unlimited uncertain children/readers.
+ quarantined = true;
+ if (reason == TerminationReason.PROCESS_EXIT) { reason = TerminationReason.CLEANUP_UNCONFIRMED; }
+ }
+ }
+ out.freeze();
+ err.freeze();
+ if (acquired) { permits.release(); }
+ if (interrupted) { Thread.currentThread().interrupt(); }
+ }
+ if (reason == TerminationReason.PROCESS_EXIT && (out.isTruncated() || err.isTruncated())) {
+ reason = TerminationReason.OUTPUT_LIMIT;
+ }
+ Integer exit = process != null && !process.isAlive() ? process.exitValue() : null;
+ DefaultExecuteResultHandler handler = new DefaultExecuteResultHandler();
+ if (ioFailure != null) {
+ handler.onProcessFailed(new ExecuteException("Native process I/O failure", exit == null ? -1 : exit, ioFailure));
+ } else if (exit != null && exit != 0) {
+ handler.onProcessFailed(new ExecuteException("Native process returned nonzero status", exit));
+ } else if (exit != null) {
+ handler.onProcessComplete(exit);
+ }
+ OpenCliExecutionDetails details = OpenCliExecutionDetails.builder()
+ .terminationReason(reason)
+ .cleanupState(process == null ? CleanupState.NOT_STARTED
+ : process.isAlive() ? CleanupState.UNCONFIRMED : CleanupState.ROOT_EXIT_CONFIRMED)
+ .processStarted(process != null).streamsDrained(!alive(stdoutReader) && !alive(stderrReader))
+ .stdoutCapturedBytes(out.size()).stdoutObservedBytes(out.observed()).stdoutTruncated(out.isTruncated())
+ .stderrCapturedBytes(err.size()).stderrObservedBytes(err.observed()).stderrTruncated(err.isTruncated())
+ .elapsedMillis(elapsedMillis(request.submittedAtNanos)).queueWaitMillis(queueWaitMillis)
+ .descendantsExitConfirmed(false).build();
+ return new RunSession(out, err, handler, watchdog, request.timeoutMillis,
+ reason == TerminationReason.QUEUE_TIMEOUT || reason == TerminationReason.EXECUTION_TIMEOUT,
+ details, exit, ioFailure);
+ }
+ }
+
+ private static boolean alive(Thread thread) { return thread != null && thread.isAlive(); }
+ private static long remaining(long start, long budget) { return budget - (System.nanoTime() - start); }
+ private static long elapsedMillis(long start) { return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); }
+
+ private static long nanos(long millis, String field) {
+ if (millis <= 0 || millis > Long.MAX_VALUE / 1_000_000L) {
+ throw new IllegalArgumentException(field + " must be positive and fit a monotonic nanosecond budget");
+ }
+ return millis * 1_000_000L;
+ }
+
+ private static final class BoundedCapture extends ByteArrayOutputStream {
+ private final int limit;
+ private long observed;
+ private boolean truncated;
+ private boolean frozen;
+
+ BoundedCapture(int limit) {
+ super(Math.min(8192, limit));
+ this.limit = limit;
}
- boolean finished = awaitResult(handler, timeoutMs + WAIT_GRACE_MILLIS);
- boolean waitTimedOut = !finished;
- if (waitTimedOut) {
- watchdog.destroyProcess();
- awaitResult(handler, WAIT_GRACE_MILLIS);
+ @Override
+ public synchronized void write(byte[] bytes, int offset, int length) {
+ if (frozen) { return; }
+ observed = observed > Long.MAX_VALUE - length ? Long.MAX_VALUE : observed + length;
+ int retained = Math.min(length, limit - count);
+ super.write(bytes, offset, retained);
+ truncated |= retained < length;
}
- return new RunSession(out, err, handler, watchdog, timeoutMs, waitTimedOut);
+ @Override
+ public synchronized void write(int value) { write(new byte[]{(byte) value}, 0, 1); }
+ synchronized boolean isTruncated() { return truncated; }
+ synchronized long observed() { return observed; }
+ synchronized void freeze() { frozen = true; }
}
- private static boolean awaitResult(DefaultExecuteResultHandler handler, long timeoutMillis)
- throws InterruptedException {
- long deadline = System.currentTimeMillis() + Math.max(1L, timeoutMillis);
- while (!handler.hasResult()) {
- if (System.currentTimeMillis() >= deadline) {
- return false;
+ private static final class Reader extends Thread {
+ private final InputStream input;
+ private final BoundedCapture capture;
+ private volatile IOException failure;
+
+ Reader(InputStream input, BoundedCapture capture, String name) {
+ super(name);
+ this.input = input;
+ this.capture = capture;
+ setDaemon(true);
+ }
+
+ @Override
+ public void run() {
+ try (InputStream stream = input) {
+ byte[] buffer = new byte[8192];
+ int size;
+ while ((size = stream.read(buffer)) != -1) {
+ capture.write(buffer, 0, size);
+ if (capture.isTruncated()) { return; }
+ }
+ } catch (IOException ex) {
+ failure = ex;
}
- Thread.sleep(Math.min(50L, deadline - System.currentTimeMillis()));
}
- return true;
}
@Getter
public static final class ExecutionRequest {
-
private final CommandLine commandLine;
+ private final List nativeArgv;
private final File workingDirectory;
private final Map environment;
private final long timeoutMillis;
+ private final int stdoutLimitBytes;
+ private final int stderrLimitBytes;
+ private final long cleanupGraceMillis;
+ private final long submittedAtNanos;
+ private final OpenCliCancellationToken cancellationToken;
- public ExecutionRequest(
- CommandLine commandLine,
- File workingDirectory,
- Map environment,
- long timeoutMillis) {
+ public ExecutionRequest(CommandLine commandLine, File workingDirectory,
+ Map environment, long timeoutMillis) {
+ this(commandLine, workingDirectory, environment, timeoutMillis,
+ DEFAULT_STDOUT_LIMIT, DEFAULT_STDERR_LIMIT, WAIT_GRACE_MILLIS,
+ System.nanoTime(), new OpenCliCancellationToken());
+ }
+
+ public ExecutionRequest(CommandLine commandLine, File workingDirectory,
+ Map environment, long timeoutMillis, int stdoutLimitBytes,
+ int stderrLimitBytes, long cleanupGraceMillis, long submittedAtNanos,
+ OpenCliCancellationToken cancellationToken) {
this.commandLine = Objects.requireNonNull(commandLine, "commandLine");
+ List argv = new ArrayList<>(Arrays.asList(commandLine.toStrings()));
+ for (int i = 0; i < argv.size(); i++) {
+ if (argv.get(i) == null) { throw new IllegalArgumentException("nativeArgv[" + i + "] must not be null"); }
+ }
+ nativeArgv = Collections.unmodifiableList(argv);
this.workingDirectory = workingDirectory;
- this.environment = environment;
+ this.environment = environment == null ? null : Collections.unmodifiableMap(new HashMap<>(environment));
+ nanos(timeoutMillis, "timeoutMillis");
+ nanos(cleanupGraceMillis, "cleanupGraceMillis");
+ if (stdoutLimitBytes <= 0 || stderrLimitBytes <= 0) {
+ throw new IllegalArgumentException("stdout/stderr capture budgets must be positive");
+ }
this.timeoutMillis = timeoutMillis;
+ this.stdoutLimitBytes = stdoutLimitBytes;
+ this.stderrLimitBytes = stderrLimitBytes;
+ this.cleanupGraceMillis = cleanupGraceMillis;
+ this.submittedAtNanos = submittedAtNanos;
+ this.cancellationToken = Objects.requireNonNull(cancellationToken, "cancellationToken");
}
}
@Getter
public static final class RunSession {
-
private final ByteArrayOutputStream stdout;
private final ByteArrayOutputStream stderr;
private final DefaultExecuteResultHandler handler;
private final ExecuteWatchdog watchdog;
private final long timeoutMillis;
private final boolean waitTimedOut;
+ private final OpenCliExecutionDetails executionDetails;
+ private final Integer observedExitCode;
+ private final IOException ioFailure;
- RunSession(
- ByteArrayOutputStream stdout,
- ByteArrayOutputStream stderr,
- DefaultExecuteResultHandler handler,
- ExecuteWatchdog watchdog,
- long timeoutMillis,
- boolean waitTimedOut) {
+ RunSession(ByteArrayOutputStream stdout, ByteArrayOutputStream stderr,
+ DefaultExecuteResultHandler handler, ExecuteWatchdog watchdog, long timeoutMillis,
+ boolean waitTimedOut, OpenCliExecutionDetails executionDetails,
+ Integer observedExitCode, IOException ioFailure) {
this.stdout = stdout;
this.stderr = stderr;
this.handler = handler;
this.watchdog = watchdog;
this.timeoutMillis = timeoutMillis;
this.waitTimedOut = waitTimedOut;
+ this.executionDetails = executionDetails;
+ this.observedExitCode = observedExitCode;
+ this.ioFailure = ioFailure;
}
- public boolean timedOut() {
- return waitTimedOut || watchdog.killedProcess();
- }
+ public boolean timedOut() { return waitTimedOut; }
}
}
diff --git a/src/main/java/io/github/easy4j/opencli/exception/OpenCliExecutableFailureException.java b/src/main/java/io/github/easy4j/opencli/exception/OpenCliExecutableFailureException.java
index 378bd95..270f2c7 100644
--- a/src/main/java/io/github/easy4j/opencli/exception/OpenCliExecutableFailureException.java
+++ b/src/main/java/io/github/easy4j/opencli/exception/OpenCliExecutableFailureException.java
@@ -2,14 +2,14 @@
import io.github.easy4j.opencli.core.OpenCliResult;
-/**
- * Thrown when the OpenCLI executable cannot be started (PATH, permissions, invalid arguments, etc.).
- *
- * @author Loong Wan
- * @since 3.0.0
- */public class OpenCliExecutableFailureException extends OpenCliException {
-
+/** Failure to start the configured executable. */
+public class OpenCliExecutableFailureException extends OpenCliException {
public OpenCliExecutableFailureException(String message, Throwable cause) {
super(message, cause, null);
}
+
+ /** Retains a no-process-started snapshot without inventing an exit code. */
+ public OpenCliExecutableFailureException(String message, Throwable cause, OpenCliResult partialResult) {
+ super(message, cause, partialResult);
+ }
}
diff --git a/src/test/java/io/github/easy4j/opencli/contract/ContractProbe.java b/src/test/java/io/github/easy4j/opencli/contract/ContractProbe.java
new file mode 100644
index 0000000..3c50a12
--- /dev/null
+++ b/src/test/java/io/github/easy4j/opencli/contract/ContractProbe.java
@@ -0,0 +1,16 @@
+package io.github.easy4j.opencli.contract;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+/** Offline child process: emits every actual JVM argument, including empty values. */
+public final class ContractProbe {
+ private ContractProbe() { }
+
+ public static void main(String[] args) {
+ System.out.println("argc:" + args.length);
+ for (String arg : args) {
+ System.out.println("arg:" + Base64.getEncoder().encodeToString(arg.getBytes(StandardCharsets.UTF_8)));
+ }
+ }
+}
diff --git a/src/test/java/io/github/easy4j/opencli/contract/LifecycleProbe.java b/src/test/java/io/github/easy4j/opencli/contract/LifecycleProbe.java
new file mode 100644
index 0000000..88247cf
--- /dev/null
+++ b/src/test/java/io/github/easy4j/opencli/contract/LifecycleProbe.java
@@ -0,0 +1,44 @@
+package io.github.easy4j.opencli.contract;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Arrays;
+
+/** Offline, self-bounded child fixture. Release files allow cleanup even against a broken SDK. */
+public final class LifecycleProbe {
+ private LifecycleProbe() { }
+
+ public static void main(String[] args) throws Exception {
+ String mode = args[0];
+ if ("utf8".equals(mode)) {
+ System.out.write("中文".getBytes(StandardCharsets.UTF_8));
+ return;
+ }
+ if ("stdout".equals(mode) || "stderr".equals(mode)) {
+ byte[] block = new byte[8192];
+ Arrays.fill(block, (byte) 'x');
+ int remaining = Integer.parseInt(args[1]);
+ while (remaining > 0) {
+ int size = Math.min(block.length, remaining);
+ if ("stdout".equals(mode)) { System.out.write(block, 0, size); }
+ else { System.err.write(block, 0, size); }
+ remaining -= size;
+ }
+ return;
+ }
+ Path marker = Paths.get(args[1]);
+ Files.write(marker, "started".getBytes(StandardCharsets.UTF_8));
+ if ("write".equals(mode)) { return; }
+ Path release = Paths.get(args[2]);
+ long start = System.nanoTime();
+ int tick = 0;
+ while (!Files.exists(release) && System.nanoTime() - start < 10_000_000_000L) {
+ if ("heartbeat".equals(mode)) {
+ Files.write(marker, Integer.toString(++tick).getBytes(StandardCharsets.UTF_8));
+ }
+ Thread.sleep(20L);
+ }
+ }
+}
diff --git a/src/test/java/io/github/easy4j/opencli/contract/OpenCliArgvContractTest.java b/src/test/java/io/github/easy4j/opencli/contract/OpenCliArgvContractTest.java
new file mode 100644
index 0000000..e50a631
--- /dev/null
+++ b/src/test/java/io/github/easy4j/opencli/contract/OpenCliArgvContractTest.java
@@ -0,0 +1,171 @@
+package io.github.easy4j.opencli.contract;
+
+import io.github.easy4j.opencli.OpenCliProperties;
+import io.github.easy4j.opencli.browser.OpenCliBrowserClient;
+import io.github.easy4j.opencli.core.OpenCliAdapterChannel;
+import io.github.easy4j.opencli.core.OpenCliAdapterCommandRequest;
+import io.github.easy4j.opencli.core.OpenCliArgSupport;
+import io.github.easy4j.opencli.core.OpenCliExecutor;
+import io.github.easy4j.opencli.core.OpenCliResult;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.StringReader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.DynamicTest;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestFactory;
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Shared Java 8-compatible contract tests, with a real child rather than Recording executor. */
+class OpenCliArgvContractTest {
+ private static OpenCliProperties properties() {
+ OpenCliProperties p = new OpenCliProperties();
+ String exe = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java";
+ p.setExecutable(new File(new File(System.getProperty("java.home"), "bin"), exe).getAbsolutePath());
+ p.setLeadingArguments(new ArrayList<>(Arrays.asList("-cp",
+ System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")),
+ ContractProbe.class.getName())));
+ p.setCommandTimeoutMillis(10000L);
+ return p;
+ }
+
+ private static List received(OpenCliResult result) throws Exception {
+ assertTrue(result.isSuccess());
+ List actual = new ArrayList<>();
+ try (BufferedReader reader = new BufferedReader(new StringReader(result.getStdout()))) {
+ String header = reader.readLine();
+ assertNotNull(header, "child produced no argv evidence");
+ assertTrue(header.startsWith("argc:"), "missing child protocol header");
+ int count = Integer.parseInt(header.substring(5));
+ for (int i = 0; i < count; i++) {
+ String line = reader.readLine();
+ assertNotNull(line, "incomplete child output");
+ assertTrue(line.startsWith("arg:"));
+ actual.add(new String(Base64.getDecoder().decode(line.substring(4)), StandardCharsets.UTF_8));
+ }
+ assertNull(reader.readLine(), "unexpected extra child output");
+ }
+ return actual;
+ }
+
+ @TestFactory
+ Stream sharedVectorsReachEveryRawEntryPoint() throws Exception {
+ List tests = new ArrayList<>();
+ InputStream resource = getClass().getResourceAsStream("/opencli-contracts/v1/argv.tsv");
+ assertNotNull(resource, "shared fixture is required");
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(resource, StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ String[] fields = line.split("\t", -1);
+ List expected = new ArrayList<>();
+ for (int i = 1; i < fields.length; i++) {
+ expected.add(new String(Base64.getDecoder().decode(fields[i]), StandardCharsets.UTF_8));
+ }
+ for (int mode = 0; mode < 4; mode++) {
+ final int entry = mode;
+ tests.add(DynamicTest.dynamicTest(fields[0] + "/entry-" + mode, () -> {
+ OpenCliExecutor executor = new OpenCliExecutor(properties());
+ List before = new ArrayList<>(expected);
+ OpenCliResult result;
+ if (entry == 0) {
+ result = executor.invoke(expected);
+ } else if (entry == 1) {
+ result = executor.invoke(expected.toArray(new String[0]));
+ } else {
+ OpenCliAdapterChannel channel = new OpenCliAdapterChannel(executor, expected.get(0));
+ List rest = expected.subList(1, expected.size());
+ result = entry == 2 ? channel.invoke(rest) : channel.invoke(rest.toArray(new String[0]));
+ }
+ assertEquals(before, expected, "caller list changed");
+ assertEquals(expected, received(result), "full argv changed at child boundary");
+ }));
+ }
+ }
+ }
+ assertEquals(24, tests.size(), "fixture denominator changed; review sources.lock.json");
+ return tests.stream();
+ }
+
+ @Test
+ void structuredPositionalsAndValuesReachChildUnchanged() throws Exception {
+ Map options = new LinkedHashMap<>();
+ options.put("text", " value ");
+ OpenCliAdapterCommandRequest request = OpenCliAdapterCommandRequest.builder()
+ .subcommand("echo").positional("").positional(" positional ").options(options).build();
+ assertEquals(Arrays.asList("demo", "echo", "", " positional ", "--text", " value "),
+ received(new OpenCliAdapterChannel(new OpenCliExecutor(properties()), "demo").invoke(request)));
+ }
+
+ @Test
+ void typedBrowserFillCanClearAField() throws Exception {
+ OpenCliBrowserClient browser = new OpenCliBrowserClient(new OpenCliExecutor(properties()));
+ assertEquals(Arrays.asList("browser", "contract", "fill", "#input", ""),
+ received(browser.session("contract").fill("#input", "", null, null)));
+ }
+
+ @Test
+ void rawMergePreservesEmptyAndPaddedValues() throws Exception {
+ List prefix = Arrays.asList("demo", "echo", "");
+ List extra = Arrays.asList(" x ", "--", "-literal");
+ assertEquals(Arrays.asList("demo", "echo", "", " x ", "--", "-literal"),
+ received(new OpenCliExecutor(properties()).invoke(OpenCliArgSupport.merge(prefix, extra))));
+ assertEquals(Arrays.asList("demo", "echo", ""), prefix);
+ }
+
+ @Test
+ void leadingArgumentsPreserveEmptyAndPaddedValues() throws Exception {
+ OpenCliProperties p = properties();
+ p.getLeadingArguments().add("");
+ p.getLeadingArguments().add(" leading ");
+ assertEquals(Arrays.asList("", " leading ", "demo"),
+ received(new OpenCliExecutor(p).invoke("demo")));
+ }
+
+ @Test
+ void nullRawTokenIsRejectedWithoutLeakingOtherTokens() {
+ OpenCliExecutor executor = new OpenCliExecutor(properties());
+ IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
+ () -> executor.invoke(Arrays.asList("demo", "SECRET_MARKER", null)));
+ assertTrue(error.getMessage().contains("2"), "validation must identify null index");
+ assertFalse(error.getMessage().contains("SECRET_MARKER"));
+ }
+
+ @Test
+ void blankCommandIdentifierIsRejectedBeforeSpawn() {
+ OpenCliExecutor executor = new OpenCliExecutor(properties());
+ assertThrows(IllegalArgumentException.class, () -> executor.invoke(Arrays.asList("", "demo")));
+ assertThrows(IllegalArgumentException.class, () -> OpenCliAdapterCommandRequest.builder()
+ .subcommand(" ").build().toSubcommandAndArgs());
+ }
+
+ @Test
+ void nullVarargsContainerIsRejectedConsistently() {
+ OpenCliExecutor executor = new OpenCliExecutor(properties());
+ assertThrows(NullPointerException.class, () -> executor.invoke((String[]) null));
+ assertThrows(NullPointerException.class,
+ () -> new OpenCliAdapterChannel(executor, "demo").invoke((String[]) null));
+ }
+
+ @Test
+ void nullLeadingTokenIsRejectedBeforeSpawn() {
+ OpenCliProperties p = properties();
+ p.getLeadingArguments().add(null);
+ assertThrows(IllegalArgumentException.class, () -> new OpenCliExecutor(p).invoke("demo"));
+ }
+
+ @Test
+ void nullMergedTokenIsRejected() {
+ assertThrows(IllegalArgumentException.class,
+ () -> OpenCliArgSupport.merge(Arrays.asList("demo", null), Collections.emptyList()));
+ }
+}
diff --git a/src/test/java/io/github/easy4j/opencli/contract/OpenCliProcessBoundaryTest.java b/src/test/java/io/github/easy4j/opencli/contract/OpenCliProcessBoundaryTest.java
new file mode 100644
index 0000000..389140c
--- /dev/null
+++ b/src/test/java/io/github/easy4j/opencli/contract/OpenCliProcessBoundaryTest.java
@@ -0,0 +1,162 @@
+package io.github.easy4j.opencli.contract;
+
+import io.github.easy4j.opencli.OpenCliProperties;
+import io.github.easy4j.opencli.core.OpenCliCancellationToken;
+import io.github.easy4j.opencli.core.OpenCliExecutionDetails.TerminationReason;
+import io.github.easy4j.opencli.core.OpenCliExecutor;
+import io.github.easy4j.opencli.core.OpenCliResult;
+import io.github.easy4j.opencli.exception.OpenCliException;
+import io.github.easy4j.opencli.exception.OpenCliTimeoutException;
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.io.TempDir;
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Additional finite-budget and immutable-submission regression vectors. */
+@Timeout(20)
+class OpenCliProcessBoundaryTest {
+ @TempDir Path dir;
+
+ private static OpenCliProperties properties() {
+ OpenCliProperties p = new OpenCliProperties();
+ String exe = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java";
+ p.setExecutable(new File(new File(System.getProperty("java.home"), "bin"), exe).getAbsolutePath());
+ p.setLeadingArguments(new ArrayList<>(Arrays.asList("-cp",
+ System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")),
+ LifecycleProbe.class.getName())));
+ p.setCommandTimeoutMillis(10000L);
+ p.setMaxConcurrentExecutions(1);
+ return p;
+ }
+
+ private static void await(Path path) throws Exception {
+ long started = System.nanoTime();
+ while (!Files.exists(path) && System.nanoTime() - started < TimeUnit.SECONDS.toNanos(3L)) { Thread.sleep(10L); }
+ assertTrue(Files.exists(path), "fixture did not start");
+ }
+
+ @Test
+ void utf8TruncationReportsBytesNotReencodedCharacters() {
+ OpenCliProperties p = properties();
+ p.setMaxStdoutBytes(4);
+ OpenCliException failure = assertThrows(OpenCliException.class, () -> new OpenCliExecutor(p).invoke("utf8"));
+ OpenCliResult partial = failure.getPartialResult();
+ assertNotNull(partial);
+ assertEquals(TerminationReason.OUTPUT_LIMIT, partial.getExecutionDetails().getTerminationReason());
+ assertEquals(4L, partial.getExecutionDetails().getStdoutCapturedBytes());
+ assertEquals(6L, partial.getExecutionDetails().getStdoutObservedBytes());
+ assertEquals("中\uFFFD", partial.getStdout());
+ assertTrue(partial.getExecutionDetails().isStdoutTruncated());
+ }
+
+ @Test
+ void invalidBudgetsFailBeforeChildCreation() {
+ for (int choice = 0; choice < 5; choice++) {
+ OpenCliProperties p = properties();
+ if (choice == 0) { p.setMaxStdoutBytes(0); }
+ if (choice == 1) { p.setMaxStderrBytes(-1); }
+ if (choice == 2) { p.setCleanupGraceMillis(0); }
+ if (choice == 3) { p.setCommandTimeoutMillis(Long.MAX_VALUE); }
+ if (choice == 4) { p.setCleanupGraceMillis(Long.MAX_VALUE); }
+ Path marker = dir.resolve("invalid-" + choice);
+ assertThrows(IllegalArgumentException.class, () -> new OpenCliExecutor(p).invoke("write", marker.toString()));
+ assertFalse(Files.exists(marker));
+ }
+ }
+
+ @Test
+ void explicitCancellationOfRunningChildRetainsBoundedEvidence() throws Exception {
+ OpenCliExecutor executor = new OpenCliExecutor(properties());
+ OpenCliCancellationToken token = new OpenCliCancellationToken();
+ Path heartbeat = dir.resolve("heartbeat");
+ Path release = dir.resolve("release");
+ ExecutorService worker = Executors.newSingleThreadExecutor();
+ try {
+ Future future = worker.submit(() -> executor.invoke(
+ Arrays.asList("heartbeat", heartbeat.toString(), release.toString()), token));
+ await(heartbeat);
+ token.cancel();
+ ExecutionException failed = assertThrows(ExecutionException.class, () -> future.get(3, TimeUnit.SECONDS));
+ assertTrue(failed.getCause() instanceof OpenCliException);
+ OpenCliResult partial = ((OpenCliException) failed.getCause()).getPartialResult();
+ assertEquals(TerminationReason.CANCELLED, partial.getExecutionDetails().getTerminationReason());
+ assertTrue(partial.getExecutionDetails().isProcessStarted());
+ assertFalse(partial.getExecutionDetails().isDescendantsExitConfirmed());
+ String stopped = new String(Files.readAllBytes(heartbeat), StandardCharsets.UTF_8);
+ Thread.sleep(100L);
+ assertEquals(stopped, new String(Files.readAllBytes(heartbeat), StandardCharsets.UTF_8));
+ assertTrue(executor.invoke("write", dir.resolve("next").toString()).isSuccess());
+ } finally {
+ Files.write(release, new byte[]{1});
+ worker.shutdownNow();
+ assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
+ }
+ }
+
+ @Test
+ void queuedTokenCancellationDoesNotStartTheWaitingChild() throws Exception {
+ OpenCliExecutor executor = new OpenCliExecutor(properties());
+ Path first = dir.resolve("first");
+ Path second = dir.resolve("second");
+ Path gate = dir.resolve("release");
+ OpenCliCancellationToken token = new OpenCliCancellationToken();
+ ExecutorService workers = Executors.newFixedThreadPool(2);
+ try {
+ Future one = workers.submit(() -> executor.invoke("hold", first.toString(), gate.toString()));
+ await(first);
+ Future two = workers.submit(() -> executor.invoke(Arrays.asList("write", second.toString()), token));
+ Thread.sleep(100L);
+ token.cancel();
+ ExecutionException failed = assertThrows(ExecutionException.class, () -> two.get(2, TimeUnit.SECONDS));
+ OpenCliResult partial = ((OpenCliException) failed.getCause()).getPartialResult();
+ assertEquals(TerminationReason.CANCELLED, partial.getExecutionDetails().getTerminationReason());
+ assertFalse(partial.getExecutionDetails().isProcessStarted());
+ assertNull(partial.getExitCode());
+ assertFalse(Files.exists(second));
+ Files.write(gate, new byte[]{1});
+ assertTrue(one.get(3, TimeUnit.SECONDS).isSuccess());
+ } finally {
+ Files.write(gate, new byte[]{1});
+ workers.shutdownNow();
+ assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
+ }
+ }
+
+ @Test
+ void timeoutAndCaptureConfigurationAreCopiedForReverseWorkers() {
+ OpenCliProperties p = properties();
+ p.setMaxStdoutBytes(123);
+ p.setMaxStderrBytes(456);
+ p.setCleanupGraceMillis(789);
+ OpenCliProperties copy = p.copyForLocalCliExecution();
+ assertEquals(123, copy.getMaxStdoutBytes());
+ assertEquals(456, copy.getMaxStderrBytes());
+ assertEquals(789L, copy.getCleanupGraceMillis());
+ }
+
+ @Test
+ void repeatedExecutionTimeoutsDoNotBecomeNonzeroOrIoFailures() {
+ OpenCliProperties p = properties();
+ p.setCommandTimeoutMillis(100L);
+ OpenCliExecutor executor = new OpenCliExecutor(p);
+ for (int i = 0; i < 5; i++) {
+ Path marker = dir.resolve("timeout-" + i);
+ OpenCliTimeoutException failure = assertThrows(OpenCliTimeoutException.class,
+ () -> executor.invoke("hold", marker.toString(), dir.resolve("never-release").toString()));
+ assertEquals(TerminationReason.EXECUTION_TIMEOUT, failure.getPartialResult().getExecutionDetails().getTerminationReason());
+ assertFalse(executor.getProcessRuntime().isQuarantined());
+ }
+ }
+}
diff --git a/src/test/java/io/github/easy4j/opencli/contract/OpenCliProcessContractTest.java b/src/test/java/io/github/easy4j/opencli/contract/OpenCliProcessContractTest.java
new file mode 100644
index 0000000..5cb4397
--- /dev/null
+++ b/src/test/java/io/github/easy4j/opencli/contract/OpenCliProcessContractTest.java
@@ -0,0 +1,233 @@
+package io.github.easy4j.opencli.contract;
+
+import io.github.easy4j.opencli.OpenCliProperties;
+import io.github.easy4j.opencli.core.OpenCliExecutor;
+import io.github.easy4j.opencli.core.OpenCliResult;
+import io.github.easy4j.opencli.exception.OpenCliException;
+import io.github.easy4j.opencli.exception.OpenCliTimeoutException;
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.io.TempDir;
+import static org.junit.jupiter.api.Assertions.*;
+
+/** C02 tests observe actual fixture processes, not private semaphore counters. */
+@Timeout(20)
+class OpenCliProcessContractTest {
+ @TempDir Path dir;
+
+ private static OpenCliProperties properties(int maxConcurrent) {
+ OpenCliProperties p = new OpenCliProperties();
+ String exe = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java";
+ p.setExecutable(new File(new File(System.getProperty("java.home"), "bin"), exe).getAbsolutePath());
+ p.setLeadingArguments(new ArrayList<>(Arrays.asList("-cp",
+ System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")),
+ LifecycleProbe.class.getName())));
+ p.setCommandTimeoutMillis(10000L);
+ p.setMaxConcurrentExecutions(maxConcurrent);
+ return p;
+ }
+
+ private static boolean awaitFile(Path path, long millis) throws Exception {
+ long start = System.nanoTime();
+ while (System.nanoTime() - start < TimeUnit.MILLISECONDS.toNanos(millis)) {
+ if (Files.exists(path)) { return true; }
+ Thread.sleep(10L);
+ }
+ return Files.exists(path);
+ }
+
+ private static void release(Path path) {
+ try { Files.write(path, new byte[]{1}); }
+ catch (Exception ex) { throw new AssertionError("fixture cleanup failed", ex); }
+ }
+
+ private static Object getter(Object object, String name) {
+ assertNotNull(object, "partial evidence is required");
+ return assertDoesNotThrow(() -> object.getClass().getMethod(name).invoke(object),
+ "required execution evidence is missing: " + name);
+ }
+
+ private static Object details(OpenCliResult result) { return getter(result, "getExecutionDetails"); }
+
+ @Test
+ void anotherClientCannotReplaceAnActiveClientsLimiter() throws Exception {
+ OpenCliExecutor a = new OpenCliExecutor(properties(1));
+ Path first = dir.resolve("first");
+ Path second = dir.resolve("second");
+ Path gate = dir.resolve("release");
+ ExecutorService workers = Executors.newFixedThreadPool(2);
+ try {
+ Future one = workers.submit(() -> a.invoke("hold", first.toString(), gate.toString()));
+ assertTrue(awaitFile(first, 3000), "first child did not start");
+ new OpenCliExecutor(properties(4));
+ Future two = workers.submit(() -> a.invoke("write", second.toString()));
+ assertFalse(awaitFile(second, 600), "constructing B bypassed A's active limiter");
+ release(gate);
+ assertTrue(one.get(5, TimeUnit.SECONDS).isSuccess());
+ assertTrue(two.get(5, TimeUnit.SECONDS).isSuccess());
+ } finally {
+ release(gate);
+ workers.shutdownNow();
+ assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
+ }
+ }
+
+ @Test
+ void queuedDeadlineExpiresWithoutSpawning() throws Exception {
+ OpenCliProperties p = properties(1);
+ OpenCliExecutor executor = new OpenCliExecutor(p);
+ Path first = dir.resolve("first");
+ Path second = dir.resolve("must-not-start");
+ Path gate = dir.resolve("release");
+ ExecutorService worker = Executors.newSingleThreadExecutor();
+ ScheduledExecutorService cleanup = Executors.newSingleThreadScheduledExecutor();
+ try {
+ Future one = worker.submit(() -> executor.invoke("hold", first.toString(), gate.toString()));
+ assertTrue(awaitFile(first, 3000));
+ p.setCommandTimeoutMillis(50L);
+ cleanup.schedule(() -> release(gate), 1500L, TimeUnit.MILLISECONDS);
+ long started = System.nanoTime();
+ OpenCliTimeoutException failure = assertThrows(OpenCliTimeoutException.class,
+ () -> executor.invoke("write", second.toString()));
+ long elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started);
+ assertTrue(elapsed < 750L, "queue wait ignored total deadline: " + elapsed);
+ assertFalse(Files.exists(second), "queue-expired child was started");
+ Object evidence = details(failure.getPartialResult());
+ assertEquals("QUEUE_TIMEOUT", String.valueOf(getter(evidence, "getTerminationReason")));
+ assertEquals(false, getter(evidence, "isProcessStarted"));
+ assertNull(failure.getPartialResult().getExitCode());
+ release(gate);
+ assertTrue(one.get(5, TimeUnit.SECONDS).isSuccess());
+ } finally {
+ release(gate);
+ worker.shutdownNow();
+ cleanup.shutdownNow();
+ assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
+ assertTrue(cleanup.awaitTermination(5, TimeUnit.SECONDS));
+ }
+ }
+
+ @Test
+ void stdoutDefaultBudgetFailsInsteadOfReturningUnboundedSuccess() {
+ OpenCliException failure = assertThrows(OpenCliException.class,
+ () -> new OpenCliExecutor(properties(1)).invoke("stdout", Integer.toString(9 * 1024 * 1024)));
+ OpenCliResult partial = failure.getPartialResult();
+ Object evidence = details(partial);
+ assertEquals("OUTPUT_LIMIT", String.valueOf(getter(evidence, "getTerminationReason")));
+ assertEquals(8L * 1024 * 1024, ((Number) getter(evidence, "getStdoutCapturedBytes")).longValue());
+ assertTrue(((Number) getter(evidence, "getStdoutObservedBytes")).longValue() > 8L * 1024 * 1024);
+ assertEquals(true, getter(evidence, "isStdoutTruncated"));
+ assertFalse(partial.isSuccess());
+ }
+
+ @Test
+ void stderrHasItsOwnSmallerBudget() {
+ OpenCliException failure = assertThrows(OpenCliException.class,
+ () -> new OpenCliExecutor(properties(1)).invoke("stderr", Integer.toString(3 * 1024 * 1024)));
+ Object evidence = details(failure.getPartialResult());
+ assertEquals("OUTPUT_LIMIT", String.valueOf(getter(evidence, "getTerminationReason")));
+ assertEquals(2L * 1024 * 1024, ((Number) getter(evidence, "getStderrCapturedBytes")).longValue());
+ assertEquals(true, getter(evidence, "isStderrTruncated"));
+ }
+
+ @Test
+ void interruptionStopsTheOwnedHeartbeatAndRestoresFlag() throws Exception {
+ OpenCliExecutor executor = new OpenCliExecutor(properties(1));
+ Path heartbeat = dir.resolve("heartbeat");
+ Path gate = dir.resolve("release");
+ AtomicBoolean restored = new AtomicBoolean();
+ AtomicReference error = new AtomicReference<>();
+ Thread caller = new Thread(() -> {
+ try { executor.invoke("heartbeat", heartbeat.toString(), gate.toString()); }
+ catch (Throwable failure) { error.set(failure); restored.set(Thread.currentThread().isInterrupted()); }
+ }, "contract-interrupted-caller");
+ try {
+ caller.start();
+ assertTrue(awaitFile(heartbeat, 3000));
+ Thread.sleep(80L);
+ caller.interrupt();
+ caller.join(1500L);
+ assertFalse(caller.isAlive(), "interrupted call did not finish cleanup");
+ assertTrue(error.get() instanceof OpenCliException);
+ assertTrue(restored.get(), "caller interrupt flag was lost");
+ String observed = new String(Files.readAllBytes(heartbeat), StandardCharsets.UTF_8);
+ Thread.sleep(250L);
+ assertEquals(observed, new String(Files.readAllBytes(heartbeat), StandardCharsets.UTF_8),
+ "owned child kept running after interrupted call returned");
+ OpenCliException failure = (OpenCliException) error.get();
+ assertEquals("CANCELLED", String.valueOf(getter(details(failure.getPartialResult()), "getTerminationReason")));
+ assertEquals("ROOT_EXIT_CONFIRMED", String.valueOf(getter(details(failure.getPartialResult()), "getCleanupState")));
+ Path next = dir.resolve("next");
+ assertTrue(executor.invoke("write", next.toString()).isSuccess(), "permit leaked after cancellation");
+ } finally {
+ release(gate);
+ caller.interrupt();
+ caller.join(5000L);
+ }
+ }
+
+ @Test
+ void negativeConcurrencyIsNotSilentlyTreatedAsDefault() {
+ assertThrows(IllegalArgumentException.class, () -> new OpenCliExecutor(properties(-1)));
+ }
+
+ @Test
+ void explicitSharedRuntimeLimitsBothClients() throws Exception {
+ Class> runtimeType = assertDoesNotThrow(() -> Class.forName("io.github.easy4j.opencli.core.OpenCliProcessRuntime"));
+ Object runtime = runtimeType.getConstructor(int.class).newInstance(1);
+ OpenCliExecutor a = OpenCliExecutor.class.getConstructor(OpenCliProperties.class, runtimeType)
+ .newInstance(properties(4), runtime);
+ OpenCliExecutor b = OpenCliExecutor.class.getConstructor(OpenCliProperties.class, runtimeType)
+ .newInstance(properties(4), runtime);
+ Path first = dir.resolve("shared-first");
+ Path second = dir.resolve("shared-second");
+ Path gate = dir.resolve("release");
+ ExecutorService workers = Executors.newFixedThreadPool(2);
+ try {
+ Future one = workers.submit(() -> a.invoke("hold", first.toString(), gate.toString()));
+ assertTrue(awaitFile(first, 3000));
+ Future two = workers.submit(() -> b.invoke("write", second.toString()));
+ assertFalse(awaitFile(second, 500), "shared runtime did not enforce its one permit");
+ release(gate);
+ assertTrue(one.get(5, TimeUnit.SECONDS).isSuccess());
+ assertTrue(two.get(5, TimeUnit.SECONDS).isSuccess());
+ } finally {
+ release(gate);
+ workers.shutdownNow();
+ assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
+ }
+ }
+
+ @Test
+ void preCancelledRequestNeverStartsAProcess() throws Exception {
+ Class> tokenType = assertDoesNotThrow(() -> Class.forName("io.github.easy4j.opencli.core.OpenCliCancellationToken"));
+ Object token = tokenType.getConstructor().newInstance();
+ tokenType.getMethod("cancel").invoke(token);
+ Path marker = dir.resolve("pre-cancelled");
+ OpenCliExecutor executor = new OpenCliExecutor(properties(1));
+ java.lang.reflect.InvocationTargetException failure = assertThrows(java.lang.reflect.InvocationTargetException.class,
+ () -> OpenCliExecutor.class.getMethod("invoke", List.class, tokenType)
+ .invoke(executor, Arrays.asList("write", marker.toString()), token));
+ assertTrue(failure.getCause() instanceof OpenCliException);
+ OpenCliResult partial = ((OpenCliException) failure.getCause()).getPartialResult();
+ assertEquals("CANCELLED", String.valueOf(getter(details(partial), "getTerminationReason")));
+ assertEquals(false, getter(details(partial), "isProcessStarted"));
+ assertNull(partial.getExitCode());
+ assertFalse(Files.exists(marker));
+ }
+}
diff --git a/src/test/java/io/github/easy4j/opencli/contract/OpenCliStructuredArgvContractTest.java b/src/test/java/io/github/easy4j/opencli/contract/OpenCliStructuredArgvContractTest.java
new file mode 100644
index 0000000..c23685d
--- /dev/null
+++ b/src/test/java/io/github/easy4j/opencli/contract/OpenCliStructuredArgvContractTest.java
@@ -0,0 +1,180 @@
+package io.github.easy4j.opencli.contract;
+
+import io.github.easy4j.opencli.OpenCliProperties;
+import io.github.easy4j.opencli.core.OpenCliAdapterChannel;
+import io.github.easy4j.opencli.core.OpenCliAdapterCommandRequest;
+import io.github.easy4j.opencli.core.OpenCliExecutor;
+import io.github.easy4j.opencli.core.OpenCliResult;
+import java.io.File;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+
+/** Public binary/API contract: missing methods fail assertions rather than preventing the RED build. */
+class OpenCliStructuredArgvContractTest {
+ private static Class> type(String simpleName) {
+ return assertDoesNotThrow(() -> Class.forName("io.github.easy4j.opencli.core." + simpleName),
+ "required ordered-option API is not implemented");
+ }
+
+ private static Object call(Class> owner, Object receiver, String name, Class>[] parameterTypes, Object... args) {
+ Method method = assertDoesNotThrow(() -> owner.getMethod(name, parameterTypes),
+ "required public method is not implemented: " + name);
+ try {
+ return method.invoke(receiver, args);
+ } catch (InvocationTargetException ex) {
+ Throwable cause = ex.getCause();
+ if (cause instanceof RuntimeException) {
+ throw (RuntimeException) cause;
+ }
+ throw new AssertionError("unexpected checked failure", cause);
+ } catch (ReflectiveOperationException ex) {
+ throw new AssertionError("public API is inaccessible", ex);
+ }
+ }
+
+ private static Object schema(String form, String name, boolean repeatable) {
+ Class> owner = type("OpenCliOptionSchema");
+ return "value".equals(form)
+ ? call(owner, null, form, new Class>[]{String.class, boolean.class}, name, repeatable)
+ : call(owner, null, form, new Class>[]{String.class}, name);
+ }
+
+ private static Object value(Object definition, Object value) {
+ return call(type("OpenCliOption"), null, "value",
+ new Class>[]{type("OpenCliOptionSchema"), Object.class}, definition, value);
+ }
+
+ private static Object flag(String form, Object definition) {
+ return call(type("OpenCliOption"), null, form,
+ new Class>[]{type("OpenCliOptionSchema")}, definition);
+ }
+
+ private static void add(Object builder, Object occurrence) {
+ call(builder.getClass(), builder, "option", new Class>[]{type("OpenCliOption")}, occurrence);
+ }
+
+ private static List run(OpenCliAdapterCommandRequest request) {
+ OpenCliProperties p = new OpenCliProperties();
+ String exe = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java";
+ p.setExecutable(new File(new File(System.getProperty("java.home"), "bin"), exe).getAbsolutePath());
+ p.setLeadingArguments(new ArrayList<>(Arrays.asList("-cp",
+ System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")),
+ ContractProbe.class.getName())));
+ p.setCommandTimeoutMillis(10000L);
+ OpenCliResult result = new OpenCliAdapterChannel(new OpenCliExecutor(p), "demo").invoke(request);
+ assertTrue(result.isSuccess());
+ String[] lines = result.getStdout().split("\r?\n");
+ List actual = new ArrayList<>();
+ for (int i = 1; i < lines.length; i++) {
+ assertTrue(lines[i].startsWith("arg:"));
+ actual.add(new String(Base64.getDecoder().decode(lines[i].substring(4)), StandardCharsets.UTF_8));
+ }
+ assertEquals("argc:" + actual.size(), lines[0]);
+ return actual;
+ }
+
+ @Test
+ void orderedRepeatedValuesAndExplicitFalseReachChild() {
+ Object tags = schema("value", "--tag", true);
+ Object enabled = schema("value", "--enabled", false);
+ OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo");
+ add(b, value(tags, " A "));
+ add(b, value(enabled, false));
+ add(b, value(tags, ""));
+ assertEquals(Arrays.asList("demo", "echo", "--tag", " A ", "--enabled", "false", "--tag", ""), run(b.build()));
+ }
+
+ @Test
+ void explicitNegationIsDifferentFromAbsenceAndPresence() {
+ Object cache = schema("negatableFlag", "--cache", false);
+ OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo");
+ add(b, flag("negated", cache));
+ assertEquals(Arrays.asList("demo", "echo", "--no-cache"), run(b.build()));
+ OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder present = OpenCliAdapterCommandRequest.builder().subcommand("echo");
+ add(present, flag("present", cache));
+ assertEquals(Arrays.asList("demo", "echo", "--cache"), run(present.build()));
+ assertEquals(Arrays.asList("demo", "echo"), run(OpenCliAdapterCommandRequest.builder().subcommand("echo").build()));
+ }
+
+ @Test
+ void flagSchemaCannotSilentlyConsumeAValue() {
+ Object verbose = schema("flag", "--verbose", false);
+ assertThrows(IllegalArgumentException.class, () -> value(verbose, false));
+ assertThrows(IllegalArgumentException.class, () -> flag("negated", verbose));
+ }
+
+ @Test
+ void nonrepeatableOptionCannotAppearTwice() {
+ Object once = schema("value", "--limit", false);
+ OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo");
+ add(b, value(once, 1));
+ add(b, value(once, 2));
+ assertThrows(IllegalArgumentException.class, () -> b.build().toSubcommandAndArgs());
+ }
+
+ @Test
+ void legacyAndOrderedOptionsCannotCollide() {
+ Object limit = schema("value", "--limit", true);
+ OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo")
+ .options(Collections.singletonMap("limit", "1"));
+ add(b, value(limit, "2"));
+ assertThrows(IllegalArgumentException.class, () -> b.build().toSubcommandAndArgs());
+ }
+
+ @Test
+ void mutableOccurrenceValueIsCapturedWhenCreated() {
+ StringBuilder text = new StringBuilder(" before ");
+ Object occurrence = value(schema("value", "--text", false), text);
+ text.append("after");
+ OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo");
+ add(b, occurrence);
+ assertEquals(Arrays.asList("demo", "echo", "--text", " before "), run(b.build()));
+ }
+
+ @Test
+ void malformedSchemaIdentifiersAreRejected() {
+ Class> owner = type("OpenCliOptionSchema");
+ assertThrows(IllegalArgumentException.class,
+ () -> call(owner, null, "flag", new Class>[]{String.class}, "--x y"));
+ assertThrows(IllegalArgumentException.class,
+ () -> call(owner, null, "flag", new Class>[]{String.class}, ""));
+ }
+
+ @Test
+ void legacyMapIsCapturedRatherThanAliased() {
+ Map options = new LinkedHashMap<>();
+ options.put("text", " before ");
+ OpenCliAdapterCommandRequest request = OpenCliAdapterCommandRequest.builder().subcommand("echo").options(options).build();
+ options.put("text", "after");
+ assertEquals(Arrays.asList("demo", "echo", "--text", " before "), run(request));
+ }
+
+ @Test
+ void mutableLegacyValueCannotChangeAnExistingRequest() {
+ StringBuilder text = new StringBuilder(" before ");
+ OpenCliAdapterCommandRequest request = OpenCliAdapterCommandRequest.builder().subcommand("echo")
+ .options(Collections.singletonMap("text", text)).build();
+ text.append("after");
+ assertEquals(Arrays.asList("demo", "echo", "--text", " before "), run(request));
+ }
+
+ @Test
+ void reusingBuilderDoesNotMutatePreviousRequest() {
+ Object tags = schema("value", "--tag", true);
+ OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo");
+ add(b, value(tags, "A"));
+ OpenCliAdapterCommandRequest first = b.build();
+ add(b, value(tags, "B"));
+ assertEquals(Arrays.asList("demo", "echo", "--tag", "A"), run(first));
+ }
+}
diff --git a/src/test/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequestTest.java b/src/test/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequestTest.java
index 8f2c36b..d98b0f5 100644
--- a/src/test/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequestTest.java
+++ b/src/test/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequestTest.java
@@ -116,7 +116,7 @@ void shouldReturnImmutableOptions() {
}
@Test
- void shouldSkipNullPositionalInToSubcommandAndArgs() {
+ void shouldPreserveEmptyPositionalInToSubcommandAndArgs() {
OpenCliAdapterCommandRequest req = OpenCliAdapterCommandRequest.builder()
.subcommand("sub")
.positional("a")
@@ -124,7 +124,7 @@ void shouldSkipNullPositionalInToSubcommandAndArgs() {
.positional("b")
.build();
List argv = req.toSubcommandAndArgs();
- assertEquals(Arrays.asList("sub", "a", "b"), argv);
+ assertEquals(Arrays.asList("sub", "a", "", "b"), argv);
}
@Test
diff --git a/src/test/java/io/github/easy4j/opencli/core/OpenCliArgSupportTest.java b/src/test/java/io/github/easy4j/opencli/core/OpenCliArgSupportTest.java
index f5efc59..977ec52 100644
--- a/src/test/java/io/github/easy4j/opencli/core/OpenCliArgSupportTest.java
+++ b/src/test/java/io/github/easy4j/opencli/core/OpenCliArgSupportTest.java
@@ -15,9 +15,15 @@ void shouldMergeNonNullLists() {
}
@Test
- void shouldFilterNullAndBlankWhenMerging() {
- List result = OpenCliArgSupport.merge(Arrays.asList("a", null, "", " ", "b"), Arrays.asList("c"));
- assertEquals(Arrays.asList("a", "b", "c"), result);
+ void shouldRejectNullElementWhenMerging() {
+ assertThrows(IllegalArgumentException.class,
+ () -> OpenCliArgSupport.merge(Arrays.asList("a", null, "", " ", "b"), Arrays.asList("c")));
+ }
+
+ @Test
+ void shouldPreserveEmptyAndBlankWhenMerging() {
+ assertEquals(Arrays.asList("a", "", " ", "b", "c"),
+ OpenCliArgSupport.merge(Arrays.asList("a", "", " ", "b"), Arrays.asList("c")));
}
@Test
diff --git a/src/test/resources/opencli-contracts/v1/SHA256SUMS b/src/test/resources/opencli-contracts/v1/SHA256SUMS
new file mode 100644
index 0000000..c7a5e08
--- /dev/null
+++ b/src/test/resources/opencli-contracts/v1/SHA256SUMS
@@ -0,0 +1,2 @@
+e7e6dd57e81e24e92a7cb0de0e1204f95025f80310f5dc34d820aa7419636f78 argv.tsv
+2fa30626c415bb3c152bc4c2827cd9282ba710654f6a56fb7bde70a83b2bc192 sources.lock.json
diff --git a/src/test/resources/opencli-contracts/v1/argv.tsv b/src/test/resources/opencli-contracts/v1/argv.tsv
new file mode 100644
index 0000000..bb4ce3a
--- /dev/null
+++ b/src/test/resources/opencli-contracts/v1/argv.tsv
@@ -0,0 +1,6 @@
+ordinary ZGVtbw== ZWNobw== aGVsbG8=
+empty ZGVtbw== ZWNobw==
+whitespace ZGVtbw== ZWNobw== ICB4ICA= ICAg
+unicode-newline ZGVtbw== ZWNobw== CuS4reaWhwo= 8J+Zgg==
+literal-shell ZGVtbw== ZWNobw== JChwcmludGYgU0hPVUxEX05PVF9SVU4pOyAq LS0= LWxpdGVyYWw=
+equals-quotes ZGVtbw== ZWNobw== LS1rZXk9YT1i ImxpdGVyYWwi Qzpc6Lev5b6EXGZpbGUgbmFtZQ==
diff --git a/src/test/resources/opencli-contracts/v1/sources.lock.json b/src/test/resources/opencli-contracts/v1/sources.lock.json
new file mode 100644
index 0000000..a1ed16b
--- /dev/null
+++ b/src/test/resources/opencli-contracts/v1/sources.lock.json
@@ -0,0 +1,58 @@
+{
+ "schemaVersion": 1,
+ "observedAt": "2026-09-21",
+ "scope": "Synthetic offline argv vectors; not captures from OpenCLI or websites.",
+ "source": {
+ "kind": "specification",
+ "requirements": [
+ "OC-ARGV-001",
+ "OC-ARGV-002",
+ "OC-ARGV-004",
+ "OC-ARGV-005"
+ ],
+ "ref": "d0c8056990f7a47fcc202acffa387ba066bcfc67",
+ "path": "openspec/changes/harden-opencli-argv-contract/specs/opencli-argv-contract/spec.md"
+ },
+ "files": [
+ {
+ "path": "argv.tsv",
+ "sha256": "e7e6dd57e81e24e92a7cb0de0e1204f95025f80310f5dc34d820aa7419636f78",
+ "vectors": 6,
+ "encoding": "case-id then tab-separated base64 UTF-8 tokens; preserve trailing empty fields"
+ }
+ ],
+ "branches": [
+ {
+ "branch": "feature/1.0.x",
+ "base": "abba809f11dae68437c39d2ea5a2f4cf8798c0ef",
+ "java": 8,
+ "jackson": 2,
+ "mavenMajor": 3
+ },
+ {
+ "branch": "feature/2.0.x",
+ "base": "d0c8056990f7a47fcc202acffa387ba066bcfc67",
+ "java": 17,
+ "jackson": 2,
+ "mavenMajor": 3,
+ "canonical": true
+ },
+ {
+ "branch": "feature/3.0.x",
+ "base": "6e38904bdfcae90ec617e8d29bf3d8cf2f002893",
+ "java": 21,
+ "jackson": 3,
+ "mavenMajor": 4
+ }
+ ],
+ "allowedDifferences": [
+ "JDK baseline",
+ "Jackson imports/decoder internals",
+ "Maven wrapper/POM schema"
+ ],
+ "forbiddenDifferences": [
+ "raw token contents/order/count",
+ "null validation",
+ "observable semantics of shared vectors"
+ ]
+}