Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
import io.agentscope.harness.agent.filesystem.sandbox.AbstractSandboxFilesystem;
import io.agentscope.harness.agent.workspace.LocalFsMode;
import io.agentscope.harness.agent.workspace.PathPolicy;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
Expand Down Expand Up @@ -52,6 +54,15 @@ public class LocalFilesystemWithShell extends LocalFilesystem implements Abstrac
/** Default timeout in seconds for shell command execution. */
public static final int DEFAULT_EXECUTE_TIMEOUT = 120;

/** Read buffer size (bytes) for the stream drainer threads. */
private static final int DRAIN_CHUNK_BYTES = 8192;

/**
* Safety net (millis) for joining drainer threads after the process exits or is destroyed.
* Never reached in practice: process exit closes the streams and ends the drainers at once.
*/
private static final long DRAIN_JOIN_TIMEOUT_MILLIS = 5000;

private final String sandboxId;
private final int defaultTimeout;
private final int maxOutputBytes;
Expand Down Expand Up @@ -335,14 +346,27 @@ public ExecuteResponse execute(

Process proc = pb.start();

// stdout/stderr must be drained concurrently with waitFor: if the child writes
// more than the OS pipe buffer (~4 KB on Windows, 64 KB default on Linux) while
// the parent blocks in waitFor, both sides deadlock and every such command is
// misreported as a timeout (exit 124).
ByteArrayOutputStream stdoutBuf = new ByteArrayOutputStream();
ByteArrayOutputStream stderrBuf = new ByteArrayOutputStream();
Thread stdoutDrainer = drainAsync(proc.getInputStream(), stdoutBuf);
Thread stderrDrainer = drainAsync(proc.getErrorStream(), stderrBuf);

boolean finished = proc.waitFor(effectiveTimeout, TimeUnit.SECONDS);
if (!finished) {
proc.destroyForcibly();
}
joinQuietly(stdoutDrainer);
joinQuietly(stderrDrainer);

Charset outputCharset = outputCharset(osName);
String stdout = new String(proc.getInputStream().readAllBytes(), outputCharset);
String stderr = new String(proc.getErrorStream().readAllBytes(), outputCharset);
String stdout = stdoutBuf.toString(outputCharset);
String stderr = stderrBuf.toString(outputCharset);

if (!finished) {
proc.destroyForcibly();
String msg;
if (timeoutSeconds != null) {
msg =
Expand Down Expand Up @@ -432,6 +456,38 @@ private Path resolveExecuteCwd(RuntimeContext rc) {
return namespaced;
}

/**
* Continuously copies a subprocess stream into {@code buf} on a daemon thread so the child
* never blocks on a full OS pipe buffer. Read errors (e.g. the stream closing when the
* process is destroyed on timeout) end the drainer quietly.
*/
private static Thread drainAsync(InputStream in, ByteArrayOutputStream buf) {
Thread t =
new Thread(
() -> {
byte[] chunk = new byte[DRAIN_CHUNK_BYTES];
int n;
try {
while ((n = in.read(chunk)) != -1) {
buf.write(chunk, 0, n);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit / follow-up: please bound memory while draining, not only after assembling the final string.

maxOutputBytes currently truncates post-hoc, so a huge command can OOM before truncation. Cap buf.write once buf.size() hits the limit, but keep reading so the OS pipe stays drained (same idea as gemini-cli / qwen-code). Mark truncated when bytes were discarded.

}
} catch (IOException ignored) {
// Stream closed because the process was destroyed; nothing to do.
}
});
t.setDaemon(true);
t.start();
return t;
}

private static void joinQuietly(Thread t) {
try {
t.join(DRAIN_JOIN_TIMEOUT_MILLIS);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit / follow-up: please align reader completion with ShellCommandTool.

join(5s) may return while the drainer is still alive; ByteArrayOutputStream is not thread-safe, so calling toString() concurrently with write is racy. Prefer Future.get(timeout) + cancel(true) and discard untrusted output on timeout, or after join check isAlive() → interrupt and do not trust the buffer.

} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}

static Charset outputCharset(String osName) {
return outputCharset(osName, System.getProperty("native.encoding"));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@
package io.agentscope.harness.agent.filesystem.local;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;

import io.agentscope.harness.agent.filesystem.model.ExecuteResponse;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

class LocalFilesystemWithShellTest {

Expand All @@ -41,4 +45,29 @@ void outputCharset_fallsBackToDefaultWhenWindowsNativeEncodingIsUnavailable() {
Charset.defaultCharset(),
LocalFilesystemWithShell.outputCharset("Windows 10", null));
}

@Test
void execute_outputLargerThanOsPipeBufferCompletesWithoutDeadlock(@TempDir Path tempDir) {
// ~68-72 KB of stdout: beyond the OS pipe buffer (~4 KB on Windows, 64 KB on Linux),
// below the default maxOutputBytes cap. Before stdout/stderr were drained concurrently
// with waitFor, this deadlocked and was misreported as a timeout (exit 124).
int lines = 4000;
String payload = "0123456789abcdef"; // 16 chars per line
boolean windows = System.getProperty("os.name").toLowerCase().contains("win");
String command =
windows
? "for /l %i in (1,1," + lines + ") do @echo " + payload
: "i=0; while [ \"$i\" -lt "
+ lines
+ " ]; do echo "
+ payload
+ "; i=$((i+1)); done";

LocalFilesystemWithShell fs = new LocalFilesystemWithShell(tempDir);
ExecuteResponse resp = fs.execute(null, command, 60);

assertEquals(0, resp.exitCode(), "unexpected exit code, output: " + resp.output());
assertFalse(resp.truncated());
assertEquals(lines, resp.output().split(payload, -1).length - 1);
}
}
Loading