Skip to content

fix(worker): measure and kill an action by its process tree - #2922

Open
rohnnyjoy wants to merge 3 commits into
TraceMachina:mainfrom
rohnnyjoy:worker-session-sampling
Open

rohnnyjoy wants to merge 3 commits into
TraceMachina:mainfrom
rohnnyjoy:worker-session-sampling

Conversation

@rohnnyjoy

@rohnnyjoy rohnnyjoy commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

What and why

The worker treats an action's process group as the action: it samples the
group's memory and CPU, enforces against it, and kills it. Processes leave a
group freely. A job-control shell moves each job to a group of its own, and
Bazel's test wrapper (test-setup.sh) runs set -m before starting every
test. A detached spawn (Node's spawn(..., { detached: true }), a
daemonizing server) calls setsid, which leaves the session as well. So a
test measures as its waiting shell (a two-second busy test: 0 ms CPU, 194 KiB
peak; a web e2e test whose harness detaches its server: 60 MB while using
gigabytes), soft memory and disk enforcement never see it, and a kill leaves
it running.

This runs every action under a stub, as Bazel's process-wrapper does for a
local action (src/main/tools/process-tools-linux.cc). The stub is a child
subreaper, so anything the action orphans is reparented to it, never to the
worker, whatever session or group it moved to. The action's processes are
exactly the stub's descendants.

Fixes #2921.

  • action_stub.rs (new): fork_stub, for pre_exec. After setsid the
    spawned child forks. The child (with PR_SET_PDEATHSIG) execs the action
    and the parent stays as the stub. The stub passes SIGTERM on and reaps as
    children exit. When the action's own process exits, it kills and reaps
    everything left, reading /proc/thread-self/children until waitpid
    reports ECHILD (process-wrapper's TerminateAndWaitForAll). Then it
    writes a StubReport to a pipe and exits with the action's status. The
    report holds getrusage(RUSAGE_CHILDREN) (the kernel's exact CPU total of
    everything reaped) and the straggler count. All of it is
    async-signal-safe: system calls only, no allocation.
  • process_tree.rs (was process_session.rs):
    • tree_members(root): one pass over /proc that builds the parent map.
    • signal_descendants(root, signal): signals through a pidfd after reading
      the parent again, so a reaped and reused pid is never signalled. SIGKILL
      repeats until no descendant is alive, bounded at 16 passes.
    • The session helpers stay as the fallback for a stub that was itself
      killed, and on macOS.
  • running_actions_manager.rs:
    • The sampler walks the stub's tree. It leaves the stub out, since the stub
      is a fork of the worker and its pages are the worker's.
    • The grace SIGTERM and the hard kill reach every descendant.
    • The reported CPU replaces the sampled CPU.
    • Stragglers come from the report, plus a session kill for orphans of a
      killed stub.
  • namespace_utils.rs: with namespaces on, the same stub replaces the
    namespace's own fork and sits outside the PID namespace the action is the
    init of. MaybeNamespacedChild becomes ActionStubChild. Its kill ends
    the stub's descendants first and then the stub, so nothing is orphaned to
    the worker by the stub's death.
  • persistent_worker/live_worker.rs: a pooled worker is sampled from its own
    pid. Under namespaces it gets a stub with no report.
  • Doc wording in the config (MemoryEnforcement::Soft), the
    ActionResourceUsage.cpu_time_ms proto doc and the generated config docs.

How was this verified?

New tests in nativelink-worker/tests/running_actions_manager_test.rs:

  • no_namespace_action_runs_under_its_stub (replaces
    no_namespace_action_is_process_group_leader): the action's shell reads
    its ppid, pgrp and session from /proc/$$/stat. All three are equal, and
    the parent is not the worker.
  • job_control_job_is_measured: set -m and a two-second busy loop in a
    background job. Asserts cpu_time_ms >= 500.
  • detached_job_is_measured: the same loop under setsid.
  • detached_job_memory_is_measured: 256 MiB held for two seconds under
    setsid. Asserts peak_memory_kb >= 200 MiB.
  • job_control_straggler_is_killed and detached_straggler_is_killed: a
    sleep 300 left in its own group, and in its own session. After the action
    returns, the sleep is gone or a zombie within 5 s.
  • parse_ppid_from_stat_*, parse_session_from_stat_* and
    is_zombie_stat_*.

The three detached tests fail on main without the change:

test tests::detached_job_is_measured ... FAILED
test tests::detached_straggler_is_killed ... FAILED
test tests::detached_job_memory_is_measured ... FAILED
test result: FAILED. 0 passed; 3 failed; 0 ignored; 0 measured; 76 filtered out

With it, cargo test -p nativelink-worker passes every suite: 80 in
running_actions_manager_test, 12 in namespace_utils_test.
cargo clippy -p nativelink-worker --all-targets -- -D warnings is clean and
the crate is formatted with nightly rustfmt.

We run this on a production fleet as a carried patch on v1.7.5 (the same
change, cut against that tag).

Risk

  • Every action now has one more process, the stub: a forked copy of the
    worker that touches almost none of its pages. Namespaced actions already
    had one.
  • When an action's own process exits, everything it left running is killed
    before the action completes. The worker already did this to the session's
    stragglers, and a detached process now cannot escape it. An action that
    relied on a daemon outliving it would notice.
  • /proc is scanned per sample as before; the filter is the parent map
    instead of the session field. Kills scan it once or a few times.
  • If the stub is killed, its orphans go to the worker and are reached only
    by the session, as before this change. A cgroup per action would close
    that, where the worker may create cgroups.

AI assistance

Written by an AI agent (Claude Code). I reviewed every line and ran the tests myself.

@vercel

vercel Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
nativelink Ready Ready Preview Oct 16, 2026 8:48pm UTC
nativelink-aidm Ready Ready Preview Oct 16, 2026 8:48pm UTC

Request Review

@github-actions

This comment has been minimized.

The worker started each action as its own process-group leader, sampled
the group's memory and CPU, and killed the group. Processes leave a
group freely: a job-control shell moves every job to a group of its own
(Bazel's test-setup.sh runs `set -m` before every test), and a detached
spawn (Node's `spawn(..., { detached: true })`, a daemonizing server)
calls `setsid`, which leaves the session too. So the sampler measured
the shell waiting for the test (a two-second busy test read as 0 ms of
CPU and 194 KiB peak; a web e2e test whose harness detaches its server
read as 60 MB while using gigabytes), soft memory and disk enforcement
could not see the real work, and a kill left it running.

Run every action under a stub, as Bazel's process-wrapper does for a
local action. The spawned child calls `setsid` and forks: the child
execs the action, the parent stays as its stub. The stub is a child
subreaper, so a process the action orphans is reparented to it, never
to the worker, whatever session or group it moved to: the action's
processes are exactly the stub's descendants. The sampler walks that
tree in /proc each tick (not the stub, a fork of the worker whose pages
are the worker's). The SIGTERM grace and the hard kill signal every
descendant, through a pidfd after checking the parent again, so a
reused pid is never signalled, and the hard kill repeats until none is
left alive. When the action's own process exits the stub kills what is
left, reaps it, and exits with the action's status, as process-wrapper's
TerminateAndWaitForAll does, so nothing outlives its action to hold a
port or memory the next action is charged for. Before exiting it writes
the kernel's CPU total of everything it reaped (getrusage
RUSAGE_CHILDREN) to a pipe the worker reads, which replaces the sampled
CPU: exact, including processes too short-lived for any sample.

With namespaces on, the same stub sits outside the PID namespace the
action is the init of; it replaces the namespace's own fork. Pooled
persistent workers are sampled from their own pid. If the stub itself
is killed, its orphans go to the worker, and the session it leads is
still signalled. Without /proc (macOS) there is no stub and the session's
group is signalled, as before.

Tests: the action runs under a stub that leads its group and session; a
busy loop in a `set -m` job and in a `setsid` job is the action's CPU;
256 MiB held by a `setsid` job is the action's memory; a job left in a
group of its own and one left in a session of its own are killed when
the action ends. The three detached tests fail on the code before this
change.
`bazel run //nativelink-proto:update_protos` on this branch; the
checked-in file had been formatted by hand and
`//nativelink-proto:update_protos_test` refused it.
The two tests spawn their job with util-linux's `setsid` and hold its
memory from python3. The coverage build runs the tests in a nix
sandbox with neither on PATH, so the job never started and the tests
measured an action that finished in two milliseconds. Skip them where
the tools are missing, as the other tests here do with `which`.

This branch was successfully deployed

2 active deployments
Preview – nativelink — 13a44942 Deployed Oct 16, 2026 by vercel[bot]
Preview – nativelink-aidm — 13a44942 Deployed Oct 16, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Bazel tests leave the action's process group, so the worker measures and kills only the wrapper

1 participant