Repository navigation
fix(sandbox-e2b): reliable exec timeout with server-side kill #2976
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
b64ed41
ec8cf44
d995191
a007a21
298b138
da3eb1b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -119,10 +119,19 @@ byte[] runShellBinaryStdout( | |
| private ShellCapture runShellCapture( | ||
| E2bSandboxState state, String cwd, String shellCommand, int timeoutSeconds) | ||
| throws Exception { | ||
| if (timeoutSeconds <= 0) { | ||
| throw new IllegalArgumentException( | ||
| "timeoutSeconds must be positive: " + timeoutSeconds); | ||
| } | ||
| OkHttpClient callClient = | ||
| timeoutSeconds > 0 | ||
| ? http.newBuilder().callTimeout(timeoutSeconds, TimeUnit.SECONDS).build() | ||
| : http; | ||
| http.newBuilder() | ||
| .callTimeout(timeoutSeconds, TimeUnit.SECONDS) | ||
| // Disable the idle read timeout: it fires on gaps between bytes and | ||
| // would preempt callTimeout (both surface as InterruptedIOException), | ||
| // misreporting a short idle stall as a full exec timeout (#2974). | ||
| // Total-duration semantics is owned solely by callTimeout. | ||
| .readTimeout(0, TimeUnit.SECONDS) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Info] A |
||
| .build(); | ||
| String host = envdHost(state); | ||
| String url = host + "/process.Process/Start"; | ||
| byte[] envelope = encodeStartRequestEnvelope(shellCommand, cwd); | ||
|
|
@@ -134,7 +143,10 @@ private ShellCapture runShellCapture( | |
| .addHeader("User-Agent", "agentscope-java-e2b") | ||
| .addHeader("E2b-Sandbox-Id", state.getSandboxId()) | ||
| .addHeader("E2b-Sandbox-Port", Integer.toString(ENVD_PORT)) | ||
| .addHeader("Authorization", basicAuthUser(opt.getRunUser())); | ||
| .addHeader("Authorization", basicAuthUser(opt.getRunUser())) | ||
| // Ask envd to kill the remote process when this lapses; otherwise a | ||
| // client-side timeout only drops our HTTP stream and leaks the process. | ||
| .addHeader("Connect-Timeout-Ms", String.valueOf(timeoutSeconds * 1000L)); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Warning] The server-side deadline and the client-side Give the server a small head start so its close frame can arrive first: long timeoutMs = TimeUnit.SECONDS.toMillis(timeoutSeconds);
rb.addHeader("Connect-Timeout-Ms", String.valueOf(timeoutMs));
// client waits slightly longer than envd's own deadline
OkHttpClient callClient = http.newBuilder()
.callTimeout(timeoutMs + 5_000L, TimeUnit.MILLISECONDS)
.readTimeout(0, TimeUnit.SECONDS)
.build();Also |
||
| if (state.getEnvdAccessToken() != null && !state.getEnvdAccessToken().isBlank()) { | ||
| rb.addHeader("X-Access-Token", state.getEnvdAccessToken()); | ||
| } | ||
|
|
@@ -145,7 +157,7 @@ private ShellCapture runShellCapture( | |
| int exit; | ||
| try (Response res = callClient.newCall(req).execute()) { | ||
| if (!res.isSuccessful()) { | ||
| String err = res.body() != null ? res.body().string() : ""; | ||
| String err = res.body().string(); | ||
| throw new SandboxException.SandboxRuntimeException( | ||
| SandboxErrorCode.WORKSPACE_START_ERROR, | ||
| "envd Start failed HTTP " + res.code() + ": " + err); | ||
|
|
@@ -154,6 +166,12 @@ private ShellCapture runShellCapture( | |
| exit = drainStartStream(in, stdout, stderr); | ||
| } | ||
| } catch (InterruptedIOException e) { | ||
| // External cancellation surfaces here too; don't misreport it as a timeout | ||
| // and don't swallow the interrupt bit. | ||
| if (Thread.currentThread().isInterrupted()) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Warning] Restoring the interrupt bit and rethrowing is the right call, but the exception type now leaves the Two knock-on effects worth deciding on explicitly:
The second |
||
| Thread.currentThread().interrupt(); | ||
| throw e; | ||
| } | ||
| throw new SandboxException.ExecTimeoutException(shellCommand, timeoutSeconds); | ||
| } | ||
| return new ShellCapture(exit, stdout, stderr); | ||
|
|
@@ -179,7 +197,7 @@ private int drainStartStream( | |
| break; | ||
| } | ||
| int len = ByteBuffer.wrap(lenB).order(ByteOrder.BIG_ENDIAN).getInt() & 0x7FFFFFFF; | ||
| if (len < 0 || len > 64 * 1024 * 1024) { | ||
| if (len > 64 * 1024 * 1024) { | ||
| throw new IOException("Invalid connect frame length: " + len); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Info] Dropping If you want the length guard to be meaningful, validate the unsigned value before masking: int raw = ByteBuffer.wrap(lenB).order(ByteOrder.BIG_ENDIAN).getInt();
long len = Integer.toUnsignedLong(raw);
if (len > 64L * 1024 * 1024) {
throw new IOException("Invalid connect frame length: " + len);
}Pre-existing behaviour, not introduced here — mentioning it only because this hunk touches the validation. |
||
| } | ||
| byte[] data = in.readNBytes(len); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Warning] Fail-fast on
timeoutSeconds <= 0changes the effective contract, and it diverges from the sibling sandbox implementations.AbstractBaseSandbox.exec()only substitutes a default when the argument isnull(int timeout = timeoutSeconds != null ? timeoutSeconds : getDefaultExecTimeoutSeconds()), so an explicit0used to mean "nocallTimeout" and now aborts before the request is issued.DaytonaHttp.execute()(Math.max(1, timeoutSeconds)) andAgentRunMcpChannel.exec()(Math.max(1, ...)) clamp instead of throwing, so the same input now behaves differently per backend.It also escapes the tool-layer error mapping:
SandboxBackedFilesystem.execute()catchesSandboxException.ExecTimeoutException/ExecExceptiononly, so anIllegalArgumentExceptionsurfaces as an unmapped failure rather than anExecuteResponsewith a non-zero exit code.Either clamp to keep the backends consistent, or throw a
SandboxExceptionso the harness renders it as a tool error:If throwing is deliberate, it is worth a line in the PR description plus a javadoc note on
runShell/runShellBinaryStdout, since both are called with hardcoded constants today.