1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
// SPDX-License-Identifier: Apache-2.0
//! Getting into a cgroup Zygo can actually build under.
//!
//! On a systemd machine an ssh login sits in a `session-N.scope`. systemd owns
//! that directory, it is not delegated, and an unprivileged process cannot
//! create a cgroup inside it — so a sandbox started from there has nowhere to
//! put its limits and refuses to run. The controllers *are* delegated to the
//! user manager; it is the session's own scope that is a dead end.
//!
//! The fix a user can apply by hand is to start Zygo inside a transient scope
//! of its own:
//!
//! ```text
//! systemd-run --user --scope -p Delegate=yes -- zygo run …
//! ```
//!
//! Making people type that for every command is not a fix, it is a footnote.
//! So Zygo does it itself: when the cgroup it finds cannot take a child, and
//! `systemd-run` is available, it re-executes itself inside one. `podman` has
//! done the same thing for the same reason for years.
//!
//! Only the commands that build a sandbox are moved. `zygo ps` does not need
//! a cgroup and should not pay a process spawn for one.
//!
//! `zygo bench` is in the list for a reason worth stating: every mode of it
//! warms a sandbox of its own, so leaving it out made the one command that
//! demonstrates the warm path the first one to fail on an ordinary systemd
//! login — with a cgroup error, from a benchmark.
//!
//! # What this costs, and why it is still here
//!
//! It is the largest single cost on the one-shot path. On an idle Ubuntu 24.04
//! VM, kernel 6.8, `zygo run python:3.12-slim python3 -c pass` with the image
//! already in the store, decomposed:
//!
//! | | p50 |
//! |---|---|
//! | `/bin/true` | 0.30 ms |
//! | `systemd-run --user --scope … -- true` | 5.14 ms |
//! | `zygo --version` | 3.23 ms |
//! | `systemd-run --user --scope … -- zygo --version` | 13.72 ms |
//! | `systemd-run --user --scope … -- zygo run …` | 41.21 ms |
//! | **`zygo run …`** (this file makes the scope) | **50.31 ms** |
//!
//! Two things are worth reading off that table. Creating the scope costs about
//! 5 ms; but running the *same* binary inside a brand-new scope costs 13.7 ms
//! against 3.2 ms outside one, so a fresh cgroup is about 10 ms of overhead
//! before Zygo has done anything. And the cgroup work itself is not where it
//! goes: timed step by step inside a scope, every `mkdir` and every
//! `subtree_control` write is under 0.15 ms, while *migrating a process into a
//! freshly created cgroup* is 5.6 ms on its own.
//!
//! ## The obvious fix, and why it is impossible
//!
//! Give `zygo.slice` a home that outlives the command. `user@$UID.service` is
//! the natural one: systemd starts it with `Delegate=`, so the directory, its
//! `cgroup.procs` and its `cgroup.subtree_control` all belong to the user, it
//! holds no processes directly, and its controllers are already enabled. This
//! was built and tried, and the layout works — `zygo.slice/tenants/<name>`
//! gets `memory.max`, `pids.max` and `cpu.max`, and they are writable.
//!
//! What does not work is *getting there*. cgroup v2's delegation containment
//! rule says a process may be migrated only by a writer with write access to
//! the destination's `cgroup.procs` **and to the `cgroup.procs` of the common
//! ancestor of source and destination**. The source is
//! `user-$UID.slice/session-N.scope`, the destination is under
//! `user-$UID.slice/user@$UID.service`, so the common ancestor is
//! `user-$UID.slice` — `root:root 644` on both hosts this was checked on
//! (Ubuntu 24.04 aarch64 / 6.8, and a Raspberry Pi 5 on 6.5). The write
//! returns `EACCES`, and it is meant to: the rule exists so a delegatee cannot
//! move processes out of its own subtree.
//!
//! Asking systemd to do the move instead — `systemd-run --slice=zygo.slice` —
//! puts the scope inside a persistent slice but still pays for `systemd-run`
//! and still creates a fresh scope, which is where the 10 ms is. It buys
//! nothing.
//!
//! Caching the `zygo doctor` host probe was tried too, on the theory that
//! `zygo run` did it twice. An interleaved A/B of 90 runs each: 44.92 ms
//! against 45.33 ms — no difference. The cache was kept for the supervisor,
//! which probes once per function warmed, and not for this.
//!
//! ## What does work: a supervisor
//!
//! A cgroup that is already delegated and already built — which is exactly
//! what the **supervisor** holds. So when one is running, `zygo run` hands
//! the sandbox to it instead of building its own: the client sends the spec
//! and its flags, passes its three streams over `SCM_RIGHTS`, forwards its
//! terminal's signals, and waits (`cmd/run.rs`, `supervisor::run`). None of
//! this file happens, because [`ensure_delegated`] asks first whether a
//! supervisor will take the run.
//!
//! On the same VM, the same command, forty runs a round, two rounds:
//!
//! | | p50 | p90 |
//! |---|---|---|
//! | `zygo run …`, this file makes the scope | 45.9 / 43.0 ms | 50.1 / 49.5 ms |
//! | `zygo run …`, a supervisor takes it | 30.4 / 29.0 ms | 32.9 / 33.8 ms |
//!
//! Fifteen milliseconds, a third of the command, and every one of them was
//! the scope, its second `zygo`, and a cgroup tree built to be thrown away.
//! What is left is the client (~3 ms to start), the sandbox (~8 ms in the
//! supervisor) and `python3 -c pass` itself (~13 ms).
//!
//! An embedder never saw any of this: it calls a warm function, and its
//! supervisor paid for its cgroup once at start-up. It was `zygo run` at a
//! terminal that paid every time, and now only on a machine where nothing
//! else is running. `docs/book/25-performance.md` has the numbers.
use crate;
/// Set in the re-executed process, so a scope that is *still* not usable
/// fails with Zygo's own message instead of forking for ever.
const MARKER: &str = "ZYGO_IN_SCOPE";
/// Whether this command builds a sandbox, and so needs a cgroup of its own.
///
/// Only acted on where cgroups exist; kept everywhere so the list itself is
/// tested on every host rather than only on Linux.
/// Move into a delegated scope if this process is not in one already.
///
/// Returns normally when nothing needs doing — which is the common case: a
/// container, a systemd service, or a session someone has already delegated.
/// Otherwise it **replaces this process**, so anything after the call runs in
/// the new scope.
/// Whether this `zygo run` is going to be started by a supervisor rather than
/// here — the same test `cmd/run.rs` makes, asked earlier.
///
/// The isolation is read the way `run` will resolve it: the flag, else the
/// spec's default, else `ns`. A `vm` or `gvisor` run stays local and still
/// needs its own cgroup, and skipping the scope for one of those would trade
/// 34 ms for a run that cannot start.
/// This process's cgroup directory, from `/proc/self/cgroup`.