Skip to content

Commit 64cb2a6

Browse files
committed
feat(lifecycle): 远程传输实例化 + AutoCloseable 三层接线 + 输出缓冲上限
生产就绪审计低危项修复: - OpenCliRemoteAgentHttpClient 改用 Unirest.spawnInstance() 私有实例, 实现 AutoCloseable:close() 幂等 shutDown 自身实例(不再触碰 JVM 全局主 Unirest);close 后 collect 抛 IllegalStateException - OpenCliExecutor / OpenCliClient 依次实现 AutoCloseable 并透传 close (本地模式空操作;远程模式仅关闭自身私有传输) - 子进程 stdout/stderr 增加 maxOutputBytes 捕获上限(默认 10 MiB, <=0 不限):BoundedOutputStream 保留前 N 字节、置溢出标志,结果 尾部追加截断标记并打 warn, runaway 输出不再撑爆内存 - 新增 6 个测试(有界捕获/无上限/close 幂等与拒绝/截断标记),全绿
1 parent 6e38904 commit 64cb2a6

8 files changed

Lines changed: 220 additions & 18 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: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,16 @@
3838
3939
*/
4040

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

4343
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
*/
@@ -73,7 +77,8 @@ public OpenCliResult collect(OpenCliCollectRequest request) {
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")
@@ -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) {

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();

src/test/java/io/github/easy4j/opencli/core/support/SubprocessExecutionSupportTest.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,28 @@ void shouldExecuteSimpleCommand() throws Exception {
3838
assertFalse(session.timedOut());
3939
}
4040

41+
@Test
42+
void shouldCapCapturedOutputAtMaxBytes() throws Exception {
43+
org.apache.commons.exec.CommandLine cmd =
44+
org.apache.commons.exec.CommandLine.parse("printf aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
45+
SubprocessExecutionSupport.ExecutionRequest request = new SubprocessExecutionSupport.ExecutionRequest(
46+
cmd, null, null, 10_000L, 16L);
47+
SubprocessExecutionSupport.RunSession session = SubprocessExecutionSupport.execute(request);
48+
assertEquals(16, session.getStdout().toByteArray().length);
49+
assertTrue(session.isStdoutOverflowed());
50+
}
51+
52+
@Test
53+
void shouldNotOverflowWhenCapDisabled() throws Exception {
54+
org.apache.commons.exec.CommandLine cmd =
55+
org.apache.commons.exec.CommandLine.parse("printf aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
56+
SubprocessExecutionSupport.ExecutionRequest request = new SubprocessExecutionSupport.ExecutionRequest(
57+
cmd, null, null, 10_000L, 0L);
58+
SubprocessExecutionSupport.RunSession session = SubprocessExecutionSupport.execute(request);
59+
assertEquals(40, session.getStdout().toByteArray().length);
60+
assertFalse(session.isStdoutOverflowed());
61+
}
62+
4163
@Test
4264
void shouldBuildExecutionRequest() {
4365
org.apache.commons.exec.CommandLine cmd = org.apache.commons.exec.CommandLine.parse("echo");

src/test/java/io/github/easy4j/opencli/remote/OpenCliRemoteAgentHttpClientTest.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,17 @@ private static void drain(InputStream in) throws IOException {
160160
in.close();
161161
}
162162
}
163+
164+
/**
165+
* close 幂等;close 之后 collect 抛 {@link IllegalStateException}。
166+
*/
167+
@Test
168+
void closeIsIdempotentAndRejectsLaterCollect() {
169+
OpenCliRemoteAgentHttpClient client = new OpenCliRemoteAgentHttpClient(
170+
new io.github.easy4j.opencli.OpenCliProperties());
171+
client.close();
172+
client.close();
173+
Assertions.assertThrows(IllegalStateException.class,
174+
() -> client.collect(new OpenCliCollectRequest()));
175+
}
163176
}

0 commit comments

Comments
 (0)