| index 87d27c613..3673b1e02 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -20,6 +20,7 @@ import ( |
| "syscall" |
| "time" |
| |
| + "github.com/carlosframework/platform/internal/cgroup" |
| "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -56,6 +57,11 @@ type ExecBackend struct { |
| // instance so it can invalidate its own cached responses right after a |
| // write. Nil disables this — no purge listener, no error. |
| Cache *edge.Cache |
| + // Cgroups, when non-nil, places every spawned child — instance, sidecar, |
| + // seed — in a per-host cgroup under the agent's delegated subtree so the |
| + // metrics sampler can account it (exec-child accounting spec). Nil — no |
| + // delegation: old unit file, tests, darwin — spawns exactly as before. |
| + Cgroups *cgroup.Manager |
| } |
| |
| var _ Backend = (*ExecBackend)(nil) |
| @@ -213,30 +219,40 @@ func (b *ExecBackend) Seed(ctx context.Context, rt registry.Route) error { |
| ctx, cancel := context.WithTimeout(ctx, seedTimeout) |
| defer cancel() |
| argv := append(seedArgs, "--db", dbPath) |
| - cmd := exec.CommandContext(ctx, b.liveLink(host), argv...) |
| - // The purge-channel vars are agent-owned in BOTH directions here too |
| - // (Start's discipline, see withoutVars): fileEnv is tenant-supplied, so |
| - // without this a tenant's own env file could hand its seed a |
| - // CARLOS_CONTROL_SOCKET the agent never opened and a CARLOS_PURGE for a |
| - // channel that does not exist — and the seed has no serving cache to |
| - // purge in any case. This is what makes the contract above true rather |
| - // than merely intended. |
| - cmd.Env = childEnv(withoutVars(fileEnv, "CARLOS_CONTROL_SOCKET", "CARLOS_PURGE")) |
| - // Like the instance child, the seed logs through the agent's own streams |
| - // (journald under systemd) — and its stderr is ALSO teed into a bounded |
| - // buffer, because the wake error this returns is the whole diagnosis an |
| - // operator gets for a host that refuses to come up. |
| var stderr capBuffer |
| - cmd.Stdout = os.Stdout |
| - cmd.Stderr = io.MultiWriter(os.Stderr, &stderr) |
| - // WaitDelay is what makes seedTimeout real. Because Stderr is a plain |
| - // io.Writer (the tee), os/exec pipes it and Run waits for that pipe to |
| - // close — and a seed that spawned its own grandchild holds the write end |
| - // open long after the timeout killed the seed itself. Without this, a |
| - // wedged seed would hold the lease until the wake's own 5m budget expired. |
| - cmd.WaitDelay = seedDrainGrace |
| log.Printf("activator: seeding %s: %s %s", host, b.liveLink(host), strings.Join(argv, " ")) |
| - if err := cmd.Run(); err != nil { |
| + // The seed shares the instance's carlos-exec@<host> cgroup: first-boot |
| + // cost is that instance's work. No CleanupHost here — the instance spawn |
| + // reuses the dir moments later, and its exit path (or the next agent |
| + // start's sweep) removes it. |
| + cmd, err := b.Cgroups.Start(host, func() *exec.Cmd { |
| + cmd := exec.CommandContext(ctx, b.liveLink(host), argv...) |
| + // The purge-channel vars are agent-owned in BOTH directions here too |
| + // (Start's discipline, see withoutVars): fileEnv is tenant-supplied, so |
| + // without this a tenant's own env file could hand its seed a |
| + // CARLOS_CONTROL_SOCKET the agent never opened and a CARLOS_PURGE for a |
| + // channel that does not exist — and the seed has no serving cache to |
| + // purge in any case. This is what makes the contract above true rather |
| + // than merely intended. |
| + cmd.Env = childEnv(withoutVars(fileEnv, "CARLOS_CONTROL_SOCKET", "CARLOS_PURGE")) |
| + // Like the instance child, the seed logs through the agent's own streams |
| + // (journald under systemd) — and its stderr is ALSO teed into a bounded |
| + // buffer, because the wake error this returns is the whole diagnosis an |
| + // operator gets for a host that refuses to come up. |
| + cmd.Stdout = os.Stdout |
| + cmd.Stderr = io.MultiWriter(os.Stderr, &stderr) |
| + // WaitDelay is what makes seedTimeout real. Because Stderr is a plain |
| + // io.Writer (the tee), os/exec pipes it and Wait waits for that pipe to |
| + // close — and a seed that spawned its own grandchild holds the write end |
| + // open long after the timeout killed the seed itself. Without this, a |
| + // wedged seed would hold the lease until the wake's own 5m budget expired. |
| + cmd.WaitDelay = seedDrainGrace |
| + return cmd |
| + }) |
| + if err == nil { |
| + err = cmd.Wait() |
| + } |
| + if err != nil { |
| // WaitDelay reports on the SUCCESS path too: a seed that exits 0 after |
| // backgrounding something (a helper, a stray `&`) leaves the inherited |
| // stderr pipe open, and Run then returns ErrWaitDelay INSTEAD of nil |
| @@ -245,7 +261,7 @@ func (b *ExecBackend) Seed(ctx context.Context, rt registry.Route) error { |
| // built — leaving the host fresh again next wake, i.e. permanently |
| // un-wakeable. The exit status is the verdict; the leaked pipe is a |
| // log line. |
| - if errors.Is(err, exec.ErrWaitDelay) && cmd.ProcessState != nil && cmd.ProcessState.Success() { |
| + if cmd != nil && errors.Is(err, exec.ErrWaitDelay) && cmd.ProcessState != nil && cmd.ProcessState.Success() { |
| log.Printf("activator: seeded %s (it exited 0 but left a child holding its output pipe open for more than %s)", host, seedDrainGrace) |
| return nil |
| } |
| @@ -437,23 +453,33 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin |
| } else { |
| childVars = withoutVars(childVars, "CARLOS_CONTROL_SOCKET", "CARLOS_PURGE") |
| } |
| - cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath) |
| - cmd.Env = childEnv(childVars) |
| - // The child logs through the agent's own stderr/stdout (journald under |
| - // systemd); there's no per-instance unit to own a log stream anymore. |
| - cmd.Stdout = os.Stdout |
| - cmd.Stderr = os.Stderr |
| // Record the build we're about to exec — read the symlink immediately before |
| - // Start() so it names the version the kernel will resolve. Reading it here, |
| + // the start so it names the version the kernel will resolve. Reading it here, |
| // rather than back in wake() after the health wait, closes a real window: a |
| // concurrent ship's repoint during the up-to-30s wait would otherwise make |
| // us record the NEW target while this child runs the OLD binary, and the |
| // sweep would then think it's already converged and never cycle it. The |
| - // residual sub-exec race (a repoint between this read and Start) is in the |
| - // safe direction — we'd record the older version, so the sweep cycles once |
| - // too often rather than never. Empty on a read error: the sweep won't cycle. |
| + // residual sub-exec race (a repoint between this read and the start) is in |
| + // the safe direction — we'd record the older version, so the sweep cycles |
| + // once too often rather than never. Empty on a read error: the sweep won't |
| + // cycle. |
| spawnVer, _ := b.LiveVersion(host) |
| - if err := cmd.Start(); err != nil { |
| + // Spawned through the cgroup manager (nil-tolerant: no delegation means |
| + // a plain start, exactly as before) so the child lands in |
| + // carlos-exec@<host> and the sampler can account it. build may run |
| + // twice — the manager's clone3-refusal fallback — so everything about |
| + // the Cmd is configured inside it. |
| + cmd, err := b.Cgroups.Start(host, func() *exec.Cmd { |
| + cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath) |
| + cmd.Env = childEnv(childVars) |
| + // The child logs through the agent's own stderr/stdout (journald |
| + // under systemd); there's no per-instance unit to own a log stream |
| + // anymore. |
| + cmd.Stdout = os.Stdout |
| + cmd.Stderr = os.Stderr |
| + return cmd |
| + }) |
| + if err != nil { |
| return nil, "", err |
| } |
| |
| @@ -479,6 +505,9 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin |
| } |
| go func() { |
| p.err = cmd.Wait() |
| + // Last exiter wins: rmdir refuses while the sidecar (same host |
| + // cgroup) is still alive, and that failure is tolerated. |
| + b.Cgroups.CleanupHost(host) |
| close(p.done) |
| }() |
| |
| @@ -604,18 +633,26 @@ func (b *ExecBackend) StartSidecar(_ context.Context, rt registry.Route) (Proc, |
| if err != nil { |
| return nil, fmt.Errorf("read sidecar env: %w", err) |
| } |
| - cmd := exec.Command(b.liveLink(rt.Host), "sidecar", "run") |
| - cmd.Env = childEnv(fileEnv) |
| - // Like the instance child: logs through the agent's own streams (journald |
| - // under systemd) — no per-host unit owns a log stream anymore. |
| - cmd.Stdout = os.Stdout |
| - cmd.Stderr = os.Stderr |
| - if err := cmd.Start(); err != nil { |
| + // Same host cgroup as the instance: a sidecar's load is that instance's |
| + // work, and attributing it elsewhere would be a lie in the other |
| + // direction. |
| + cmd, err := b.Cgroups.Start(rt.Host, func() *exec.Cmd { |
| + cmd := exec.Command(b.liveLink(rt.Host), "sidecar", "run") |
| + cmd.Env = childEnv(fileEnv) |
| + // Like the instance child: logs through the agent's own streams |
| + // (journald under systemd) — no per-host unit owns a log stream |
| + // anymore. |
| + cmd.Stdout = os.Stdout |
| + cmd.Stderr = os.Stderr |
| + return cmd |
| + }) |
| + if err != nil { |
| return nil, err |
| } |
| p := &childProc{cmd: cmd, done: make(chan struct{})} |
| go func() { |
| p.err = cmd.Wait() |
| + b.Cgroups.CleanupHost(rt.Host) |
| close(p.done) |
| }() |
| return p, nil |