Skip to content

Commit 892919d

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

8 files changed

Lines changed: 230 additions & 27 deletions

File tree

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@
3737
* OpenCliResult result = client.chatgpt().ask("hello", null, null);
3838
* }</pre>
3939
*
40-
* @author [@Loong Wan](https://github.com/loong10k)
40+
* @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: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,14 @@
22

33
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
44
import com.fasterxml.jackson.databind.JsonNode;
5-
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import com.fasterxml.jackson.databind.json.JsonMapper;
66
import io.github.easy4j.opencli.OpenCliProperties;
77
import io.github.easy4j.opencli.core.OpenCliOutputParser;
88
import io.github.easy4j.opencli.core.OpenCliResult;
99
import io.github.easy4j.opencli.exception.OpenCliExecutableFailureException;
1010
import io.github.easy4j.opencli.exception.OpenCliNonZeroExitException;
1111
import io.github.easy4j.opencli.parser.OpenCliParsedFields;
1212
import io.github.easy4j.opencli.util.OpenCliStrings;
13-
import java.io.IOException;
1413
import java.util.Objects;
1514
import kong.unirest.HttpResponse;
1615
import kong.unirest.Unirest;
@@ -38,12 +37,16 @@
3837
3938
*/
4039

41-
public final class OpenCliRemoteAgentHttpClient {
40+
public final class OpenCliRemoteAgentHttpClient implements AutoCloseable {
4241

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

4544
private final OpenCliProperties properties;
4645

46+
private final Object transportLock = new Object();
47+
private volatile kong.unirest.UnirestInstance transport;
48+
private volatile boolean closed;
49+
4750
/**
4851
* @param properties 含 {@code remoteAgentBaseUrl} 等配置
4952
*/
@@ -68,12 +71,13 @@ public OpenCliResult collect(OpenCliCollectRequest request) {
6871
String bodyJson;
6972
try {
7073
bodyJson = MAPPER.writeValueAsString(request);
71-
} catch (IOException e) {
74+
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
7275
throw new OpenCliExecutableFailureException("Failed to serialize collect request: " + e.getMessage(), e);
7376
}
7477
try {
7578
HttpResponse<String> response =
76-
Unirest.post(url)
79+
transport()
80+
.post(url)
7781
.connectTimeout(timeout)
7882
.socketTimeout(timeout)
7983
.header("Content-Type", "application/json; charset=UTF-8")
@@ -96,7 +100,7 @@ public OpenCliResult collect(OpenCliCollectRequest request) {
96100
return mapResponse(respBody, url);
97101
} catch (OpenCliNonZeroExitException e) {
98102
throw e;
99-
} catch (IOException e) {
103+
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
100104
log.warn("Agent response parse failed url={} message={}", url, e.getMessage());
101105
throw new OpenCliExecutableFailureException("Failed to parse agent response: " + e.getMessage(), e);
102106
} catch (UnirestException e) {
@@ -105,6 +109,44 @@ public OpenCliResult collect(OpenCliCollectRequest request) {
105109
}
106110
}
107111

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

116-
private OpenCliResult mapResponse(String respBody, String url) throws IOException {
158+
private OpenCliResult mapResponse(String respBody, String url)
159+
throws com.fasterxml.jackson.core.JsonProcessingException {
117160
String rawCapture = captureRawIfEnabled(respBody);
118161
AgentCollectEnvelope env;
119162
try {
120163
env = MAPPER.readValue(respBody, AgentCollectEnvelope.class);
121-
} catch (IOException e) {
164+
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
122165
log.warn("Agent response envelope parse failed url={} message={}", url, e.getMessage());
123-
throw e;
166+
throw new OpenCliExecutableFailureException(
167+
"Failed to parse agent response envelope: " + e.getMessage(), e);
124168
}
125169
boolean success = Objects.nonNull(env.success) && env.success;
126170
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)