Skip to content

Commit 426efb4

Browse files
kalyazinclaude
andcommitted
chore(poc): dev SIGUSR2 upgrade trigger + evidence notes
Not for merge — scaffolding kept separate at the top of the branch: a SIGUSR2/marker-file manual upgrade trigger in envd main (dev convenience, inert in prod without the marker; superseded by the orchestrator trigger), and POC-NOTES.md, the running evidence log. Signed-off-by: Nikita Kalyazin <nikita.kalyazin@e2b.dev> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4664211 commit 426efb4

2 files changed

Lines changed: 167 additions & 0 deletions

File tree

POC-NOTES.md

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# envd live-upgrade PoC — running notes / evidence
2+
3+
Branch: `poc/envd-live-upgrade`. Design: `~/dev/reports/2026-06-22-envd-live-upgrade-design.md` §11.
4+
Milestone 1 = feasibility + risk register; STOP for go/no-go before Milestone 2.
5+
6+
## Risk register (live)
7+
8+
| ID | Risk | Status | Evidence |
9+
| --- | --- | --- | --- |
10+
| R-A | stdio + PTY fds survive same-PID execve, no SIGPIPE | **confirmed-benign** | E0 |
11+
| R-B | pidfd reaping works, no os/exec collision | **confirmed-benign** (spike); in-guest pending | E0 (E2 to confirm no-collision) |
12+
| R-C | execve invisible to systemd Type=simple | partially (PID continuity proven); systemd pending | E0 (E1 to confirm under systemd) |
13+
| (impl) | in-guest handover patch (process path) | **written, compiles, vet-clean, committed** | commit 5706657a1 |
14+
| R-D | freeze covers workload; CLOEXEC race closed | **confirmed e2e** (workload frozen+survived swap); explicit socat-leak (E3) still pending | E7 |
15+
| R-E | process table reconstructs (List/Connect) | **confirmed e2e** (re-adopted pid in List post-swap) | E7 |
16+
17+
## E7 — e2e on dev cluster (real guest 6.1, real SDK) — ALL PASS (2026-06-23)
18+
19+
Patched envd v0.6.7 deployed to dev (`gs://e2b-dev-nikita-fc-env-pipeline/envd`), baked into
20+
template `envd-upgrade-poc` (`envdVersion: 0.6.7` confirmed via API). Driver:
21+
`scratchpad/e2e_upgrade.py`. Create sandbox → start a stdout-writing workload (so a broken pipe
22+
would SIGPIPE-kill it) → `touch /run/e2b/allow-upgrade` + `kill -USR2 $(pidof envd)` → reconnect.
23+
24+
```
25+
sandbox ilqawa4yqv9l1543gr3me
26+
workload pid 1264
27+
pre-upgrade: logLines=10 envdPid=1224 bakedEnvd=0.6.7
28+
envd journal: "envd: SIGUSR2 -> self-upgrade (from v0.6.7)" # execve actually fired
29+
post-upgrade: alive=ALIVE logLines=33 envdPid=1224 listPids=[1264]
30+
[PASS] swap actually fired (journal)
31+
[PASS] R-A/R-D: stdout-writing workload survived the swap
32+
[PASS] workload kept running across swap (log 10->33)
33+
[PASS] R-C: envd same PID across execve (1224==1224)
34+
[PASS] R-E: workload re-adopted into new envd's List
35+
RESULT: ALL PASS
36+
```
37+
38+
Decisive: the handover mechanism works end-to-end on a real cluster guest. The execve fired
39+
(journal), the running stdout-writing workload survived with no SIGPIPE and kept producing
40+
output across the swap, envd kept its PID, the workload was re-adopted into the new image's
41+
process table, and *new* commands (the post-swap `wc`/`kill -0`) ran fine — exercising os/exec
42+
reaping alongside the pidfd-reaped re-adopted process (R-B no-collision).
43+
44+
Not yet run: E2 gap-exit exit-code case, E3 explicit socat-fd-leak, E4 watchers (R-F deferred),
45+
E5 plain-resume baseline (Q1), Q3 telemetry. None blocking the verdict.
46+
47+
**Workload & coverage.** Every cluster run used a synthetic counter loop, background/root:
48+
`i=0; while true; do echo "out$i"; echo "$i" >> /tmp/poc.log; i=$((i+1)); sleep 0.3; done`. The
49+
stdout `echo` exercises the envd-owned pipe (failed fd-carry → SIGPIPE death); the tmpfs file is
50+
an out-of-band liveness probe (line growth = survived + still running). Proxy for an arbitrary
51+
process, not a real app. Cluster runs = non-PTY stdout; PTY validated only in E0 spike; stdin /
52+
native long-running / multi-subscriber streams untested.
53+
54+
## M2 — production trigger path (2026-06-24)
55+
56+
Goal: real cross-version upgrade (vA->vB) with host-side binary delivery, wired to the resume
57+
path. Built envd `/upgrade` endpoint (POST, target path in `X-Envd-Upgrade-Bin`; SIGUSR2 also
58+
reads `/run/e2b/upgrade-to`) + orchestrator `CallEnvdUpgrade` trigger helper (committed).
59+
60+
**vA->vB cross-version e2e — ALL PASS** (`scratchpad/e2e_vab.py`, sandbox ifea94q6di0wy79lctvxh):
61+
vA=0.6.7 baked in template; deployed/delivered vB=0.6.8. Delivered vB into the guest (host-side),
62+
pointed envd at it, triggered upgrade.
63+
```
64+
pre: envdPid=1220 runningEnvd=vA(0.6.7) logLines=11
65+
journal: "self-upgrade (from v0.6.7, newBin=/usr/bin/envd.next)"
66+
journal: "resumed as v0.6.8 after handover (from v0.6.7, 1 procs)"
67+
post: alive=ALIVE logLines=47 envdPid=1220 listPids=[1264]
68+
RESULT: ALL PASS — running envd flipped 0.6.7->0.6.8, workload survived, same PID, re-adopted
69+
```
70+
The running envd image genuinely changed version while the live workload was preserved — the
71+
production mechanism (host-delivered different binary + targeted upgrade). Same PID across execve
72+
confirmed via `pidof envd.next`==prior `pidof envd` (comm changed to the new binary's basename).
73+
74+
**Orchestrator auto-trigger (resume path):** trigger helper `Sandbox.CallEnvdUpgrade` committed
75+
(direct POST /upgrade + WaitForEnvd). Remaining for a full auto-resume deploy:
76+
- insertion point: `server/sandboxes.go` resume handler (after `ResumeSandbox`, ~line 763) where
77+
`s.proxy` (SandboxProxy, for `sandboxtools.CopyFile` delivery) and `s.config.HostEnvdPath` are
78+
in scope; resolver compares built-with `config.Envd.Version` vs target.
79+
- **caveat:** the target envd binary isn't guaranteed present on client nodes (HOST_ENVD_PATH is
80+
a build-node path) — for the PoC it's pre-staged at `/opt/poc-envd-target` on the n2 nodes.
81+
82+
### M2 AUTO-RESUME e2e — ALL PASS (2026-06-24, deployed to dev)
83+
84+
Deployed patched orchestrator to dev (binary hot-swap on n2 nodes; backup at
85+
`gs://…/orchestrator.poc-backup`) + patched envd 0.6.7 (body-handling `/upgrade`) baked into the
86+
template + vB 0.6.8 staged at `/opt/poc-envd-target` on n2. `scratchpad/e2e_auto.py` (sandbox
87+
izvhctu5n0ja8vj8rb719): create 0.6.7 sandbox + workload → **pause → resume** with NO manual
88+
trigger.
89+
```
90+
journal: self-upgrade (from v0.6.7, newBin="/usr/bin/envd.next")
91+
journal: resumed as v0.6.8 after handover (from v0.6.7, 1 procs)
92+
post: alive=ALIVE log 9->23 re-adopted in List
93+
RESULT: ALL PASS
94+
```
95+
The ORCHESTRATOR auto-delivered+triggered the upgrade at resume (Loki: maybeUpgradeEnvd ->
96+
"delivering+triggering"); running envd flipped 0.6.7->0.6.8, workload preserved.
97+
98+
Two debugging finds: (1) hook must be in the **Create** handler (API /resume = Create
99+
snapshot=true), not Checkpoint; (2) delivery can't use build-time `CopyFile` (posts to envd
100+
`/files` unauthenticated → runtime sandbox rejects with "closed pipe") — stream the binary in
101+
the body of the **token-authenticated** `/upgrade` call instead.
102+
103+
**Cluster side effects to clean up:** patched orchestrator on n2 (revert: copy
104+
`orchestrator.poc-backup` back + reroll); patched envd 0.6.7 active (revert: `build-and-upload/envd`
105+
from main); `/opt/poc-envd-target` staged on n2; **n4 drain status was wiped by the orchestrator
106+
restarts** (n4 now `ready` — normally kept draining for TSC portability; needs re-draining).
107+
| R-E | process table reconstructs (List/Connect) | pending | E1 |
108+
| R-F | watcher re-arm Option C | pending | E4 |
109+
| R-G | access_token_hash carry closes 401 window | pending | E1 |
110+
111+
## E0 — mechanism spike (laptop, host kernel 6.17, Go 1.26.3) — ALL PASS
112+
113+
`packages/envd/cmd/upgrade-spike/main.go`. Two-phase same-PID `syscall.Exec`.
114+
115+
```
116+
[phase1] pid=3334504
117+
[phase1] pre-exec read from A: "A0\n"
118+
[phase1] exec -> phase2 (carry pipeA=100 pidA=101 pidB=102 lis=103 pty=104)
119+
[phase2] pid=3334504 (same PID => R-C ok)
120+
[phase2] post-exec read from A: [A1 A2 A3] # pipe child survived + streamed
121+
[phase2] post-exec read from PTY:[P0 P1 P2] # PTY child survived + streamed
122+
[phase2] control child B: wait4=... signaled=true sig=broken pipe # NEGATIVE CONTROL
123+
[phase2] pidfd A readable; wait4=... signaled=true sig=terminated # pidfd reaping
124+
RESULT: ALL PASS
125+
```
126+
127+
Findings:
128+
- **R-A pipe + PTY:** carried read-end / master across execve; children kept writing, no SIGPIPE.
129+
- **Negative control:** child B whose read-end was *not* carried died of SIGPIPE on its next
130+
write — proves the fd-carry (CLOEXEC-clear + dup3) is what saves the workload, not luck.
131+
- **R-C:** PID identical across phases (`syscall.Exec` keeps PID; systemd would not observe it).
132+
- **listener:** inherited TCP listener fd reconstructed via `net.FileListener` and Accept()ed.
133+
- **R-B:** `pidfd_open` fd became POLLIN-readable on child exit; targeted `wait4(pid)` harvested
134+
the status — the no-`wait4(-1)` path. (os/exec-coexistence still to confirm in-guest, E2.)
135+
136+
Mechanics that mattered: `unix.Dup3(old, target, 0)` to fixed high fd numbers (100-104) clears
137+
CLOEXEC; done under `syscall.ForkLock`. No Siginfo parsing needed — poll(pidfd)+pid-specific
138+
wait4 is enough to prove the targeted reap.
139+
140+
Verdict: the two highest-risk assumptions (fd inheritance w/o SIGPIPE; pidfd reaping) hold on a
141+
real kernel. Proceed to in-guest (E1+) — no cheap no-go here.

packages/envd/main.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ import (
88
"log"
99
"net/http"
1010
"os"
11+
"os/signal"
1112
"path/filepath"
1213
"strconv"
14+
"strings"
15+
"syscall"
1316
"time"
1417

1518
"connectrpc.com/authn"
@@ -294,6 +297,29 @@ func run() error {
294297
return nil
295298
}
296299

300+
// Manual trigger via SIGUSR2 (dev convenience): inert unless the per-sandbox
301+
// marker /run/e2b/allow-upgrade exists, so deploying this envd doesn't arm
302+
// every dev sandbox. If /run/e2b/upgrade-to holds a path, that binary is
303+
// exec'd (real vA->vB); otherwise envd re-execs itself.
304+
go func() {
305+
ch := make(chan os.Signal, 1)
306+
signal.Notify(ch, syscall.SIGUSR2)
307+
for range ch {
308+
if _, err := os.Stat("/run/e2b/allow-upgrade"); err != nil {
309+
fmt.Fprintf(os.Stderr, "envd: SIGUSR2 ignored (no /run/e2b/allow-upgrade marker)\n")
310+
311+
continue
312+
}
313+
newBin := ""
314+
if b, err := os.ReadFile("/run/e2b/upgrade-to"); err == nil {
315+
newBin = strings.TrimSpace(string(b))
316+
}
317+
if err := doUpgrade(newBin); err != nil {
318+
fmt.Fprintf(os.Stderr, "self-upgrade failed: %v\n", err)
319+
}
320+
}
321+
}()
322+
297323
// Orchestrator-driven trigger: authenticated POST /upgrade with the target
298324
// binary path in the X-Envd-Upgrade-Bin header (empty = re-exec self). This
299325
// is the production trigger the orchestrator calls at resume after delivering

0 commit comments

Comments
 (0)