envd: a stale process-stream subscriber can wedge stdout fan-out for all subscribers for ~15 minutes after sandbox resume
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 1.6k
- Forks
- 438
- PR merge metrics
- No merged PRs in 30d
Description
Summary
After resuming a paused sandbox, a background process's stdout stream can go completely silent for ~15.5 minutes and then suddenly recover on its own. During the silence, new process.Connect subscribers receive nothing (the start event arrives, but no data events), while unary RPCs (SendInput, List) to the same sandbox work fine.
We traced this to the interaction of three things in packages/envd:
MultiplexedChannel.run()fans out to subscribers via unbuffered channel sends (internal/services/process/handler/multiplex.go). A subscriber that is registered but not reading blocks the fan-out loop for every subscriber. Thedone-guard only helps for cancelled subscribers.- A subscriber's consumer goroutine is only cleaned up when
stream.Sendreturns an error (internal/services/process/connect.go,start.go). There is no write deadline on the response stream, so a send to a dead peer blocks until the guest kernel's TCP gives up retransmitting — with the defaultnet.ipv4.tcp_retries2 = 15, that is ~924 seconds ≈ 15.4 min, which matches our observed silence duration exactly. - Pausing snapshots the whole VM, including the TCP state of streams whose peer-side close has not yet been processed by envd. Even when the client calls
disconnect()beforepause()(we do), the FIN may not reach envd before the snapshot is taken. The snapshot then preserves a "ghost" subscriber + consumer goroutine that believes its connection is alive.
After resume, the ghost's stream.Send blocks on a connection whose remote side no longer exists; the fan-out loop blocks on the ghost's unbuffered channel; every legitimate subscriber (including ones attached after resume via process.Connect) starves. When the guest TCP stack finally gives up (~924s), the write errors out, the ghost is removed (defer dataCancel()), and buffered events suddenly flow to the healthy subscribers.
Observed impact (production)
We run agent workloads that pause on human-in-the-loop waits and resume on reply (Python SDK, betaPause/connect). Incident pattern, observed on 3 independent sandboxes in one day:
- resume completes;
commands.connect(pid, ...)re-attaches and receives the start event - 0 or 1 data events arrive (consistent with whether the ghost was frozen inside
Sendor waiting on<-data; the first event after resume can be absorbed by the ghost's socket send buffer) - then total silence on stdout for ~16 min, while
SendInput/Liston the same sandbox keep working (separate unary connections) - stdout suddenly resumes and the workload completes normally
Reproduction
Honest note: we could not reproduce this on demand (4 attempts: with/without client disconnect() before pause, immediate and delayed resume, large payloads to fill socket buffers). In all attempts the peer close apparently reached envd before the snapshot, so no ghost was created. We suspect triggering depends on timing and possibly on whether resume placement changes the network path, which the client cannot control. The production evidence (three incidents with the exact tcp_retries2 timeout signature, and the 0-vs-1-event split matching the two possible frozen states of the consumer goroutine) is what points at this mechanism.
Suggested fixes
Either (or both) of:
- Set a write deadline on stream sends in the process service consumers, e.g. via
http.ResponseController.SetWriteDeadline, keyed to the keepalive interval the client already sends (Keepalive-Ping-Intervalheader). A dead peer then errors out in seconds instead of ~15 minutes, and the existing cleanup path (cancel+defer dataCancel()) takes over. - Make the fan-out robust to a slow/stuck subscriber in
MultiplexedChannel.run(): e.g. per-subscriber buffered channel with "cancel the subscriber on overflow" semantics, so one stuck consumer can never starve the others. (Dropping data silently would be wrong for stdout; disconnecting the stuck subscriber is consistent with what already happens whenSenderrors.)
Workaround we deployed
Setting net.ipv4.tcp_retries2 = 8 (RFC 1122's lower bound, ~100s) inside the guest right after sandbox creation shortens the wedge from ~15 min to ~1-2 min. Since sysctl state is part of the VM memory snapshot, it survives pause/resume. This is a mitigation, not a fix — the fan-out still wedges, just for less time.
Environment
- e2b Python SDK 2.x (
AsyncSandbox,betaPause/connect) - Custom templates based on
python:3.14-slim/node:24-slim/debian:13-slim - E2B cloud (us), Firecracker sandboxes with pause/resume
Happy to provide timestamps/sandbox IDs privately if useful.
This analysis was drafted with an AI coding agent; the code reading, production log correlation, and the deployed workaround were verified by our team.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reading packages/envd/internal/services/process/handler/multiplex.go and the consumer lifecycle in packages/envd/internal/services/process/connect.go and start.go. Trace how a subscriber that stops reading affects run() and stream.Send, then determine which suggested cleanup or fan-out behavior fits the process service. Done means a dead or stuck subscriber cannot starve healthy stdout subscribers after resume, and its resources are cleaned up promptly.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- backend, networking
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100