carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Package cgroup gives each exec-backed child its own cgroup under the
2// subtree systemd delegates to carlos-edge.service (Delegate= in the unit
3// file), so the metrics sampler can report per-instance figures for routes
4// that run as exec children of the agent — today those live inside the
5// agent's own cgroup and can never show readings. Design:
6// docs/superpowers/specs/2026-08-07-exec-child-accounting-design.md. Since
7// the memory-pressure spec (2026-08-28) the same per-host cgroup also
8// carries the instance's memory cap (Limit, the tiers).
9//
10// The agent is NOT root: everything here works through ownership of the
11// delegated subtree, never privilege. When delegation is absent (old unit
12// file, a test, darwin) Setup returns a nil *Manager and every method on it
13// degrades to today's behavior — fail toward absent readings, never toward
14// a broken spawn path. The generation layout (StartChildGated, armed only
15// under CARLOS_SWAP) is the exception once a manager exists: a generation
16// whose limits cannot be applied is refused (ErrLimitNotApplied), never run
17// uncapped.
18package cgroup
19
20import (
21 "context"
22 "errors"
23 "fmt"
24 "os"
25 "os/exec"
26 "path/filepath"
27 "strconv"
28 "strings"
29 "sync"
30 "sync/atomic"
31 "testing"
32)
33
34// execPrefix is the ONE definition of the per-host exec cgroup naming —
35// activator.ExecName and the sampler's directory match both come through
36// this package.
37const execPrefix = "carlos-exec@"
38
39// ExecName is the cgroup directory name — and therefore the metrics sample
40// key — for an exec-backed host's children. Deliberately NOT dressed up as
41// a fake ".service": an operator who takes a dashboard row to `systemctl
42// status` must not be told a unit exists that doesn't.
43func ExecName(host string) string { return execPrefix + host }
44
45// IsExecName reports whether a cgroup directory name is one of our per-host
46// exec cgroups: the carlos-exec@ prefix with a non-empty host after it.
47func IsExecName(name string) bool {
48 return strings.HasPrefix(name, execPrefix) && len(name) > len(execPrefix)
49}
50
51// The claim containment groups' host names: claim-<id>.local, where id is
52// the hex digest internal/host derives from the claim's fence target. This
53// is the ONE definition of the format (final review round 1, merge item 8):
54// internal/host builds names with ClaimContainmentName, and the retaining
55// setup (SetupRetaining), which runs flag off too, recognises them with
56// IsClaimContainment, so the two cannot drift apart.
57const (
58 claimContainmentPrefix = "claim-"
59 claimContainmentSuffix = ".local"
60)
61
62// ClaimContainmentName is the containment group host name for a claim whose
63// identity digest is id (hex).
64func ClaimContainmentName(id string) string {
65 return claimContainmentPrefix + id + claimContainmentSuffix
66}
67
68// IsClaimContainment reports whether name is a claim containment group's:
69// its host name (ClaimContainmentName) or its exec cgroup's directory name
70// (ExecName of one).
71func IsClaimContainment(name string) bool {
72 host := strings.TrimPrefix(name, execPrefix)
73 return strings.HasPrefix(host, claimContainmentPrefix) && strings.HasSuffix(host, claimContainmentSuffix) &&
74 len(host) > len(claimContainmentPrefix)+len(claimContainmentSuffix)
75}
76
77// Limit is what Start writes into a host's cgroup before the child lands
78// in it. MemoryMax is bytes; zero means "write nothing" — the pre-spec
79// behaviour for a caller with no tier. memory.high is derived (90% of
80// max) rather than a second field: a tenant that is throttled before it is
81// killed is the whole point, and there is no honest reason for a caller to
82// pick a different band.
83type Limit struct {
84 MemoryMax int64
85}
86
87// The tiers are PLATFORM-chosen (memory-pressure spec §2.3), mirroring
88// what caps/render.go already writes for unit-backed apps: the carlos-app@
89// template's 512M, and the 1G a forking family gets when its manifest
90// declares `children`. No number comes from a manifest.
91var (
92 TierDefault = Limit{MemoryMax: 512 << 20}
93 TierChildren = Limit{MemoryMax: 1 << 30}
94)
95
96// Shares is the CPU and IO half of a tier (#214 item 1). Memory alone let
97// one tenant take every core, or the disk's whole queue, from every other
98// tenant on the box. Like the memory caps these are PLATFORM-chosen: the
99// tier a route gets is decided by its manifest's one bit (children), and
100// its shares follow from the tier, so nothing new travels on the wire and
101// no number comes from a manifest.
102//
103// The weights are the kernel's default, written explicitly: a weight is
104// what makes the per-host slices take turns under contention, and setting
105// one is also what turns the controller on for the subtree. The quota is
106// the hard ceiling, in percent of one CPU. IOWeight is honoured only where
107// the disk has a weight-aware controller (io.cost or BFQ); the probe
108// reports whether this box has one.
109type Shares struct {
110 CPUWeight int
111 CPUQuotaPercent int
112 IOWeight int
113}
114
115// SharesOf is the tier's shares. A forking family gets two CPUs' worth of
116// quota, as it gets twice the memory; anything else, including a limit
117// that is not a tier, gets one: the smaller, and so the safe direction.
118func SharesOf(l Limit) Shares {
119 if l == TierChildren {
120 return Shares{CPUWeight: 100, CPUQuotaPercent: 200, IOWeight: 100}
121 }
122 return Shares{CPUWeight: 100, CPUQuotaPercent: 100, IOWeight: 100}
123}
124
125// HighOf is the memory.high for a memory.max: 90 percent, computed as the
126// cgroup manager always has, so a unit tier and an exec-child tier are the
127// same number.
128func HighOf(max int64) int64 { return max / 10 * 9 }
129
130// ExecOOMScoreAdj is written to every exec child's /proc/<pid>/oom_score_adj
131// right after it starts. The kernel's OOM killer — the memcg one that fires
132// when carlos-edge.service's own MemoryMax is hit included — scores per
133// PROCESS by RSS, and the agent (~110 MB) outweighs any single tenant
134// process on the flagship today, so without this the outer wall's victim
135// would be the agent, and Restart=always would then cycle every awake
136// instance: precisely what OOMPolicy=continue exists to prevent. +500 adds
137// half the box's RAM to a child's score; the agent stays at 0 and is never
138// the first pick. Raising one's own-uid process's score needs no privilege
139// (lowering does), which is why this is done here rather than by a unit
140// directive. Grandchildren inherit the value at fork; a family that forks
141// in the microseconds between exec and this write keeps 0 for those early
142// forks — the safe direction is still "a tenant process before the agent"
143// for everything forked after.
144const ExecOOMScoreAdj = "500"
145
146// oomScoreAdjPath is a seam so a test can point the write at a temp dir.
147var oomScoreAdjPath = func(pid int) string {
148 return filepath.Join("/proc", strconv.Itoa(pid), "oom_score_adj")
149}
150
151// ParseSelfCgroup extracts the cgroup v2 path from /proc/self/cgroup
152// content — the "0::" unified-hierarchy line — relative (no leading slash),
153// ready to join under /sys/fs/cgroup. Not-ok when there is no v2 line (a
154// v1-only box) or the process sits in the root cgroup: neither is a subtree
155// systemd could have delegated to us.
156func ParseSelfCgroup(data string) (string, bool) {
157 for _, line := range strings.Split(data, "\n") {
158 rest, found := strings.CutPrefix(line, "0::")
159 if !found {
160 continue
161 }
162 p := strings.TrimPrefix(rest, "/")
163 if p == "" {
164 return "", false
165 }
166 return p, true
167 }
168 return "", false
169}
170
171// Manager owns the delegated subtree. A nil *Manager is the documented
172// "no delegation" state: every method is nil-safe and degrades to today's
173// behavior.
174type Manager struct {
175 root string
176 logf func(string, ...any)
177 // locks holds each host's start lock (hostLock), made on first use.
178 locksMu sync.Mutex
179 locks map[string]*sync.Mutex
180 // placing is the in-flight registry (inflight.go): placements by
181 // cleaned target directory. placeWake is closed and dropped whenever a
182 // count drops or a hold ends, so waitPlacementsUnder and a registration
183 // waiting on a hold wake and recheck. windows are the readQuiet reads in
184 // progress; holds, the directories no placement may register under.
185 placeMu sync.Mutex
186 placing map[string]int
187 placeWake chan struct{}
188 windows map[*placeWindow]struct{}
189 holds map[string]int
190}
191
192// hostLock is host's start lock. A start holds it while it checks for, and
193// removes, a cgroup of the host that took cgroup.kill (freshen, amadan
194// #272), so two starts of one host never both remove one. It is the
195// innermost lock: nothing is taken while it is held, and it is never held
196// across a before-start hook or a start.
197func (m *Manager) hostLock(host string) *sync.Mutex {
198 m.locksMu.Lock()
199 defer m.locksMu.Unlock()
200 if m.locks == nil {
201 m.locks = make(map[string]*sync.Mutex)
202 }
203 mu, ok := m.locks[host]
204 if !ok {
205 mu = &sync.Mutex{}
206 m.locks[host] = mu
207 }
208 return mu
209}
210
211// Root is the delegated subtree's path — the sampler's third scan root.
212// "" for a nil manager (no delegation): the sampler skips an empty root.
213func (m *Manager) Root() string {
214 if m == nil {
215 return ""
216 }
217 return m.root
218}
219
220// Setup arms exec-child accounting for the running agent: resolve our own
221// cgroup from /proc/self/cgroup and run the delegation dance there. Any
222// failure — v1-only box, old unit file without Delegate=, a test, darwin —
223// returns nil with one logged line, and every spawn behaves exactly as
224// today. Fail toward absent readings, never toward a broken spawn path.
225func Setup(logf func(string, ...any)) *Manager { return setup(logf, false) }
226func SetupRetaining(logf func(string, ...any)) *Manager { return setup(logf, true) }
227func setup(logf func(string, ...any), retain bool) *Manager {
228 b, err := os.ReadFile("/proc/self/cgroup")
229 if err != nil {
230 logf("agent: exec-child accounting disabled: %v", err)
231 return nil
232 }
233 rel, ok := ParseSelfCgroup(string(b))
234 if !ok {
235 logf("agent: exec-child accounting disabled: no cgroup v2 path in /proc/self/cgroup")
236 return nil
237 }
238 m, err := setupAt(filepath.Join("/sys/fs/cgroup", rel), logf, retain)
239 if err != nil {
240 logf("agent: exec-child accounting disabled (no delegated subtree — unit file without Delegate=?): %v", err)
241 return nil
242 }
243 logf("agent: exec-child accounting armed (delegated subtree %s)", m.Root())
244 return m
245}
246
247// SetupAt is the delegation dance against an explicit subtree root — the
248// testable core Setup wraps. Order matters: probe, move self to a leaf, THEN
249// enable controllers — cgroup v2 refuses subtree_control writes while the
250// cgroup itself still has member processes ("no internal processes").
251func SetupAt(root string, logf func(string, ...any)) (*Manager, error) {
252 return setupAt(root, logf, false)
253}
254func SetupAtRetaining(root string, logf func(string, ...any)) (*Manager, error) {
255 return setupAt(root, logf, true)
256}
257func setupAt(root string, logf func(string, ...any), retain bool) (*Manager, error) {
258 // 1. Probe live delegation: both control files must be writable by us.
259 // systemd chowns exactly these to User= when Delegate= is set; without
260 // it this open is the cheap, side-effect-free "no" that keeps an
261 // undelegated box on today's path.
262 for _, name := range []string{"cgroup.procs", "cgroup.subtree_control"} {
263 f, err := os.OpenFile(filepath.Join(root, name), os.O_WRONLY, 0)
264 if err != nil {
265 return nil, fmt.Errorf("probe delegation: %w", err)
266 }
267 f.Close()
268 }
269 // 2. Move ourselves into a supervise/ leaf, making step 3 legal.
270 sup := filepath.Join(root, "supervise")
271 if err := os.MkdirAll(sup, 0o755); err != nil {
272 return nil, fmt.Errorf("create supervise leaf: %w", err)
273 }
274 if err := os.WriteFile(filepath.Join(sup, "cgroup.procs"), []byte(strconv.Itoa(os.Getpid())), 0o644); err != nil {
275 return nil, fmt.Errorf("move self into supervise leaf: %w", err)
276 }
277 // 3. Enable the controllers per-child cgroups need for memory.current,
278 // io.stat and pids accounting (cpu.stat exists regardless, but +cpu
279 // keeps utilization attributable). One write, all four: the kernel
280 // applies subtree_control writes atomically.
281 if err := os.WriteFile(filepath.Join(root, "cgroup.subtree_control"), []byte("+cpu +memory +io +pids"), 0o644); err != nil {
282 return nil, fmt.Errorf("enable controllers: %w", err)
283 }
284 m := &Manager{root: root, logf: logf}
285 // 4. Sweep per-host dirs a previous agent left behind — empty after a
286 // restart killed their members. rmdir refuses a non-empty dir, which is
287 // exactly the tolerance we want.
288 // A retaining setup keeps them, and marks the empty ones instead, so a
289 // cgroup an earlier run killed and kept is made fresh before anything
290 // spawns into it (amadan #272, seedKilled).
291 if !retain {
292 m.sweepStale()
293 } else {
294 m.seedKilled()
295 }
296 return m, nil
297}
298
299// sweepStale removes leftover carlos-exec@* dirs under the root. Failures
300// are logged and tolerated: a dir that won't rmdir has something alive in
301// it, and killing is the service manager's job, not ours.
302func (m *Manager) sweepStale() {
303 entries, err := os.ReadDir(m.root)
304 if err != nil {
305 return
306 }
307 for _, e := range entries {
308 if !e.IsDir() || !IsExecName(e.Name()) {
309 continue
310 }
311 dir := filepath.Join(m.root, e.Name())
312 // A generation parent's children go first: rmdir refuses a cgroup
313 // that has child cgroups, empty or not (zero-downtime spec §7.3).
314 if kids, err := os.ReadDir(dir); err == nil {
315 for _, k := range kids {
316 if !k.IsDir() || !isChildName(k.Name()) {
317 continue
318 }
319 if err := os.Remove(filepath.Join(dir, k.Name())); err != nil {
320 m.logf("cgroup: stale %s/%s not removed: %v", e.Name(), k.Name(), err)
321 }
322 }
323 }
324 if err := os.Remove(dir); err != nil {
325 m.logf("cgroup: stale %s not removed: %v", e.Name(), err)
326 }
327 }
328}
329
330// ErrKilledCgroupKept is a start refused because the cgroup it would spawn
331// into took cgroup.kill and could not be removed and made fresh first
332// (amadan #272). Spawning into it anyway would have the kernel SIGKILL the
333// child at once, so nothing is started.
334var ErrKilledCgroupKept = errors.New("cgroup: a cgroup that took cgroup.kill could not be made fresh before a spawn")
335
336// startCmd is the package's one spawn seam: every exec.Cmd.Start, the
337// shim's own included, goes through it, so a test can fake a start without
338// a process.
339var startCmd = func(cmd *exec.Cmd) error { return cmd.Start() }
340
341// placeStart is startIn's placement: the exec-shim (startViaShim) puts the
342// process in dir before any of its code runs. FakePlacementForTest swaps
343// it for a test whose cgroupfs is a temp directory.
344var placeStart = func(m *Manager, dir string, cmd *exec.Cmd, before func() error) (bool, error) {
345 return m.startViaShim(dir, cmd, before)
346}
347
348// hostDir is the per-host cgroup: instance, sidecar and seed all share it —
349// a seed's first-boot cost and a sidecar's load ARE that instance's work.
350func (m *Manager) hostDir(host string) string { return filepath.Join(m.root, ExecName(host)) }
351
352// Start runs build() and starts the result inside host's cgroup, with
353// limit applied to that cgroup first (memory-pressure spec §2.3). Nil-safe:
354// with no manager (or any accounting failure) the child starts exactly as
355// today — a spawn must never fail because accounting failed. build must
356// return a fully configured, unstarted Cmd; it may be called twice, because
357// a Cmd cannot be reused after a failed Start. On a non-nil error the
358// returned Cmd may be nil.
359//
360// Primary mechanism: the placement exec-shim (startViaShim, amadan #272).
361// The child starts as this binary, writes its own PID into the cgroup,
362// and only then execs the command, so none of the command's code runs in
363// the wrong cgroup. clone3 CLONE_INTO_CGROUP did this until the shipped
364// unit's RestrictNamespaces=yes made clone3 answer ENOSYS. If the shim
365// proves it could not place the child before exec (a placement error),
366// this one spawn falls back to starting the command normally and moving it
367// by PID; the next Start still uses the shim. The window between start and
368// move charges the child's first moments to the agent: mis-accounting in
369// the safe direction. Note the PID write migrates only the child process
370// itself — any grandchildren it has already forked by then stay charged to
371// the agent's cgroup, not the child's. A failure that is the command's own
372// (a missing binary, a bad Dir) is returned as is and never retried.
373func (m *Manager) Start(host string, limit Limit, build func() *exec.Cmd) (*exec.Cmd, error) {
374 return m.StartGated(host, limit, build, nil)
375}
376
377// StartGated is Start with a before-start hook. before, when set, is asked
378// immediately before EACH exec of the command, after the cgroup is made and
379// its limits are written (zero-downtime deploys final review F1: the
380// caller's ownership can change during that preparation). Through the shim
381// that is after the child is placed and before it execs (Ruling 13); on
382// the PID-move fallback it is before the start. A refusal runs nothing of
383// the command (a placed shim is killed and reaped), undoes that attempt's
384// preparation as a failed start does (the cgroup dir this spawn would have
385// used is removed if empty), and comes back as before's own error, never
386// joined with an earlier attempt's. A nil before is Start exactly.
387//
388// A host cgroup that took cgroup.kill (KillHost keeps it) is removed and
389// made again before anything is spawned into it, and a start whose killed
390// cgroup will not go is refused with ErrKilledCgroupKept, never run
391// unaccounted (amadan #272, freshen).
392func (m *Manager) StartGated(host string, limit Limit, build func() *exec.Cmd, before func() error) (*exec.Cmd, error) {
393 if m == nil {
394 return startPlain(build, before)
395 }
396 if err := m.freshen(host, m.hostDir(host)); err != nil {
397 return nil, err
398 }
399 return m.startIn(host, m.hostDir(host), limit, false, build, before)
400}
401
402// FakePlacementForTest is for another package's tests over a fake cgroupfs:
403// a temp directory is no cgroup, so the placement shim cannot place a
404// process there, and a generation start (StartChildGated) is never retried
405// by start-and-move. Until t's cleanup, every start that would go through
406// the shim instead starts the command directly behind its before hook, as
407// if it had been placed, and the shim counts as available even in a test
408// binary whose TestMain has no RunShimIfRequested. That is safe because the
409// fake never runs a shim, so it cannot make a test binary re-run itself.
410// Both are restored at t's cleanup. t is a *testing.T. It panics outside a
411// test binary. It swaps package globals, so a test that calls it, and every
412// other test in that binary, must not use t.Parallel.
413func FakePlacementForTest(t interface {
414 Helper()
415 Cleanup(func())
416}) {
417 if !testing.Testing() {
418 panic("cgroup: FakePlacementForTest called outside a test")
419 }
420 t.Helper()
421 orig := placeStart
422 placeStart = func(_ *Manager, _ string, cmd *exec.Cmd, before func() error) (bool, error) {
423 refused, _, err := startGated(cmd, before, nil)
424 return refused, err
425 }
426 forced := shimForcedForTest.Swap(true)
427 t.Cleanup(func() {
428 placeStart = orig
429 shimForcedForTest.Store(forced)
430 })
431}
432
433// startGated starts cmd once before, when set, has agreed. refused reports
434// that before said no: nothing was started, and err is before's own error.
435//
436// register, when set, is called after before agrees and immediately before
437// the start, and the done it returns comes back for the caller to call once
438// the process is where it belongs. A start that fails ends it here, and
439// with no register done is a no-op; either way done may be called.
440func startGated(cmd *exec.Cmd, before func() error, register func() func()) (refused bool, done func(), err error) {
441 done = func() {}
442 if before != nil {
443 if err := before(); err != nil {
444 return true, done, err
445 }
446 }
447 if register != nil {
448 done = register()
449 }
450 if err := startCmd(cmd); err != nil {
451 done()
452 return false, done, err
453 }
454 return false, done, nil
455}
456
457// startPlain is the unaccounted start (no manager, or no cgroup dir): one
458// start behind before. With before nil it is today's `cmd, startCmd(cmd)`.
459func startPlain(build func() *exec.Cmd, before func() error) (*exec.Cmd, error) {
460 cmd := build()
461 refused, _, err := startGated(cmd, before, nil)
462 if refused {
463 return nil, err
464 }
465 return cmd, err
466}
467
468// startIn is Start against an explicit cgroup directory: the host's own
469// (Start) or one of its children (StartChild). Every start places its
470// process through the shim (placeStart); before is StartGated's.
471//
472// required is the generation layout's (StartChildGated, Task 7): every
473// limit must be in place before the process exists. With it, a process
474// that no shim can place here (a binary without RunShimIfRequested) is
475// refused before the child dir is made or build() runs, a child dir that
476// cannot be made or a limit write that fails refuses the start, and a
477// placement that fails refuses it too: all with ErrLimitNotApplied, the
478// box's condition. A required start is never started outside its cgroup
479// and moved by PID: it never reaches startAndMoveIn. Without required
480// (StartGated, the flat start) every one of those stays best-effort, and a
481// placement failure falls back to startAndMoveIn for that spawn (Ruling 2).
482//
483// Either way a failure that is the command's own (a missing binary, a bad
484// Dir) is returned as is, unwrapped, and never retried, and an empty dir
485// this spawn made is removed. A start whose exec.CommandContext context
486// ends during the placement is the caller's own condition too, never the
487// box's: the shim reports it as a placement failure (it could not start,
488// or was killed before placement), so startIn tells it apart (cancelWatch)
489// and, required or not, answers what exec.Cmd.Start answers for a command
490// whose context has ended: the context's own error (context.Canceled or
491// context.DeadlineExceeded), bare. Nothing ran and nothing is retried. The
492// placement's detail goes to the operator log only, so a member reading
493// the wake's failure sees what a direct start's cancellation always showed
494// (whole-branch review, finding 3).
495func (m *Manager) startIn(host, dir string, limit Limit, required bool, build func() *exec.Cmd, before func() error) (*exec.Cmd, error) {
496 if required && !shimAvailable() {
497 m.logf("cgroup: %s: the placement shim is not available in this process, and a generation is never started then moved — generation start refused", host)
498 return nil, fmt.Errorf("%w: %s: the placement shim is not available", ErrLimitNotApplied, host)
499 }
500 if err := os.MkdirAll(dir, 0o755); err != nil {
501 if required {
502 m.logf("cgroup: %s: create cgroup: %v — generation start refused", host, err)
503 return nil, fmt.Errorf("%w: %s: create %s: %w", ErrLimitNotApplied, host, filepath.Base(dir), err)
504 }
505 m.logf("cgroup: %s: create cgroup: %v — spawning unaccounted", host, err)
506 return startPlain(build, before)
507 }
508 if err := m.applyLimit(host, dir, limit, required); err != nil && required {
509 _ = os.Remove(dir)
510 m.logf("cgroup: %s: %s: %v — generation start refused", host, filepath.Base(dir), err)
511 return nil, fmt.Errorf("%w: %s: %w", ErrLimitNotApplied, filepath.Base(dir), err)
512 }
513 if !required && !shimAvailable() {
514 cmd, _, err := m.startAndMoveIn(host, dir, build, before)
515 return cmd, err
516 }
517 cmd := build()
518 watch := watchCancel(cmd)
519 refused, err := placeStart(m, dir, cmd, before)
520 switch {
521 case refused:
522 // Nothing of the command ran, and no retry: the dir goes as it does
523 // after a spawn that never happened (startAndMoveIn).
524 _ = os.Remove(dir)
525 return nil, err
526 case err == nil:
527 m.biasOOM(host, cmd)
528 return cmd, nil
529 case !isPlacementError(err):
530 // The command's own failure: nothing ran, and a retry would fail the
531 // same way.
532 _ = os.Remove(dir)
533 return nil, err
534 }
535 if ctxErr, ended := watch.endedErr(err); ended {
536 // The caller's context ended: nothing ran, and it says nothing
537 // about the box. No fallback either: its Start would answer the
538 // same error at once.
539 _ = os.Remove(dir)
540 m.logf("cgroup: %s: the start's context ended during placement (%v); nothing ran", host, err)
541 return nil, ctxErr
542 }
543 if required {
544 _ = os.Remove(dir)
545 m.logf("cgroup: %s: placement before exec failed (%v) — generation start refused", host, err)
546 return nil, fmt.Errorf("%w: %s: %w", ErrLimitNotApplied, host, err)
547 }
548 // The flat start alone, after a placement failure the shim proved
549 // happened before exec.
550 m.logf("cgroup: %s: placement before exec failed (%v) — using the PID-move fallback for this spawn", host, err)
551 cmd, _, err = m.startAndMoveIn(host, dir, build, before)
552 return cmd, err
553}
554
555// cancelWatch records whether cmd's exec.CommandContext context ended
556// while it was being started: exec.Cmd calls Cancel (by default, a kill)
557// when the context ends after Start, which during a placement kills the
558// shim. A cmd without a context has no Cancel, and nothing is recorded.
559type cancelWatch struct {
560 ended atomic.Bool
561 // probe is a copy of cmd taken before its start, for the context
562 // exec.Cmd keeps unexported (contextErr). Nil without a Cancel.
563 probe *exec.Cmd
564}
565
566func watchCancel(cmd *exec.Cmd) *cancelWatch {
567 w := &cancelWatch{}
568 if cmd.Cancel != nil {
569 probe := *cmd
570 w.probe = &probe
571 cancel := cmd.Cancel
572 cmd.Cancel = func() error {
573 w.ended.Store(true)
574 return cancel()
575 }
576 }
577 return w
578}
579
580// endedErr reports whether a failed placement was the start's context
581// ending, and that context's error: before the shim could start (Start
582// answered the context's error, which the placement error wraps), or
583// during it (Cancel ran, and the shim's answer names no context).
584func (w *cancelWatch) endedErr(err error) (error, bool) {
585 switch {
586 case errors.Is(err, context.Canceled):
587 return context.Canceled, true
588 case errors.Is(err, context.DeadlineExceeded):
589 return context.DeadlineExceeded, true
590 case w.ended.Load():
591 return w.contextErr(), true
592 }
593 return nil, false
594}
595
596// contextErr is the error of the command's context, once Cancel has run,
597// which exec.Cmd does only after that context is done. exec.Cmd keeps the
598// context unexported, but its Start answers ctx.Err() for a context that
599// has ended, before it opens a descriptor or forks. So the copy taken
600// before the start is asked to start, stripped of everything it could
601// otherwise act on: no files, no attributes, no Cancel, and /dev/null as
602// its path, which no exec would run. Should the answer ever be something
603// else, the context still ended, and Canceled is the caller's own end.
604// The unit tests pin both Canceled and DeadlineExceeded through it.
605func (w *cancelWatch) contextErr() error {
606 if w.probe != nil {
607 p := w.probe
608 p.Path, p.Args, p.Env, p.Dir = "/dev/null", nil, []string{}, ""
609 p.Stdin, p.Stdout, p.Stderr, p.ExtraFiles = nil, nil, nil, nil
610 p.SysProcAttr, p.Cancel, p.WaitDelay = nil, nil, 0
611 if err := p.Start(); errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
612 return err
613 }
614 }
615 return context.Canceled
616}
617
618// applyLimit writes limit into dir. Best-effort in the package's usual
619// sense: a refused write (a kernel without the memory controller here, a
620// control file that isn't ours) is one logged line and an uncapped child.
621// It returns the first write that failed, for a caller that must not start
622// without the whole limit (startIn's required); the flat start ignores it.
623// With required, the first failure returns at once and logs nothing here:
624// no child will run, so "runs uncapped" would be untrue, and startIn logs
625// the one refusal line (Task 7 fix round 1). Without it, every line is as
626// before. Written BEFORE the child is in the cgroup, so the cap holds from
627// its first page. memory.max first: if that one fails, high is pointless.
628//
629// The cap bounds the cgroup's TOTAL charge — anonymous memory plus the page
630// cache its reads pull in — not RSS. A tenant whose cache alone exceeds
631// memory.high lives under constant reclaim: slow, and nothing in the
632// journal says why. Hence the log line: the tier a host landed on is the
633// first thing to read when an app "got slow after the roll".
634//
635// memory.oom.group=1 makes an OOM kill inside this cgroup take the whole
636// family: a hub whose children outlive it would otherwise leave orphans
637// holding the socket while the activator respawns a fresh hub beside them.
638func (m *Manager) applyLimit(host, dir string, limit Limit, required bool) error {
639 if limit.MemoryMax <= 0 {
640 return nil
641 }
642 if err := os.WriteFile(filepath.Join(dir, "memory.max"), []byte(strconv.FormatInt(limit.MemoryMax, 10)), 0o644); err != nil {
643 if !required {
644 m.logf("cgroup: %s: memory.max: %v — child runs uncapped", host, err)
645 }
646 return fmt.Errorf("memory.max: %w", err)
647 }
648 var first error
649 if err := os.WriteFile(filepath.Join(dir, "memory.high"), []byte(strconv.FormatInt(HighOf(limit.MemoryMax), 10)), 0o644); err != nil {
650 if required {
651 return fmt.Errorf("memory.high: %w", err)
652 }
653 m.logf("cgroup: %s: memory.high: %v — child is capped but not throttled first", host, err)
654 first = fmt.Errorf("memory.high: %w", err)
655 }
656 // Swap is bounded too, or the cap isn't one: with zram on the box a
657 // cgroup at memory.max simply pushes its anonymous pages into
658 // compressed swap and carries on — a 200 MB allocator under a 64M cap
659 // "survived" on the flagship, live, until this line. Same number as
660 // max: a tenant may spill up to its cap into swap (the slow-then-dead
661 // gradient), then the kill is real.
662 if err := os.WriteFile(filepath.Join(dir, "memory.swap.max"), []byte(strconv.FormatInt(limit.MemoryMax, 10)), 0o644); err != nil {
663 if required {
664 return fmt.Errorf("memory.swap.max: %w", err)
665 }
666 m.logf("cgroup: %s: memory.swap.max: %v — the cap can be exceeded into swap", host, err)
667 if first == nil {
668 first = fmt.Errorf("memory.swap.max: %w", err)
669 }
670 }
671 if err := os.WriteFile(filepath.Join(dir, "memory.oom.group"), []byte("1"), 0o644); err != nil {
672 if required {
673 return fmt.Errorf("memory.oom.group: %w", err)
674 }
675 m.logf("cgroup: %s: memory.oom.group: %v — an OOM kill here takes one process, not the family", host, err)
676 if first == nil {
677 first = fmt.Errorf("memory.oom.group: %w", err)
678 }
679 }
680 m.logf("cgroup: %s: memory.max=%dM memory.high=%dM", host, limit.MemoryMax>>20, HighOf(limit.MemoryMax)>>20)
681 return first
682}
683
684func (m *Manager) startAndMove(host string, build func() *exec.Cmd) (*exec.Cmd, error) {
685 cmd, _, err := m.startAndMoveIn(host, m.hostDir(host), build, nil)
686 return cmd, err
687}
688
689// startAndMoveIn is startAndMove into an explicit cgroup directory, behind
690// StartGated's before. refused reports that before said no (err is its
691// own error). It is the flat start's fallback only: a generation start
692// never comes here.
693//
694// From the moment before agrees until the PID write has returned, the
695// child runs in the agent's cgroup, not dir, so dir's placement is in
696// flight for that window (the registry, inflight.go): a cleanup's proof
697// that dir is empty must not miss it, as it must not miss a shim's.
698func (m *Manager) startAndMoveIn(host, dir string, build func() *exec.Cmd, before func() error) (*exec.Cmd, bool, error) {
699 cmd := build()
700 refused, done, err := startGated(cmd, before, func() func() { return m.beginPlacement(dir) })
701 if err != nil {
702 // The spawn never happened: drop the now-ownerless dir so a failed
703 // wake doesn't leave the sampler reporting permanent zeros for a
704 // host with nothing running. rmdir-safe: os.Remove refuses a
705 // non-empty dir, so this can never take a live sibling's dir.
706 _ = os.Remove(dir)
707 return nil, refused, err
708 }
709 if cmd.Process == nil {
710 // A broken startCmd seam reporting success with no process: this
711 // package must never panic the agent over it. Log and skip the
712 // move; the child, if any, simply stays unaccounted.
713 done()
714 m.logf("cgroup: %s: start reported success with no process — skipping pid move", host)
715 return cmd, false, nil
716 }
717 procs := filepath.Join(dir, "cgroup.procs")
718 err = os.WriteFile(procs, []byte(strconv.Itoa(cmd.Process.Pid)), 0o644)
719 done()
720 if err != nil {
721 m.logf("cgroup: %s: move pid %d: %v — child stays unaccounted", host, cmd.Process.Pid, err)
722 }
723 m.biasOOM(host, cmd)
724 return cmd, false, nil
725}
726
727// biasOOM writes ExecOOMScoreAdj for a just-started child. Best-effort: a
728// refused write is one line, and the child runs with the agent's score.
729func (m *Manager) biasOOM(host string, cmd *exec.Cmd) {
730 if cmd == nil || cmd.Process == nil {
731 return
732 }
733 if err := os.WriteFile(oomScoreAdjPath(cmd.Process.Pid), []byte(ExecOOMScoreAdj), 0o644); err != nil {
734 // A child that crashed in its first milliseconds is a zombie by the
735 // time this write lands, and a zombie's oom_score_adj refuses the
736 // write (seen live: birthday-alarm's crash loop logged one of these
737 // per wake). A dead child needs no bias — say nothing.
738 if procGone(cmd.Process.Pid) {
739 return
740 }
741 m.logf("cgroup: %s: oom_score_adj for pid %d: %v — the agent and this child score alike at an edge-tree OOM", host, cmd.Process.Pid, err)
742 }
743}
744
745// procGone reports whether pid is dead — absent from /proc, or a zombie
746// (state Z in /proc/<pid>/stat, third field after the parenthesised comm).
747func procGone(pid int) bool {
748 b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat"))
749 if err != nil {
750 return true
751 }
752 // comm may contain spaces and parens; the state letter follows the LAST ')'.
753 s := string(b)
754 i := strings.LastIndexByte(s, ')')
755 if i < 0 || i+2 >= len(s) {
756 return false
757 }
758 return s[i+2] == 'Z'
759}
760
761// CleanupHost removes host's per-host cgroup dir after its children exit.
762// Nil-safe and rmdir-tolerant: while a sibling (instance vs sidecar) is
763// still alive the dir is non-empty and the remove fails — the LAST exiter's
764// call succeeds, and the next agent start's sweep catches anything missed.
765// (On real cgroupfs the control files applyLimit wrote don't count: rmdir
766// ignores interface files. In the temp-dir test fixture they are ordinary
767// files, so a test that caps a host and then expects CleanupHost to remove
768// the dir must delete them first — that failure has no production analogue.)
769func (m *Manager) CleanupHost(host string) {
770 if m == nil {
771 return
772 }
773 _ = removeEmptyDir(m.hostDir(host))
774}
775