| 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. |
| 18 | package cgroup |
| 19 | |
| 20 | import ( |
| 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. |
| 37 | const 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. |
| 43 | func 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. |
| 47 | func 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. |
| 57 | const ( |
| 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). |
| 64 | func 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). |
| 71 | func 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. |
| 83 | type 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. |
| 91 | var ( |
| 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. |
| 109 | type 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. |
| 118 | func 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. |
| 128 | func 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. |
| 144 | const ExecOOMScoreAdj = "500" |
| 145 | |
| 146 | // oomScoreAdjPath is a seam so a test can point the write at a temp dir. |
| 147 | var 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. |
| 156 | func 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. |
| 174 | type 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. |
| 197 | func (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. |
| 213 | func (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. |
| 225 | func Setup(logf func(string, ...any)) *Manager { return setup(logf, false) } |
| 226 | func SetupRetaining(logf func(string, ...any)) *Manager { return setup(logf, true) } |
| 227 | func 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"). |
| 251 | func SetupAt(root string, logf func(string, ...any)) (*Manager, error) { |
| 252 | return setupAt(root, logf, false) |
| 253 | } |
| 254 | func SetupAtRetaining(root string, logf func(string, ...any)) (*Manager, error) { |
| 255 | return setupAt(root, logf, true) |
| 256 | } |
| 257 | func 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. |
| 302 | func (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. |
| 334 | var 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. |
| 339 | var 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. |
| 344 | var 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. |
| 350 | func (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. |
| 373 | func (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). |
| 392 | func (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. |
| 413 | func 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. |
| 440 | func 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)`. |
| 459 | func 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). |
| 495 | func (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. |
| 559 | type 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 | |
| 566 | func 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). |
| 584 | func (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. |
| 605 | func (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. |
| 638 | func (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 | |
| 684 | func (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. |
| 698 | func (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. |
| 729 | func (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). |
| 747 | func 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.) |
| 769 | func (m *Manager) CleanupHost(host string) { |
| 770 | if m == nil { |
| 771 | return |
| 772 | } |
| 773 | _ = removeEmptyDir(m.hostDir(host)) |
| 774 | } |
| 775 | |