Repository navigation
fix(sandboxd, sandboxmatrix, e2b): eight E2B SDK 2.56.0 compatibility defects - #667
Merged
Merged
Conversation
xiaods
force-pushed
the
fix/e2b-sdk-compat-followup
branch
from
October 22, 2026 01:23
b9a1446 to
4fd9ef1
Compare
Closed
Contributor
xiaods
force-pushed
the
fix/e2b-sdk-compat-followup
branch
from
October 22, 2026 01:40
4fd9ef1 to
b30dc12
Compare
…fficial SDK
The official e2b SDK (2.56.0) failed 21 of 44 checks against the K8E
E2B-compatible surface. Root causes, all server-side:
sandboxd (the Pod's PID 1) — a panic killed the whole sandbox:
* files.zig: the errno of a failed syscall was decoded with
`@intCast(rc)` to isize. A failing syscall returns the NEGATED errno
as a usize, so the cast overflowed and, in ReleaseSafe, panicked.
Replaced with std.os.linux.errno() (no narrowing) via a new
syscall.zig helper. The same idiom existed in handleMove,
handleSignal, and every getdents64 call site, and in the open()
error checks (a failed open left fd as a huge usize, so `fd < 0`
never fired). This is what made files.make_dir() on a missing
parent kill the Pod -- and, because the dead Pod was never
reclaimed, permanently burn one of a 4C8G node's ~13 Pod slots
until the node refused every new sandbox ("warm pool full").
* exec.zig: stdout and stderr shared one pipe, so the stream carried
no channel information and the SDK's stderr was always empty.
They are now separate pipes, drained together with poll(), and
each frame is tagged with its channel and base64-encoded (which
also keeps the SSE frame boundary intact).
* files.zig: six hard-coded write lengths did not match their string
literals, leaking adjacent memory into the stream.
warm pool / capacity:
* controller.go: detectResetting() only promoted *Running* pods back
to warm, so a dead pod stayed in resetting forever. It now reclaims
dead and orphaned pods.
* controller.go: DestroySession now deletes a terminated pod instead of
recycling it (its workspace was never reset).
* orchestrator.go: CheckCapacity counted terminated pods, so a node
with 4.5GiB of dead pods looked full while using 34% of its memory.
It now ignores them, and it also checks CPU -- which is what binds
first on a small node.
* orchestrator.go: the pod IP cache could hand back the address of a
pod that was being replaced (a resume), so every later call went to
a pod that no longer existed.
E2B protocol surface:
* files.go: the read path returned the file as escaped JSON text,
which cannot carry non-UTF-8 bytes -- the SDK received an EMPTY
file for every binary payload. It now asks for base64.
* envd.go: commands.run(envs=...) was parsed and then dropped before
reaching the gateway, so the SDK's env had no effect.
* envd.go: Process/List reported sandboxd's pids while Start handed
out the e2b layer's, so the documented "take the pid from
commands.list() and pass it to commands.connect()" always failed.
* envd.go: Process/Connect never delivered an end event, so
CommandHandle.wait() raised "Command ended without an end event".
* envd.go: the streaming WatchDir is now implemented (it was an
honest 501 on the assumption that no SDK uses it -- true for
Python, but the JS SDK's files.watchDir() calls it).
* envd.go: RemoveWatcher answered 200 with no content type, which
the Connect client rejects.
* grpc: files.list walked the whole workspace (a populated .venv is
thousands of files) and then stat'ed every entry. It now lists the
requested subtree at the requested depth.
Verified against the official SDKs 2.56.0: Python 44/44 (sync + async)
and JavaScript 17/17, each run twice.
xiaods
force-pushed
the
fix/e2b-sdk-compat-followup
branch
from
October 22, 2026 01:57
b30dc12 to
787e03b
Compare
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Follow-up to #659. That PR fixed four E2B-SDK 2.56.0 compatibility defects; running the same guide (levels 1–5) against a live 4C8G cluster surfaced eight more, and they turned out to explain a class of failures the earlier PR could not have found — including one that made the cluster refuse every new sandbox.
Verified with the official SDKs pinned to 2.56.0: Python 44/44 (sync + async) and JavaScript 17/17, each run repeatedly against a live deployment.
The eight defects fall into three groups: a panic in the sandbox daemon that killed the whole sandbox, a leak in the warm pool that permanently burned the node's capacity, and protocol-shape bugs that made the SDK answer wrongly.
Part 1 — the sandbox daemon panicked, taking the sandbox with it
sandboxd is the Pod's PID 1. A panic there kills the container, and with
RestartPolicy: Neverthe Pod is Failed for good.1. A failed syscall's errno was decoded with an overflowing cast — sandboxd/src/files.zig
A failing Linux syscall returns the negated errno as a
usize(ENOENT is0xFFFF_FFFF_FFFF_FFFE). Recovering it must reinterpret the whole word, never narrow it:@intCastnarrows that value toisizeand overflows — and in ReleaseSafe an overflow is a panic. So every errno path in the daemon was a landmine:files.make_dir()on a directory whose parent does not exist hit it, which is an ordinary thing for an agent to do. The same idiom was inhandleMove,handleSignal, everygetdents64call site, and — worse — theopen()error checks, where a failed open leavesfdas a hugeusizesofd < 0never fires and the code went on to use a garbage descriptor.The new
sandboxd/src/syscall.zigdecodes the errno without narrowing (std.os.linux.errnobitcasts), anddirReadCountdoes the same for a directory read whose error must read as "end of directory".2. stdout and stderr shared one pipe — sandboxd/src/exec.zig
The child did
dup2(stdout_pipe[1], 1)anddup2(stdout_pipe[1], 2), so the stream carried no channel information at all. The e2b layer could only broadcast every frame as stdout, and the SDK'sstderrwas permanently empty while itson_stderrcallback never fired:The pipes are now separate, drained together with
poll()(reading one to EOF before the other deadlocks as soon as a child fills the unread pipe), and each frame names its channel. The payload is base64 for two reasons: it is JSON-safe, and it keeps the SSE frame boundary intact — a raw payload containing a blank line used to split the frame and leak thedata:prefix into the client's stdout.3. Six hard-coded write lengths did not match their string literals — sandboxd/src/{exec,execctl,pty}.zig
write(client_fd, "data: {\"done\":true}\n\n".ptr, 24)writes 24 bytes of a 21-byte literal, so three bytes of whatever follows it in the image were appended to every stream. The lengths are now derived from the literal.Part 2 — dead Pods were never reclaimed, and the node refused every new sandbox
4.
detectResetting()only promoted Running Pods — pkg/sandboxmatrix/controller.goA crashed sandboxd leaves a Failed Pod.
DestroySessionrelabels itresetting, anddetectResetting— which runs every 10 s — skipped anything that was notRunning:The only recycler for dead Pods (
recycleUnhealthyWarmPods) is scoped tostate=warm, so aresettingPod was nobody's business. It sat there forever.That is not untidy, it is a capacity leak:
CheckCapacitycounts every Pod carrying the state label against the node's memory budget, so each dead Pod permanently burned one of a 4C8G node's ~13 sandbox slots. Nine crashes filled the node, and it stayed full — 30 consecutive retries over 25 minutes, all refused:detectResettingnow reclaims dead Pods and orphaned Pending ones.5.
DestroySessionrecycled a terminated Pod — pkg/sandboxmatrix/grpc/orchestrator.goA Pod whose container exited can never serve a session again, and the workspace reset that
releasePodassumes could not have happened (the daemon was dead) — so handing it back to the pool handed a dirty workspace to the next session. It is now deleted, claim and all.6.
CheckCapacitycounted terminated Pods, and ignored CPU — pkg/sandboxmatrix/grpc/orchestrator.goTwo problems in one check:
Pendingforever ("0/1 nodes are available: 1 Insufficient cpu"), each holding a slot and starving every other sandbox.Terminated Pods are now excluded, and CPU requests are checked the way the scheduler counts them.
7. The Pod IP cache could hand back the address of a Pod being replaced — pkg/sandboxmatrix/grpc/orchestrator.go
A resume re-creates the Pod while the old one is still terminating, and both carry the session-id label. The IP lookup listed by label and returned the dying Pod's address, so every later call went to a Pod that no longer existed — the SDK saw "file not found" after a resume that had actually worked, and then "connection timed out" to a stale address. The session's own Pod is now preferred, and a terminating Pod is never used.
Part 3 — the E2B protocol surface
8. Six protocol-shape bugs — pkg/sandbox/e2b, pkg/sandboxmatrix/grpc
WriteFileRequest.Contentis a proto string, so fix(sandbox): four E2B SDK 2.56.0 compatibility fixes #659 fixed the write path by riding base64 — but the read path still returned the file as escaped JSON text, which cannot hold non-UTF-8 bytes. The SDK received an empty file for every binary payload. The read now asks sandboxd for base64.commands.run(envs=...)was parsed and then dropped before reaching the gateway, so the SDK's env had no effect on the command.ExecRequesthad noenvfield at all; it does now, and the per-call env is merged over the session's own env.Process/ListandProcess/Startdisagreed on the pid space. List reported sandboxd's pids while Start handed out the e2b layer's, so the documented flow — "take the pid fromcommands.list()and pass it tocommands.connect()" — always failed with "process not found". Both now use the same space.Process/Connectnever delivered an end event, soCommandHandle.wait()raised "Command ended without an end event" and the attach reported neither output nor an exit code.WatchDirwas an honest 501 on the assumption that "no SDK uses it". That is true of the Python SDK (which uses the polling trio) but not of the JS SDK: itsfiles.watchDir()calls the streaming RPC, so the JS SDK's watch failed with "501: WatchDir is not supported: K8E has no watch". It is now implemented on top of the polling trio, with the frame shape the JS client parses.RemoveWatcheranswered 200 with no content type, which the Connect client rejects ("invalid content-type: ''").One more, found while measuring the above:
files.listwalked the entire workspace (a populated.venvis thousands of files) and then stat'ed every entry, so listing one directory took seconds. sandboxd's list now takes apathand adepth, and the walk is bounded by both.Tests
Go:
TestDetectResetting_ReclaimsDeadPods— a Failed Pod is reclaimed instead of lingeringTestDetectResetting_ReclaimsOrphanedPending— a Pod stuck Pending past the grace period is reclaimed; a recent one is left aloneTestDetectResetting_PromotesRunning— the healthy path still promotes to warmTestCheckCapacity_IgnoresTerminatedPods— dead Pods do not consume the budgetTestCheckCapacity_RejectsWhenActuallyFull— the check still refuses a genuinely full nodeTestCheckCapacity_RejectsWhenCPUFull— CPU is now part of the checkTestCheckCapacity_AcceptsWhenCPUHasRoom— a node with CPU to spare is acceptedTestDestroySession_DeletesTerminatedPod— a terminated Pod is deleted, claim and allTestDestroySession_KeepsClaimForPendingPod— a Pending Pod keeps its claim (deleting it left the Pod referencing a claim that no longer existed)TestEnvdProcessConnectDeliversEndEvent— the attach stream carries an end eventTestEnvdFSWatchDirStreaming— the streaming WatchDir emits a start frame and afilesystemevent frame with the numeric typeTestParseChannelFrame,TestSSEFramerChannelFramesKeepsBoundaries— the channel-tagged frame formatencoding="base64"the way real sandboxd doessandboxd (Zig):
errnoFromSyscall: negated errno decodes without overflowerrnoFromSyscall: real mkdir failure decodes to the right errnodirReadCount: a failed read decodes as end-of-directory, not a panicurlDecode: percent-encoded path decodes to a filesystem pathgo test ./pkg/...— all pass.cd sandboxd && zig build test— 59/59 on Linux.Notes
not report an allocatable CPU (some test fixtures, some managed clusters) is
not CPU-constrained as far as the scheduler is concerned either, so the CPU
comparison is skipped rather than failing the request — a node with memory to
spare must not refuse a session. The first version of this check turned a
missing CPU into an error, which broke
sandbox_createon such a node; theMCP interop job caught it.
imagePullPolicychange is part of this PR on purpose: sandbox Pods defaulted toAlways, so every Pod re-pulled the image from the registry. That defeats an air-gapped or locally-patched deployment, and it made verifying a patched sandbox image impossible (the pull overwrote it). A digest-pinned image is immutable, so re-pulling it could only ever fetch the same bytes.hack/reclaim-sandbox-pods.shis a stopgap for operators who cannot upgrade yet: it reclaims the dead and orphaned Pods that update README to delight the focus #4 leaves behind. It is not a substitute for the fix — the panic still happens, and each crash still costs a slot until the sweep runs.read(path, 'bytes')returns the body as a string, so a payload that is not valid UTF-8 comes back lossy (invalid bytes decode to U+FFFD). That is the SDK's transport, not the server: the raw HTTP response carries the exact bytes. The Python SDK returns real bytes.