Skip to content

Commit 8ad9df8

Browse files
committed
feat(lifecycle): 同步 3.0.x 远程传输实例化 + 输出缓冲上限(Jackson 2 / JDK 17 适配)
- OpenCliRemoteAgentHttpClient 改用 Unirest.spawnInstance() 私有实例 + AutoCloseable(shutDown 幂等,close 后 collect 抛 IllegalStateException) - OpenCliExecutor / OpenCliClient 实现 AutoCloseable 并透传 close - 子进程输出 maxOutputBytes 捕获上限(默认 10 MiB,<=0 不限), 超限丢弃 + 尾部截断标记 + warn - Jackson 2 受检异常适配:serialize/envelope/mapResponse 三处 JsonProcessingException 捕获与声明(envelope 解析失败改为包装为 OpenCliExecutableFailureException) - 测试 1998 全绿(+6:有界捕获/无上限/截断标记/close 幂等)
1 parent d0c8056 commit 8ad9df8

8 files changed

Lines changed: 230 additions & 26 deletions

File tree

src/main/java/io/github/easy4j/opencli/OpenCliClient.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
*
4040
* @author <a href="https://github.com/loong10k">Loong Wan</a>
4141
* @since 3.0.0
42-
*/public class OpenCliClient {
42+
*/public class OpenCliClient implements AutoCloseable {
4343

4444
@Getter
4545
private final OpenCliProperties properties;
@@ -118,6 +118,16 @@ public JimengOpenCliClient jimeng() {
118118
}
119119

120120
/** @return DeepSeek 浏览器适配器客户端 */
121+
/**
122+
* 释放底层资源:本地模式为空操作;REMOTE_AGENT_HTTP 模式下关闭
123+
* 远程传输(实例级 shutdown,不影响 JVM 其它 Unirest 使用方)。
124+
* close 之后远程调用抛 IllegalStateException,本地调用不受影响。
125+
*/
126+
@Override
127+
public void close() {
128+
executor.close();
129+
}
130+
121131
public DeepseekOpenCliClient deepseek() {
122132
return new DeepseekOpenCliClient(executor);
123133
}

src/main/java/io/github/easy4j/opencli/OpenCliProperties.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ public class OpenCliProperties {
8888
*/
8989
private long commandTimeoutMillis = 300_000L;
9090

91+
/**
92+
* 子进程单流(stdout/stderr 各自计)输出捕获上限(字节)。超过部分丢弃,
93+
* 并在结果字符串尾部追加截断标记;{@code <= 0} 表示不限制。默认 10 MiB。
94+
*/
95+
private long maxOutputBytes = 10L * 1024 * 1024;
96+
9197
/**
9298
* 本机 CLI 子进程最大并发数;小于等于 0 时使用 CPU 核心数与 2 的较大值。
9399
*/

src/main/java/io/github/easy4j/opencli/core/OpenCliExecutor.java

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
6464
*/
6565

66-
public class OpenCliExecutor {
66+
public class OpenCliExecutor implements AutoCloseable {
6767

6868
private final OpenCliProperties properties;
6969

@@ -106,6 +106,19 @@ public OpenCliResult invoke(List<String> adapterAndRest) {
106106
/**
107107
* @return 远程 Agent HTTP 客户端(懒加载)
108108
*/
109+
/**
110+
* 释放远程传输资源:仅当 REMOTE_AGENT_HTTP 模式下已懒加载 HTTP 客户端时
111+
* 生效(实例级 shutdown,不影响 JVM 中其它 Unirest 使用方);本地模式为
112+
* 空操作。close 之后远程调用不可用,本地调用不受影响。
113+
*/
114+
@Override
115+
public void close() {
116+
OpenCliRemoteAgentHttpClient client = remoteAgentHttpClient;
117+
if (Objects.nonNull(client)) {
118+
client.close();
119+
}
120+
}
121+
109122
private OpenCliRemoteAgentHttpClient remoteAgent() {
110123
if (Objects.isNull(remoteAgentHttpClient)) {
111124
synchronized (this) {
@@ -190,7 +203,8 @@ private OpenCliResult run(CommandLine commandLine) {
190203
Map<String, String> environment = buildEnvironment();
191204
SubprocessExecutionSupport.ExecutionRequest request =
192205
new SubprocessExecutionSupport.ExecutionRequest(
193-
commandLine, workingDirectory, environment, timeoutMs);
206+
commandLine, workingDirectory, environment, timeoutMs,
207+
properties.getMaxOutputBytes());
194208

195209
try {
196210
SubprocessExecutionSupport.RunSession session = SubprocessExecutionSupport.execute(request);
@@ -229,13 +243,23 @@ private File resolveWorkingDirectory() {
229243
private OpenCliResult completeAfterWait(
230244
CommandLine commandLine,
231245
long timeoutMs,
232-
ByteArrayOutputStream out,
233-
ByteArrayOutputStream err,
246+
SubprocessExecutionSupport.BoundedOutputStream out,
247+
SubprocessExecutionSupport.BoundedOutputStream err,
234248
DefaultExecuteResultHandler handler,
235249
ExecuteWatchdog watchdog,
236250
boolean waitTimedOut) {
237251
String stdoutStr = new String(out.toByteArray(), StandardCharsets.UTF_8);
238252
String stderrStr = new String(err.toByteArray(), StandardCharsets.UTF_8);
253+
if (out.isOverflowed()) {
254+
log.warn("OpenCLI stdout truncated at maxOutputBytes={}", properties.getMaxOutputBytes());
255+
stdoutStr = stdoutStr + "\n[opencli-java-sdk] stdout truncated at maxOutputBytes="
256+
+ properties.getMaxOutputBytes();
257+
}
258+
if (err.isOverflowed()) {
259+
log.warn("OpenCLI stderr truncated at maxOutputBytes={}", properties.getMaxOutputBytes());
260+
stderrStr = stderrStr + "\n[opencli-java-sdk] stderr truncated at maxOutputBytes="
261+
+ properties.getMaxOutputBytes();
262+
}
239263
OpenCliParsedFields parsed = OpenCliOutputParser.parseBestEffort(stdoutStr, stderrStr);
240264

241265
if (waitTimedOut || watchdog.killedProcess()) {

src/main/java/io/github/easy4j/opencli/core/support/SubprocessExecutionSupport.java

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import org.apache.commons.exec.PumpStreamHandler;
99

1010
import java.io.ByteArrayOutputStream;
11+
import java.io.OutputStream;
1112
import java.io.File;
1213
import java.io.IOException;
1314
import java.time.Duration;
@@ -71,8 +72,8 @@ public static RunSession execute(ExecutionRequest request) throws IOException, I
7172

7273
private static RunSession executeWithinLimit(ExecutionRequest request) throws IOException, InterruptedException {
7374
long timeoutMs = Math.max(1L, request.getTimeoutMillis());
74-
ByteArrayOutputStream out = new ByteArrayOutputStream();
75-
ByteArrayOutputStream err = new ByteArrayOutputStream();
75+
BoundedOutputStream out = new BoundedOutputStream(request.getMaxOutputBytes());
76+
BoundedOutputStream err = new BoundedOutputStream(request.getMaxOutputBytes());
7677

7778
DefaultExecutor.Builder builder = DefaultExecutor.builder();
7879
if (request.getWorkingDirectory() != null) {
@@ -122,32 +123,43 @@ public static final class ExecutionRequest {
122123
private final File workingDirectory;
123124
private final Map<String, String> environment;
124125
private final long timeoutMillis;
126+
private final long maxOutputBytes;
125127

126128
public ExecutionRequest(
127129
CommandLine commandLine,
128130
File workingDirectory,
129131
Map<String, String> environment,
130132
long timeoutMillis) {
131-
this.commandLine = Objects.requireNonNull(commandLine, "commandLine");
132-
this.workingDirectory = workingDirectory;
133-
this.environment = environment;
134-
this.timeoutMillis = timeoutMillis;
135-
}
133+
this(commandLine, workingDirectory, environment, timeoutMillis, 0L);
134+
}
135+
136+
public ExecutionRequest(
137+
CommandLine commandLine,
138+
File workingDirectory,
139+
Map<String, String> environment,
140+
long timeoutMillis,
141+
long maxOutputBytes) {
142+
this.commandLine = Objects.requireNonNull(commandLine, "commandLine");
143+
this.workingDirectory = workingDirectory;
144+
this.environment = environment;
145+
this.timeoutMillis = timeoutMillis;
146+
this.maxOutputBytes = maxOutputBytes;
147+
}
136148
}
137149

138150
@Getter
139151
public static final class RunSession {
140152

141-
private final ByteArrayOutputStream stdout;
142-
private final ByteArrayOutputStream stderr;
153+
private final BoundedOutputStream stdout;
154+
private final BoundedOutputStream stderr;
143155
private final DefaultExecuteResultHandler handler;
144156
private final ExecuteWatchdog watchdog;
145157
private final long timeoutMillis;
146158
private final boolean waitTimedOut;
147159

148160
RunSession(
149-
ByteArrayOutputStream stdout,
150-
ByteArrayOutputStream stderr,
161+
BoundedOutputStream stdout,
162+
BoundedOutputStream stderr,
151163
DefaultExecuteResultHandler handler,
152164
ExecuteWatchdog watchdog,
153165
long timeoutMillis,
@@ -163,5 +175,61 @@ public static final class RunSession {
163175
public boolean timedOut() {
164176
return waitTimedOut || watchdog.killedProcess();
165177
}
178+
179+
boolean isStdoutOverflowed() {
180+
return stdout.isOverflowed();
181+
}
182+
183+
boolean isStderrOverflowed() {
184+
return stderr.isOverflowed();
185+
}
186+
}
187+
188+
/**
189+
* 有界内存输出流:超过上限的字节直接丢弃(保留前 maxBytes 字节),
190+
* 并置溢出标志供调用方追加截断标记。close 为空操作(纯内存流)。
191+
*/
192+
public static final class BoundedOutputStream extends OutputStream {
193+
194+
private final ByteArrayOutputStream delegate = new ByteArrayOutputStream();
195+
private final long maxBytes;
196+
private boolean overflowed;
197+
198+
BoundedOutputStream(long maxBytes) {
199+
// maxBytes <= 0 视为不限制
200+
this.maxBytes = Math.max(0L, maxBytes);
201+
}
202+
203+
@Override
204+
public synchronized void write(int b) {
205+
write(new byte[] {(byte) b}, 0, 1);
206+
}
207+
208+
@Override
209+
public synchronized void write(byte[] b, int off, int len) {
210+
if (maxBytes == 0L) {
211+
delegate.write(b, off, len);
212+
return;
213+
}
214+
long remaining = maxBytes - delegate.size();
215+
if (remaining <= 0L) {
216+
overflowed = true;
217+
return;
218+
}
219+
if (len > remaining) {
220+
delegate.write(b, off, (int) remaining);
221+
overflowed = true;
222+
} else {
223+
delegate.write(b, off, len);
224+
}
225+
}
226+
227+
public synchronized byte[] toByteArray() {
228+
return delegate.toByteArray();
229+
}
230+
231+
public synchronized boolean isOverflowed() {
232+
return overflowed;
233+
}
166234
}
167235
}

src/main/java/io/github/easy4j/opencli/remote/OpenCliRemoteAgentHttpClient.java

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
package io.github.easy4j.opencli.remote;
22

33
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
4+
import com.fasterxml.jackson.core.JacksonException;
45
import com.fasterxml.jackson.databind.JsonNode;
5-
import com.fasterxml.jackson.databind.ObjectMapper;
6+
import com.fasterxml.jackson.databind.json.JsonMapper;
67
import io.github.easy4j.opencli.OpenCliProperties;
78
import io.github.easy4j.opencli.core.OpenCliOutputParser;
89
import io.github.easy4j.opencli.core.OpenCliResult;
910
import io.github.easy4j.opencli.exception.OpenCliExecutableFailureException;
1011
import io.github.easy4j.opencli.exception.OpenCliNonZeroExitException;
1112
import io.github.easy4j.opencli.parser.OpenCliParsedFields;
1213
import io.github.easy4j.opencli.util.OpenCliStrings;
13-
import java.io.IOException;
1414
import java.util.Objects;
1515
import kong.unirest.HttpResponse;
1616
import kong.unirest.Unirest;
@@ -38,12 +38,16 @@
3838
3939
*/
4040

41-
public final class OpenCliRemoteAgentHttpClient {
41+
public final class OpenCliRemoteAgentHttpClient implements AutoCloseable {
4242

43-
private static final ObjectMapper MAPPER = new ObjectMapper();
43+
private static final JsonMapper MAPPER = new JsonMapper();
4444

4545
private final OpenCliProperties properties;
4646

47+
private final Object transportLock = new Object();
48+
private volatile kong.unirest.UnirestInstance transport;
49+
private volatile boolean closed;
50+
4751
/**
4852
* @param properties 含 {@code remoteAgentBaseUrl} 等配置
4953
*/
@@ -68,12 +72,13 @@ public OpenCliResult collect(OpenCliCollectRequest request) {
6872
String bodyJson;
6973
try {
7074
bodyJson = MAPPER.writeValueAsString(request);
71-
} catch (IOException e) {
75+
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
7276
throw new OpenCliExecutableFailureException("Failed to serialize collect request: " + e.getMessage(), e);
7377
}
7478
try {
7579
HttpResponse<String> response =
76-
Unirest.post(url)
80+
transport()
81+
.post(url)
7782
.connectTimeout(timeout)
7883
.socketTimeout(timeout)
7984
.header("Content-Type", "application/json; charset=UTF-8")
@@ -96,7 +101,7 @@ public OpenCliResult collect(OpenCliCollectRequest request) {
96101
return mapResponse(respBody, url);
97102
} catch (OpenCliNonZeroExitException e) {
98103
throw e;
99-
} catch (IOException e) {
104+
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
100105
log.warn("Agent response parse failed url={} message={}", url, e.getMessage());
101106
throw new OpenCliExecutableFailureException("Failed to parse agent response: " + e.getMessage(), e);
102107
} catch (UnirestException e) {
@@ -105,6 +110,44 @@ public OpenCliResult collect(OpenCliCollectRequest request) {
105110
}
106111
}
107112

113+
/**
114+
* 懒加载本客户端私有的 Unirest 实例({@code Unirest.spawn})——
115+
* shutdown 只影响自身,不触碰 JVM 全局主实例。
116+
*/
117+
private kong.unirest.UnirestInstance transport() {
118+
if (closed) {
119+
throw new IllegalStateException("OpenCLI remote agent HTTP transport is closed");
120+
}
121+
kong.unirest.UnirestInstance instance = transport;
122+
if (Objects.isNull(instance)) {
123+
synchronized (transportLock) {
124+
if (Objects.isNull(transport)) {
125+
transport = kong.unirest.Unirest.spawnInstance();
126+
}
127+
instance = transport;
128+
}
129+
}
130+
return instance;
131+
}
132+
133+
/**
134+
* 关闭本客户端的 Unirest 实例(幂等)。close 之后远程调用抛
135+
* {@link IllegalStateException};JVM 全局主实例不受影响。
136+
*/
137+
@Override
138+
public void close() {
139+
closed = true;
140+
kong.unirest.UnirestInstance instance = transport;
141+
if (Objects.nonNull(instance)) {
142+
synchronized (transportLock) {
143+
instance = transport;
144+
}
145+
}
146+
if (Objects.nonNull(instance)) {
147+
instance.shutDown();
148+
}
149+
}
150+
108151
private int resolveTimeoutMillis() {
109152
long timeoutMs = properties.getCommandTimeoutMillis();
110153
if (timeoutMs <= 0) {
@@ -113,14 +156,16 @@ private int resolveTimeoutMillis() {
113156
return (int) Math.min(timeoutMs, Integer.MAX_VALUE);
114157
}
115158

116-
private OpenCliResult mapResponse(String respBody, String url) throws IOException {
159+
private OpenCliResult mapResponse(String respBody, String url)
160+
throws com.fasterxml.jackson.core.JsonProcessingException {
117161
String rawCapture = captureRawIfEnabled(respBody);
118162
AgentCollectEnvelope env;
119163
try {
120164
env = MAPPER.readValue(respBody, AgentCollectEnvelope.class);
121-
} catch (IOException e) {
165+
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
122166
log.warn("Agent response envelope parse failed url={} message={}", url, e.getMessage());
123-
throw e;
167+
throw new OpenCliExecutableFailureException(
168+
"Failed to parse agent response envelope: " + e.getMessage(), e);
124169
}
125170
boolean success = Objects.nonNull(env.success) && env.success;
126171
String err = Objects.isNull(env.error) ? "" : env.error;

src/test/java/io/github/easy4j/opencli/core/OpenCliExecutorFullTest.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,22 @@ void shouldAppendQuotedKeyValueWithTrailingEquals() {
3434
assertTrue(args[args.length - 1].contains("--key=val"));
3535
}
3636

37+
@Test
38+
void shouldTruncateOversizedOutputWithMarker() {
39+
OpenCliProperties props = new OpenCliProperties();
40+
props.setExecutable("/bin/sh");
41+
props.setMaxOutputBytes(64L);
42+
props.setCommandTimeoutMillis(30_000L);
43+
OpenCliExecutor executor = new OpenCliExecutor(props);
44+
45+
OpenCliResult result = executor.invoke("-c",
46+
"printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'");
47+
48+
assertTrue(result.isSuccess(), "truncation must not fail the run");
49+
assertTrue(result.getStdout().contains("truncated"),
50+
"overflow must leave a truncation marker: " + result.getStdout());
51+
}
52+
3753
@Test
3854
void shouldDecodeUtf8OutputRegardlessOfPlatformCharset() {
3955
OpenCliProperties props = new OpenCliProperties();

0 commit comments

Comments
 (0)