feat(orch): live-upgrade envd inside a running sandbox at resume - #3333
Conversation
PR SummaryHigh Risk Overview Reviewed by Cursor Bugbot for commit a537fc8. Bugbot is set up for automated code reviews on this repo. Configure here. |
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
This comment has been minimized.
This comment has been minimized.
1e16ee2 to
ac7daef
Compare
ac7daef to
426efb4
Compare
426efb4 to
c2a93df
Compare
db05515 to
75e66d0
Compare
75e66d0 to
1fa7e12
Compare
1fa7e12 to
1fc12d7
Compare
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
1fc12d7 to
43c51ac
Compare
43c51ac to
16adb2a
Compare
16adb2a to
0db8e4b
Compare
179ffec to
2fa1b67
Compare
2fa1b67 to
a4b2e87
Compare
a4b2e87 to
301fb99
Compare
412edfd to
6757a99
Compare
6757a99 to
e29fe6f
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e29fe6ffc7
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { | ||
| return false, nil |
There was a problem hiding this comment.
Detach post-upgrade initialization from request cancellation
When the resume request is canceled after envd has read the binary but before the client observes the connection reset, the server-side handler can still complete execve, while this branch reports the exec as unconfirmed. The subsequent WaitForEnvd uses the already-canceled context, so maybeUpgradeEnvd treats the failure as recoverable and promotes the sandbox; however, the replacement envd never receives /init, remains behind the pre-init authorization gate, and the fallback timer only thaws the workload rather than restoring authentication. Finish the post-trigger readiness check with a detached, bounded context or otherwise ensure this ambiguous cancellation cannot publish an uninitialized sandbox.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed. After the trigger, the readiness /init (WaitForEnvd) ran on the cancellable resume context, so an ambiguous cancellation in that window — where the execve may already have happened — would skip /init yet still return recoverably, leaving a promoted-but-uninitialized sandbox (the exec'd envd never gets its auth/env restored).
Fixed by running the readiness /init on a detached but still-bounded context (context.WithoutCancel(upCtx), keeping readyTimeout): by this point the exec is a fait accompli, so /init is always driven to completion, and the version confirmation below still distinguishes an actual exec from an untouched old envd. Folded into the orch-trigger commit (now b910b68ad).
| watchers := filesystemService.ExportWatchers() | ||
| mounts := service.ExportMounts() | ||
| forwards := portForwarder.ExportForwards() |
There was a problem hiding this comment.
Quiesce port-forward creation through exec
After this snapshot returns, ExportForwards releases the forwarder mutex before Upgrade acquires syscall.ForkLock, so envd's still-running scanner can start and register a new socat in between. That socat survives the same-PID exec but is absent from the handover; the incoming scanner then attempts a duplicate bind, and startPortForwarding leaves the failed map entry in place, preventing later scans from repairing the forward. Hold a handover gate across forwarding export through exec so no socat can be created after the snapshot.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Right — the freeze quiesces the workload but not envd's own port scanner, so between ExportForwards releasing the forwarder mutex and the execve the scan loop could spawn a socat that is then orphaned by the swap (never carried, never re-adopted) or duplicates a port the new envd re-adopts, contending on the same bind address.
Fixed by holding the forwarder mutex from the snapshot through the execve: ExportForwardsHold returns the snapshot plus a release func, and doUpgrade holds it across Upgrade (released only on the failure path; a successful execve drops the held lock with the old process). Folded into the mounts/forwards commit (now 7aa45b03f).
| pending := make([]*rpc.FilesystemEvent, len(fw.Events)) | ||
| copy(pending, fw.Events) |
There was a problem hiding this comment.
Serialize watcher event drains with the handover
If GetWatcherEvents runs after this copy but before envd executes the replacement image, it can return these pending events to the caller and clear the old watcher's queue. The handover still imports the copied slice, so the same events are returned again after upgrade; freezing workload cgroups prevents new filesystem mutations but does not stop concurrent envd RPC handlers. Gate event draining from snapshot through exec, not only watcher membership changes, to preserve consume-once behavior.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct — ExportWatchers snapshots each watcher's pending events under watchersMu, but GetWatcherEvents drained them under the per-watcher lock only, so a concurrent drain in the export→execve window could consume events the snapshot already carried, and the re-armed watcher would re-deliver them after the swap (double-delivery).
Fixed symmetrically to the forwards: ExportWatchersHold holds watchersMu across the execve, and GetWatcherEvents now takes watchersMu around the drain, so no drain can interleave with the snapshot. This is defense-in-depth for the resume-scoped trigger (no client is connected during resume, so GetWatcherEvents cannot fire in that window), but it closes the race for any future non-resume trigger. ExportWatchersHold folded into the handover-mechanism commit (now e4a21cea5); the doUpgrade wiring into 7aa45b03f.
e29fe6f to
7aa45b0
Compare
MinEnvdVersionForUpgrade (0.6.11, the first envd with /upgrade + handover) and the multivariate string flag envd-upgrade-target (off | promoted | <git-sha>; fallback env-overridable via ENVD_UPGRADE_TARGET). ResolveEnvdUpgrade mirrors ResolveFirecrackerVersion and returns (path, version), "" when the target equals built-with (idempotent re-resume); getVersion is injected so this shared package stays free of an orchestrator dependency. Signed-off-by: Nikita Kalyazin <nikita.kalyazin@e2b.dev> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce cgroups.WorkloadFreezer, a small wrapper over the cgroup Manager that freezes/thaws the user+pty workload cgroups and exposes a Thawed() signal and a FreezeHold() that keeps the freeze lock held. It is the single freeze primitive that the following commits route every freeze/thaw caller through — the HTTP /freeze, /unfreeze and /init deferred thaw, and the live-upgrade handover — so they can no longer race each other. Added here as a standalone, dependency-free preparatory step; adoption follows. Signed-off-by: Nikita Kalyazin <nikita.kalyazin@e2b.dev>
7aa45b0 to
a768144
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a768144. Configure here.
Upgrade the envd binary inside a running sandbox by re-exec'ing into the new binary with the same PID and re-adopting the workload, so arbitrary customer processes and their stdio/PTY survive the swap. - POST /upgrade (authenticated): the new binary is streamed in the body, written, and swapped via same-PID syscall.Exec under ForkLock with the workload frozen (via the shared WorkloadFreezer); on success envd never responds (it has exec'd). - Handover blob (tmpfs) is a protobuf HandoverState (spec/upgrade/handover.proto) with an explicit schema + abort-on-newer reader rule: it carries per-process pid/tag/cgroup/config + stdio/PTY fds (CLOEXEC cleared), the terminal-event retention cache, and filesystem watchers, all as native nested messages. The incoming envd re-adopts each process (reaped via pidfd + pid-specific wait4), re-arms watchers/timeouts, restores the retention cache, and thaws the workload. - The workload stays frozen until the post-upgrade /init restores the access token (WorkloadFreezer.FreezeHold), and the pre-init auth gate keeps control endpoints closed in that window. - Retention cache is keyed by pid but also cleared by tag on Start, so a tagged restart within the TTL can't serve a predecessor's stale exit code. - Failure handling: a failed upgrade never leaves the sandbox worse off — on an execve failure the outgoing envd thaws and keeps running the old binary; the incoming resume path always thaws (success/error/panic) and is recover-wrapped. Bump envd to 0.6.12 (the first version that writes the protobuf handover) and MinEnvdVersionForUpgrade with it. Signed-off-by: Nikita Kalyazin <nikita.kalyazin@e2b.dev>
…ound-trip Unit tests for the live-upgrade handover: the protobuf HandoverState round-trip (native config/exit/watcher nesting) and the schema-gate rejecting a newer schema; terminal-event retention across a pid reuse and a tagged restart (the predecessor's exit must not be served to a later Connect-by-tag); the incoming resume re-arming watchers before the thaw; and the always-thaw guarantee on a bad blob / no blob. Signed-off-by: Nikita Kalyazin <nikita.kalyazin@e2b.dev>
…trics
At resume, maybeUpgradeEnvd resolves a target via envd-upgrade-target, gates
on MinEnvdVersionForUpgrade, and (best-effort, recover-wrapped, bounded)
delivers the binary over the authenticated /upgrade body via
Sandbox.CallEnvdUpgrade, then WaitForEnvd. The target binary is read from the
node-local read-only /fc-envd gcsfuse mount.
Metrics: orchestrator.envd.upgrade.attempts{result,from_version,to_version},
.duration{result}, .gated{reason}; an envd.upgraded label on
orchestrator.sandbox.create.duration; and an envd-upgrade child span. The
common per-resume no-op (flag off / same version) is not counted.
Signed-off-by: Nikita Kalyazin <nikita.kalyazin@e2b.dev>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Table-drive resolveEnvdUpgradePath (off/unset/promoted/sha, newer vs same-version, missing target) without a LaunchDarkly client, and assert the three rollout metrics have description+unit map entries and construct. Signed-off-by: Nikita Kalyazin <nikita.kalyazin@e2b.dev> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ARCHITECTURE.md described resume without the envd live-upgrade step and listed no /upgrade endpoint or handover responsibility. Document envd's POST /upgrade + same-PID handover in the Envd section, and the resume-time upgrade (best-effort, failing the resume only on an unrecoverable post-exec failure) in the Pause/resume flow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Nikita Kalyazin <nikita.kalyazin@e2b.dev>
Two more pieces of re-creatable state were dropped on a live-upgrade because they live only in envd's heap while the kernel objects survive the execve: - The NFS mount ledger (path -> lifecycle) on the API service. Lost, the new envd's post-upgrade /init saw an empty ledger, decided every volume needed (re)mounting, and force-unmounted + remounted a still-live mount — ESTALE for the workload and a possible failed resume. - The port forwarder's socat set. Lost, the new forwarder rescanned and spawned a duplicate socat per already-forwarded port, while the originals were orphaned and leaked as un-reaped zombies. Carry both in the protobuf HandoverState (new MountEntry and ForwardedPort messages): - API.ExportMounts/ImportMounts round-trips the ledger so /init recognizes a matching-lifecycle mount and leaves it in place. - Forwarder.ExportForwards/ImportForwards carries each socat's pid; the new forwarder re-adopts the live ones (skipping any that didn't survive), seeding the ports map so the next scan doesn't spawn a duplicate, and reaps each re-adopted socat via wait4 (there is no surviving *exec.Cmd Wait goroutine). A mutex now guards the ports map since the upgrade goroutine exports it concurrently with the scan loop. These are returned from ResumeFromHandover (rather than applied via a callback like watchers) because their owners — the API service and the forwarder — are constructed after the resume runs. Tests: proto round-trip of mounts + forwards; the mount ledger skipping a remount for an unchanged lifecycle; and the forwarder re-adopting a live socat, skipping a dead one, and exporting only real socats. Signed-off-by: Nikita Kalyazin <nikita.kalyazin@e2b.dev>
a768144 to
a537fc8
Compare
# feat(orch): live-upgrade envd inside a running sandbox at resume
## Why
envd is the in-VM daemon every sandbox depends on, but its version is
baked into
the snapshot at build time. A long-lived paused sandbox therefore
resumes on
whatever envd it was built with, potentially many releases behind —
there is no
way to ship an envd fix or behavioral change to the existing fleet of
snapshots
short of rebuilding every template. We want to roll a new envd to
running
sandboxes the same way we roll Firecracker versions: gated by a
LaunchDarkly
flag, ramped by cohort, and without disturbing the customer's workload.
The hard part is doing it without a visible interruption: the swap has
to keep
arbitrary customer processes — and their stdio/PTYs, timeouts,
filesystem
watchers, and recently-observed exit codes — alive across the binary
change.
## What
Two halves: an in-guest handover mechanism in envd, and an
orchestrator-side
resume-time trigger driven by a flag. They are independent — the envd
half is
inert until something calls `POST /upgrade`.
### In-guest handover (envd)
A new authenticated `POST /upgrade` endpoint performs a **same-PID
`syscall.Exec`** into a new envd binary and re-adopts the running
workload, so
customer processes never notice the daemon changed underneath them.
- **Delivery + trigger in one call.** The new binary is streamed in the
request
body, written inside the guest (default `/usr/bin/envd.next`), then
swapped.
This uses the token-authenticated `/upgrade` path rather than the
unauthenticated `/files` copy, which a live post-`/init` sandbox
rejects.
- **The swap.** envd freezes the workload cgroups, serializes the
process table
to a tmpfs handover blob, relocates the carried fds and `syscall.Exec`s
under
`ForkLock`. On success it never responds — the process image is
replaced. The
freeze lock is **held across the whole handover** (freeze → serialize →
execve,
via `FreezeHold`), not just the freeze sweep, so a concurrent `/init` or
`/unfreeze` thaw can't slip in and unfreeze the workload
mid-serialization; on
`execve` it evaporates with the process image, and on any failure it's
released
before the thaw. The port scanner is left running: holding `ForkLock`
across
the fd relocation already serializes it against the scanner's fd/socat
creation, so there's no CLOEXEC race to quiesce.
- **Guarded fd relocation.** The carried fds are `dup3`'d to fixed high
numbers
(so the fresh runtime's early startup can't have grabbed them) under
`ForkLock`, and each target is checked free first — a collision aborts
the
upgrade (closing what it already relocated) instead of `dup3` silently
clobbering a live fd, leaving the old envd running.
- **What the handover carries** (tmpfs `/run/e2b/envd-handover.json`, so
it
never touches the rootfs diff): per-process pid / tag / cgroup / config,
the
stdio + PTY fds (dup'd with CLOEXEC cleared so they survive execve), the
terminal-event **retention cache** (exit codes of processes that ended
just
before the swap), the filesystem **watchers**, and each process's
**remaining
timeout**.
- **The incoming side** re-adopts each process from its inherited fds,
resumes
streaming its output, and re-arms its timeout — recording a fresh
deadline so a
*further* chained upgrade re-carries the remaining timeout. It restores
the
retention cache (skipping any PID already re-adopted as live, so a
reused PID
resolves to the live process, not a stale exit) and re-arms the
filesystem
watchers **before** thawing the workload, so the workload stays frozen
until
the watches are live and no event is dropped in the gap. Re-adopted
processes
are reaped via **pidfd + a pid-specific `wait4`** — never `wait4(-1)`,
which
would steal `os/exec`'s post-upgrade children — and the reaper retains a
terminal event **synchronously, before** closing its event channel, so a
late
reconnect always finds the exit in the cache rather than racing the
retain.
- **Failure handling — the sandbox is never left worse off.** On an
execve
failure the outgoing envd closes the fds it relocated (CLOEXEC-cleared —
else
they'd leak into the still-running old envd and its future children),
thaws the
workload it froze, and keeps running the old binary. The incoming resume
path always thaws via a top-level `defer`
(success, error, or panic) and is recover-wrapped, so a malformed blob
can't
crash envd into a systemd restart that orphans the workload. Degraded at
worst, never hung or dead.
- **One shared freeze lock.** The freeze/unfreeze sweep and its
serializing lock
are factored into a single `cgroups.WorkloadFreezer` shared by the HTTP
API
(`/freeze`, `/unfreeze`, the `/init` deferred thaw) and the upgrade, so
the
upgrade's freeze can no longer interleave with the resume thaw and
strand the
workload frozen.
envd is bumped to **0.6.11**, the first upgrade-aware version.
### Resume-time trigger + gate (orchestrator + shared)
On resume (`Create` with `snapshot=true`, and the checkpoint-resume
path),
`maybeUpgradeEnvd` decides against the **live running envd version**,
delivers,
and confirms — best-effort, recover-wrapped, and bounded so it can never
disrupt
resume.
- **Live version, not built-with.** envd advertises its running version
via an
`X-Envd-Version` response header on `/init`; the orchestrator captures
it off
the resume-path `/init` it already makes (no extra round-trip) and keys
the
decision, gate, `from_version`, and success check on it. The template
built-with never changes across a live upgrade, so keying on it would
re-trigger the handover on every resume.
- **Flag-driven target resolution.** `ResolveEnvdUpgrade` (shared, the
resume
analog of `ResolveFirecrackerVersion`) reads the `envd-upgrade-target`
flag and
returns the target binary's local path + version, or `""` when the
target is
not strictly newer than the live version — so an already-upgraded
sandbox
(live == target) is a true no-op, skipped with no delivery or handover,
and a
downgrade is refused. Target binaries live on the node-local read-only
`/fc-envd` gcsfuse mount.
- **Version gate.** The *running* envd must already speak `/upgrade`, so
the
trigger skips a live version below `MinEnvdVersionForUpgrade` (0.6.11)
and
counts it (`gated{reason=old_envd}`).
- **Delivery + confirmation.** `Sandbox.CallEnvdUpgrade` streams the
binary over
the authenticated `/upgrade` body to a fixed guest path; envd reading
the body
then exec'ing without replying (transport drop) is the expected path.
After
`WaitForEnvd` (which re-reads the version off the new envd's `/init`),
success
is confirmed by the running version actually equalling the target — a
transport
quirk can't mislabel the outcome. Only unambiguous never-reached-envd
errors
(connection refused / dial) are treated as delivery failures.
Flags / knobs:
| Flag | Values | Default | Meaning |
|------|--------|---------|---------|
| `envd-upgrade-target` (LD string) | `off` \| `promoted` \| `<git-sha>`
| `off` | `off`: no upgrade. `promoted`: track node-local
`HOST_ENVD_PATH`, upgrade when strictly newer than the live version.
`<sha>`: pin `/fc-envd/envd.<sha>`. |
| `ENVD_UPGRADE_TARGET` (env fallback) | same | `off` | Overrides the
flag fallback where there is no LD (dev), mirroring
`DEFAULT_FIRECRACKER_VERSION`. |
| `MinEnvdVersionForUpgrade` (const) | — | `0.6.11` | Below this the
running envd lacks `/upgrade`; trigger skips. |
### Rollout metrics
envd has no metrics pipeline, so its side reports via Loki summary logs
(`handover_resumed{procs,retained}`, `watchers_rearmed{...}`). The
orchestrator
side adds:
- `orchestrator.envd.upgrade.attempts{result,from_version,to_version}` —
`result ∈ {success, delivery_failed, not_ready, version_mismatch,
panic}`;
`from_version` is the **live** running version. `success` is recorded
only when
the running version actually flips to `to_version` (not inferred from
the
transport outcome). `success/total` is the rollout success rate; the
common
per-resume no-op (flag off / already on target) resolves to `""` and is
deliberately **not** counted.
- `orchestrator.envd.upgrade.duration{result}` — wall-time of delivery +
trigger
+ `WaitForEnvd`, i.e. the overhead added to the resume.
- `orchestrator.envd.upgrade.gated{reason=old_envd}` — targeted resumes
skipped
by the version gate (watch during a ramp).
- an `envd.upgraded` bool label on
`orchestrator.sandbox.create.duration`, and an
`envd-upgrade` Tempo child span.
The post-upgrade readiness re-check re-runs `/init` (to re-read the
running
version) but is guarded to record the per-start resume KPIs — the
envd-init
duration histogram + call counter and `StartedAt` — **once per start**,
so a
successful upgrade doesn't double-count the init or push `StartedAt`
(and thus
`execution_time`) to a later, wrong timestamp.
```promql
# rollout success rate
sum(rate(orchestrator_envd_upgrade_attempts_total{result="success"}[5m]))
/ sum(rate(orchestrator_envd_upgrade_attempts_total[5m]))
```
## Validation
- **Unit tests (root-free, in CI):** a `Connect` after a gap-exit
returns the
retained Start+End by pid/tag (unknown → NotFound); a live PID is never
served a
stale cached exit and a reused PID keeps its successor; a stale
retention timer
can't evict a newer entry; a re-adopted process whose `pidfd_open` fails
still
emits a terminal event (no orphan); a watcher exported then imported
keeps its
id, delivers post-handover events, and preserves pending events;
`ResumeFromHandover` always thaws on a malformed blob and on no blob;
`Upgrade`
refuses a target other than the fixed path; the resolver is table-driven
over
off/unset/promoted/sha × newer/same/older/missing (downgrade refused)
without a
LaunchDarkly client; `/init` advertises `X-Envd-Version`; the
delivery-error
classifier flags only never-reached-envd; the three metrics have
description+unit entries and construct.
- **Dev cluster, end-to-end (exact branch binaries; re-validated
2026-07-23 on
the final code):** this branch's envd was rebuilt at 0.6.11 (baked into
the
template) and 0.6.12 (staged as the `promoted` target), and this
branch's
orchestrator (`0.2.0-c21705aeb`) deployed to the dev client nodes.
Validated
against a live workload — a stdout-writing + tmpfs-appending background
counter,
so a broken fd-carry would SIGPIPE-kill it and line growth proves it
kept
running:
- **upgrade fires + workload survives:** flag → target ⇒ resume
auto-upgrades
0.6.11→0.6.12; the running envd becomes `/usr/bin/envd.next
--resume-handover`
and the counter process **keeps the same PID and keeps writing across
the
swap** (7→20 lines) — exercising stdio fd inheritance and pidfd reaping
end
to end. The orchestrator confirms by ground truth:
`envd_upgrade_attempts_total{result="success",from_version="0.6.11",to_version="0.6.12"}
= 1`.
- **flag off ⇒ no upgrade:** plain resume, workload preserved on 0.6.11,
no
`self-upgrade` in the journal.
- **gap-exit retention:** a process that exits during the handover gap
has
its exit code (42) recovered by a late `Connect` via the carried
retention
cache.
The rollout metrics are emitted on the same path
(`attempts{result,from,to}`, `duration`, `gated`,
`create.duration{envd_upgraded}`),
observed on dev Prometheus with a per-upgrade `duration` of ≈ 345–440
ms.
- **Note:** the 2026-07-23 re-run above exercised the **final** binaries
(all the
review-driven changes: live-version decision, version-confirmed success,
the
shared freeze lock held across the handover, the freeze/serialize/exec
path, and
the once-per-start resume-KPI guard on the post-upgrade readiness
re-check). The
remaining review changes that only manifest on failure/edge paths —
reaper
retaining before closing its channel, watcher re-arm before thaw,
chained-upgrade
deadline carry, exec-fail fd cleanup, and the PID-reuse / retain-timer /
reaper-orphan / duplicate-forwarder fixes — are covered by the unit
tests above;
they don't change the success path the e2e validated.
## Key decisions
- **Same-PID `syscall.Exec`, not restart.** A fresh process would be
reparented
by systemd (`Type=simple`), orphaning the workload; keeping the PID
means envd
stays the workload's parent and the inherited fds/PTYs stay valid.
- **pidfd + pid-specific `wait4`, never `wait4(-1)`.** A blanket reaper
would
steal children that `os/exec` spawns after the upgrade; targeted reaping
keeps
both the re-adopted and the new processes correct.
- **Trigger at resume, gated + best-effort.** Resume is the natural
quiescent
window and gives free cohort ramping (the resume-site LD context already
carries envd-version/team/template). The whole path is recover-wrapped,
version-gated, and bounded, so a failed or slow upgrade degrades to
`upgraded=false` and the sandbox resumes on the old envd. *Caveat:* the
trigger
is currently synchronous on the resume-critical path, so on
pathologically slow
resumes (large cold-fault working sets) it can add up to its timeout
budget to
the tail; moving it off the critical path is a candidate follow-up.
- **Decide and confirm on the live version, read for free off `/init`.**
The
orchestrator can't cheaply know the running envd version, but envd can
report
it — so envd advertises `pkg.Version` via an `X-Envd-Version` header on
`/init`
(which the resume path already calls) and the orchestrator keys the
decision,
`from_version`, idempotency, and the success check on it. Re-resumes of
an
upgraded sandbox become true no-ops and success is
ground-truth-accurate, with
no new endpoint and no extra round-trip. Version comparison still relies
on
`packages/envd/pkg/version.go` being bumped per behavioral change (repo
rule);
the resolver refuses any target that isn't strictly newer.
- **Fixed exec target.** envd writes/execs the delivered binary only at
a fixed
path and refuses any other, so an authenticated-but-malformed `/upgrade`
can't
turn the same-PID exec into arbitrary code execution.
- **`getVersion` injected into the shared resolver** so
`packages/shared` gains
no dependency on the orchestrator's envd build package.
- **Handover blob on tmpfs**, additive-fields-only JSON, so it never
enters the
rootfs diff and an outgoing/incoming envd built at different versions
stay
compatible.
- **Rollout is off by default** (`envd-upgrade-target=off`, env fallback
`off`);
dev has no LaunchDarkly so the feature is inert there unless
`ENVD_UPGRADE_TARGET` is set.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Nikita Kalyazin <nikita.kalyazin@e2b.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

feat(orch): live-upgrade envd inside a running sandbox at resume
Why
envd is the in-VM daemon every sandbox depends on, but its version is baked into
the snapshot at build time. A long-lived paused sandbox therefore resumes on
whatever envd it was built with, potentially many releases behind — there is no
way to ship an envd fix or behavioral change to the existing fleet of snapshots
short of rebuilding every template. We want to roll a new envd to running
sandboxes the same way we roll Firecracker versions: gated by a LaunchDarkly
flag, ramped by cohort, and without disturbing the customer's workload.
The hard part is doing it without a visible interruption: the swap has to keep
arbitrary customer processes — and their stdio/PTYs, timeouts, filesystem
watchers, and recently-observed exit codes — alive across the binary change.
What
Two halves: an in-guest handover mechanism in envd, and an orchestrator-side
resume-time trigger driven by a flag. They are independent — the envd half is
inert until something calls
POST /upgrade.In-guest handover (envd)
A new authenticated
POST /upgradeendpoint performs a same-PIDsyscall.Execinto a new envd binary and re-adopts the running workload, socustomer processes never notice the daemon changed underneath them.
body, written inside the guest (default
/usr/bin/envd.next), then swapped.This uses the token-authenticated
/upgradepath rather than theunauthenticated
/filescopy, which a live post-/initsandbox rejects.to a tmpfs handover blob, relocates the carried fds and
syscall.Execs underForkLock. On success it never responds — the process image is replaced. Thefreeze lock is held across the whole handover (freeze → serialize → execve,
via
FreezeHold), not just the freeze sweep, so a concurrent/initor/unfreezethaw can't slip in and unfreeze the workload mid-serialization; onexecveit evaporates with the process image, and on any failure it's releasedbefore the thaw. The port scanner is left running: holding
ForkLockacrossthe fd relocation already serializes it against the scanner's fd/socat
creation, so there's no CLOEXEC race to quiesce.
dup3'd to fixed high numbers(so the fresh runtime's early startup can't have grabbed them) under
ForkLock, and each target is checked free first — a collision aborts theupgrade (closing what it already relocated) instead of
dup3silentlyclobbering a live fd, leaving the old envd running.
/run/e2b/envd-handover.json, so itnever touches the rootfs diff): per-process pid / tag / cgroup / config, the
stdio + PTY fds (dup'd with CLOEXEC cleared so they survive execve), the
terminal-event retention cache (exit codes of processes that ended just
before the swap), the filesystem watchers, and each process's remaining
timeout.
streaming its output, and re-arms its timeout — recording a fresh deadline so a
further chained upgrade re-carries the remaining timeout. It restores the
retention cache (skipping any PID already re-adopted as live, so a reused PID
resolves to the live process, not a stale exit) and re-arms the filesystem
watchers before thawing the workload, so the workload stays frozen until
the watches are live and no event is dropped in the gap. Re-adopted processes
are reaped via pidfd + a pid-specific
wait4— neverwait4(-1), whichwould steal
os/exec's post-upgrade children — and the reaper retains aterminal event synchronously, before closing its event channel, so a late
reconnect always finds the exit in the cache rather than racing the retain.
failure the outgoing envd closes the fds it relocated (CLOEXEC-cleared — else
they'd leak into the still-running old envd and its future children), thaws the
workload it froze, and keeps running the old binary. The incoming resume path always thaws via a top-level
defer(success, error, or panic) and is recover-wrapped, so a malformed blob can't
crash envd into a systemd restart that orphans the workload. Degraded at
worst, never hung or dead.
are factored into a single
cgroups.WorkloadFreezershared by the HTTP API(
/freeze,/unfreeze, the/initdeferred thaw) and the upgrade, so theupgrade's freeze can no longer interleave with the resume thaw and strand the
workload frozen.
envd is bumped to 0.6.11, the first upgrade-aware version.
Resume-time trigger + gate (orchestrator + shared)
On resume (
Createwithsnapshot=true, and the checkpoint-resume path),maybeUpgradeEnvddecides against the live running envd version, delivers,and confirms — best-effort, recover-wrapped, and bounded so it can never disrupt
resume.
X-Envd-Versionresponse header on/init; the orchestrator captures it offthe resume-path
/initit already makes (no extra round-trip) and keys thedecision, gate,
from_version, and success check on it. The templatebuilt-with never changes across a live upgrade, so keying on it would
re-trigger the handover on every resume.
ResolveEnvdUpgrade(shared, the resumeanalog of
ResolveFirecrackerVersion) reads theenvd-upgrade-targetflag andreturns the target binary's local path + version, or
""when the target isnot strictly newer than the live version — so an already-upgraded sandbox
(live == target) is a true no-op, skipped with no delivery or handover, and a
downgrade is refused. Target binaries live on the node-local read-only
/fc-envdgcsfuse mount./upgrade, so thetrigger skips a live version below
MinEnvdVersionForUpgrade(0.6.11) andcounts it (
gated{reason=old_envd}).Sandbox.CallEnvdUpgradestreams the binary overthe authenticated
/upgradebody to a fixed guest path; envd reading the bodythen exec'ing without replying (transport drop) is the expected path. After
WaitForEnvd(which re-reads the version off the new envd's/init), successis confirmed by the running version actually equalling the target — a transport
quirk can't mislabel the outcome. Only unambiguous never-reached-envd errors
(connection refused / dial) are treated as delivery failures.
Flags / knobs:
envd-upgrade-target(LD string)off|promoted|<git-sha>offoff: no upgrade.promoted: track node-localHOST_ENVD_PATH, upgrade when strictly newer than the live version.<sha>: pin/fc-envd/envd.<sha>.ENVD_UPGRADE_TARGET(env fallback)offDEFAULT_FIRECRACKER_VERSION.MinEnvdVersionForUpgrade(const)0.6.11/upgrade; trigger skips.Rollout metrics
envd has no metrics pipeline, so its side reports via Loki summary logs
(
handover_resumed{procs,retained},watchers_rearmed{...}). The orchestratorside adds:
orchestrator.envd.upgrade.attempts{result,from_version,to_version}—result ∈ {success, delivery_failed, not_ready, version_mismatch, panic};from_versionis the live running version.successis recorded only whenthe running version actually flips to
to_version(not inferred from thetransport outcome).
success/totalis the rollout success rate; the commonper-resume no-op (flag off / already on target) resolves to
""and isdeliberately not counted.
orchestrator.envd.upgrade.duration{result}— wall-time of delivery + triggerWaitForEnvd, i.e. the overhead added to the resume.orchestrator.envd.upgrade.gated{reason=old_envd}— targeted resumes skippedby the version gate (watch during a ramp).
envd.upgradedbool label onorchestrator.sandbox.create.duration, and anenvd-upgradeTempo child span.The post-upgrade readiness re-check re-runs
/init(to re-read the runningversion) but is guarded to record the per-start resume KPIs — the envd-init
duration histogram + call counter and
StartedAt— once per start, so asuccessful upgrade doesn't double-count the init or push
StartedAt(and thusexecution_time) to a later, wrong timestamp.Validation
Connectafter a gap-exit returns theretained Start+End by pid/tag (unknown → NotFound); a live PID is never served a
stale cached exit and a reused PID keeps its successor; a stale retention timer
can't evict a newer entry; a re-adopted process whose
pidfd_openfails stillemits a terminal event (no orphan); a watcher exported then imported keeps its
id, delivers post-handover events, and preserves pending events;
ResumeFromHandoveralways thaws on a malformed blob and on no blob;Upgraderefuses a target other than the fixed path; the resolver is table-driven over
off/unset/promoted/sha × newer/same/older/missing (downgrade refused) without a
LaunchDarkly client;
/initadvertisesX-Envd-Version; the delivery-errorclassifier flags only never-reached-envd; the three metrics have
description+unit entries and construct.
the final code): this branch's envd was rebuilt at 0.6.11 (baked into the
template) and 0.6.12 (staged as the
promotedtarget), and this branch'sorchestrator (
0.2.0-c21705aeb) deployed to the dev client nodes. Validatedagainst a live workload — a stdout-writing + tmpfs-appending background counter,
so a broken fd-carry would SIGPIPE-kill it and line growth proves it kept
running:
0.6.11→0.6.12; the running envd becomes
/usr/bin/envd.next --resume-handoverand the counter process keeps the same PID and keeps writing across the
swap (7→20 lines) — exercising stdio fd inheritance and pidfd reaping end
to end. The orchestrator confirms by ground truth:
envd_upgrade_attempts_total{result="success",from_version="0.6.11",to_version="0.6.12"} = 1.self-upgradein the journal.its exit code (42) recovered by a late
Connectvia the carried retentioncache.
The rollout metrics are emitted on the same path
(
attempts{result,from,to},duration,gated,create.duration{envd_upgraded}),observed on dev Prometheus with a per-upgrade
durationof ≈ 345–440 ms.review-driven changes: live-version decision, version-confirmed success, the
shared freeze lock held across the handover, the freeze/serialize/exec path, and
the once-per-start resume-KPI guard on the post-upgrade readiness re-check). The
remaining review changes that only manifest on failure/edge paths — reaper
retaining before closing its channel, watcher re-arm before thaw, chained-upgrade
deadline carry, exec-fail fd cleanup, and the PID-reuse / retain-timer /
reaper-orphan / duplicate-forwarder fixes — are covered by the unit tests above;
they don't change the success path the e2e validated.
Key decisions
syscall.Exec, not restart. A fresh process would be reparentedby systemd (
Type=simple), orphaning the workload; keeping the PID means envdstays the workload's parent and the inherited fds/PTYs stay valid.
wait4, neverwait4(-1). A blanket reaper wouldsteal children that
os/execspawns after the upgrade; targeted reaping keepsboth the re-adopted and the new processes correct.
window and gives free cohort ramping (the resume-site LD context already
carries envd-version/team/template). The whole path is recover-wrapped,
version-gated, and bounded, so a failed or slow upgrade degrades to
upgraded=falseand the sandbox resumes on the old envd. Caveat: the triggeris currently synchronous on the resume-critical path, so on pathologically slow
resumes (large cold-fault working sets) it can add up to its timeout budget to
the tail; moving it off the critical path is a candidate follow-up.
/init. Theorchestrator can't cheaply know the running envd version, but envd can report
it — so envd advertises
pkg.Versionvia anX-Envd-Versionheader on/init(which the resume path already calls) and the orchestrator keys the decision,
from_version, idempotency, and the success check on it. Re-resumes of anupgraded sandbox become true no-ops and success is ground-truth-accurate, with
no new endpoint and no extra round-trip. Version comparison still relies on
packages/envd/pkg/version.gobeing bumped per behavioral change (repo rule);the resolver refuses any target that isn't strictly newer.
path and refuses any other, so an authenticated-but-malformed
/upgradecan'tturn the same-PID exec into arbitrary code execution.
getVersioninjected into the shared resolver sopackages/sharedgainsno dependency on the orchestrator's envd build package.
rootfs diff and an outgoing/incoming envd built at different versions stay
compatible.
envd-upgrade-target=off, env fallbackoff);dev has no LaunchDarkly so the feature is inert there unless
ENVD_UPGRADE_TARGETis set.🤖 Generated with Claude Code