ADR 0004 — A supervisor upgrade re-warms; there is no --reexec
A record of the decision as it was taken; the numbers in it are as of its date. Today’s numbers are in chapter 25.
Status: accepted, 2026-09-23. Records the time-boxed investigation that closed Phase 4 of the roadmap.
Context
Upgrading Zygo replaces the supervisor process, and its warm sandboxes go with
it. The question was whether the new binary could exec over the old one and
keep them — passing the zygotes’ descriptors and pids across — so that an
upgrade cost nothing.
exec is the right primitive to ask about: it keeps the pid, so the sandbox
init processes stay children of the same process and their
PR_SET_PDEATHSIG still points at it. Nothing dies merely because the binary
changed.
What would have to cross the boundary, though, is not the pids. It is:
- Every descriptor, deliberately. Each warm function holds an agent
socket; each sandbox holds seven namespace descriptors, a
/run/secretsdirectory descriptor and a cgroup directory descriptor. All of them areCLOEXEC, on purpose and in one case after a bug (B-03: thirteen descriptors renumbered withdup2, which clears the flag, were inherited by every tenant program). A hand-over means enumerating exactly which to un-flag, which inverts the property the launcher leans on — “everything closes unless something says otherwise” becomes “everything closes unless this list says otherwise”, and the list is per function, per sandbox, per release. - The state that is not in a descriptor. The resolved spec of every function and pool, each one’s counters, logs, secrets and script leases, the gate’s in-flight and queued counts, the idle tiering’s clocks. Serialisable, but a second representation of the supervisor’s whole state, versioned across releases, and wrong in a way nothing would notice until a limit was not applied.
- The requests in flight, and their callers. This is the part that does
not reduce. A request’s answer is owed to a control connection — another
descriptor — and the mapping from request id to that connection lives in a
reply-router thread that
execdestroys. Keeping them means handing over the client sockets too, plus each request’s cgroup, child pid and deadline, and rebuilding the router around replies that may already be sitting in a socket buffer. Anything short of that drops requests, which is the one thing the feature exists to avoid.
Against that, what a restart actually costs:
- A pool re-warms at about 500 ms per zygote (
python:3.12-slim, measured intests/linux/api_driver.py: “a runtime pool is warm (1 zygote, 502 ms)”; a function, 508 ms). That was before the bytecode layer; the same warm-up is now 154–185 ms on a Raspberry Pi 5, the supervisor’s start included, and 34 ms on the Lima VM with one running (chapter 25). A Node zygote announcesREADYin 15–43 ms. min_warmbounds the window: the replacement warms before it serves.POST /drainmeans the old process stops admitting, finishes what it is running and exits — so no request is dropped by the restart itself. The Kubernetes example rolls withmaxUnavailable: 0, which means the new pod is ready before the old one is asked to leave.
So the cost of a restart is not dropped requests. It is a fraction of a second of warm-up per zygote, on a schedule the operator chooses, with a drain in front of it.
Decision
No zygo api --reexec. A supervisor upgrade is a restart: drain, exit, start,
re-warm. min_warm and maxUnavailable: 0 are what make it invisible to a
caller, and both already exist.
Consequences
- An upgrade costs
min_warm × warm-upof cold pool per replica, once, while the replacement comes up behind a drain. - A single-replica deployment has a window where the pool is cold and requests are slow rather than failed. Two replicas remove it; the example uses two.
- The descriptors stay
CLOEXECwith no exceptions, which is the property every “did this leak into the tenant’s program?” argument rests on. - Zygo has one representation of its own state — the running one. There is no serialised form to keep in step with it.
Reopening it
- A warm-up measured in tens of seconds, not half of one. A pool whose dependency set takes thirty seconds to import changes the arithmetic, and the fix might still be to make that faster rather than to keep the process.
- A deployment that cannot have two replicas — a single machine with a
single pool and a latency budget that a cold start breaks. That is a real
shape, and
--reexecis one answer to it; another is a second supervisor on the same host and a load balancer, which needs nothing new. - Note what does not reopen it: wanting upgrades to be free. They are already free of dropped requests, which is the part that matters.