Fork safety, question by question
A warm function answers each request with a fork() of a process that has
already done its imports. That is where Zygo’s speed comes from, and it is
the part people ask about first. This page answers those questions one at a
time, and says plainly where the answer is “not fully”. Each answer links to
the chapter with the detail.
zygote: interpreter + your imports, never runs a request
│
├── fork ──▶ child 1: GO ─▶ child filter ─▶ reseed ─▶ own TMPDIR ─▶ handler ─▶ _exit
├── fork ──▶ child 2: GO ─▶ …
└── fork ──▶ child 3: GO ─▶ …
▲
└── each child starts as a copy of the zygote, not of child 1 or 2
Is a forked child really clean?
Its memory is. A child starts as a copy of the zygote, and the zygote has never run a request. Whatever child 1 changes — a global, a monkeypatch, a cache — is in child 1’s own pages and is gone when it exits. Child 2 is a new copy of the zygote, not of child 1. A test holds this line for every agent (rule 4 of the protocol).
Its files need more than a fork, because every request in one sandbox
sees the same filesystem. Each request gets its own temporary folder, named
by TMPDIR and removed when it ends. But a literal /tmp/... path is one
folder for the whole sandbox, and so is any writable mount you add. Write
temporary files through tempfile or os.tmpdir(), never to a fixed path.
Warm functions lists what a
request sees.
What does a child share with the zygote?
Everything the zygote had when it was forked: the loaded modules, anything built at import time, and open descriptors. The pages are shared copy-on-write: they are only copied when one side writes. That is the point. Your import-time work is paid once and every request starts with it. It is also the rule to remember: what you build at import time, every request sees. Build clients, compiled templates and models there. Do not put per-user data there.
Does every child have the same memory layout (ASLR)?
Yes. ASLR places a program’s memory at random addresses, so an attacker
cannot know where things are. A fork copies the layout rather than drawing a
new one. So every child of one zygote has the same addresses, and an address
leaked by one request holds for the next. The layout changes only when the
zygote starts again, such as on a zygo up that replaces it or a serve
after a stop. This is a real cost of the fork model, and it is
why the ns backend is described as a wall for semi-trusted code. The Node
agent is different: each of its workers is a new process with its own
layout.
Can a secret end up in the zygote?
No. A secret is never in the environment, never in the EXEC message and
never in the zygote. The supervisor writes it as a file, /run/secrets/NAME
(mode 0400), from outside the sandbox, after the child exists and before it
is told to start. The file goes when the function’s last request in flight
finishes. Requests of the same function that run at the same moment can read
it too: they have the same value, the same uid and the same folder. A request
of another function cannot, because that function has its own sandbox. In a
runtime pool, which many tenants share, a request that receives secrets has
its zygote to itself while the files exist, so no other tenant’s child is
forked beside them; the values are the calling tenant’s and are written the
same way, never through the zygote.
Chapter 14 has
the details.
What about threads and locks?
A fork copies only the thread that called it. If another thread held a lock
at that moment, the lock stays locked in every child. At warm-up the Python
agent checks for threads that would survive a fork, Python threads and native
ones. If it finds any, it stops forking and starts a fresh interpreter for
each request instead: much slower, but correct, and zygo logs says it did.
Start threads inside the handler, or lazily on first use.
Chapter 13.
Do children draw the same random numbers?
Not from the usual places. The agent reseeds Python’s random, numpy’s
global generator and torch’s generator in every child, before your code runs.
os.urandom, secrets and uuid.uuid4() ask the kernel on every call, so a
fork does not repeat them. What the agent cannot reseed is a generator
you created at import time, such as RNG = random.Random(). Every request
draws the same numbers from it. The agent names such a generator in
zygo logs at warm-up. Create it inside the handler.
What about a connection opened at import time?
Every child inherits the same socket. Two requests running at once would then
write into one connection, and the replies would mix. The agent does not
detect this. Build the client at import time, which is where the cost is,
and let it connect on first use in the child. Most clients, boto3 and
requests.Session among them, connect lazily.
Can a request talk to the supervisor?
No. The zygote reaches the supervisor over one socket, and a forked child starts with a copy of it. The agent closes that copy in the child before any of your code runs, so a request cannot send messages as the agent. Without this, a request could answer for another request, which in a runtime pool may be another tenant’s. The Node agent’s workers never get the socket at all. The rule is in the protocol, and the agent’s tests try to break it.
Do atexit handlers run?
No. A child leaves with os._exit: no atexit handlers, no interpreter
shutdown, and no flushing of buffers that the zygote also owns. Anything your
handler must save, it saves before it returns.
Does the seccomp filter survive the fork?
Yes, and it cannot be removed. The kernel copies a process’s filters into its
children, and nothing can take one away. The zygote runs under the sandbox’s
filter, so every child does too. Under strict, each child also installs a
second filter after GO and before any request code: no execve, no new
process. Filters stack, and the strictest answer wins. That second filter is
installed by the agent, so it is only as good as the agent.
Chapter 24.
Can one tenant’s data reach another through shared pages?
Not through a function’s zygote. An agent function’s zygote holds that function’s code and nothing else, and the multi-tenant pattern is one zygote per script version (ADR 0005). A runtime pool is shared by tenants, so its zygote holds no tenant code at all. The script arrives with the request and loads in the child. Zygo sends it as a path to a read-only mount, which never passes through the zygote. The fallback, the script’s text in the message, does pass through the zygote’s memory, where the next tenant’s child inherits a copy. Zygo logs when it has to use that fallback. What a child writes stays in its own pages.
Why not snapshot and restore a microVM instead?
It is the same idea one level down: restore a whole VM from a snapshot
instead of copying a process. It gives each call a hardware boundary, which
ns does not. It also costs more: the projects that do it report around
10–20 ms per restore, against Zygo’s 1.4 ms fork. It has the same problems
too: every restore of one snapshot starts with the same memory, random state
included. Zygo’s vm backend runs one-shot sandboxes, and warm functions stay
on ns (ADR 0002 says why, and what would
change that). Similar projects
compares the two.
Is the Node agent any different?
Yes. Node cannot be forked safely, so its agent keeps a few workers ready. Each worker is a new Node process that loads the handler itself, serves one request and exits, and a replacement starts off the request path. Nothing is inherited copy-on-write. So the thread, random-number and layout questions above do not arise. The cost is a little higher than a fork. The handler’s import-time code runs when the worker starts, before the request it will serve has its own cgroup. Chapter 13.