|
| 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. |
0 commit comments