Skip to content

#135 translate pids across the namespace boundary so step attribution works on ARC runners - #136

Merged
matthewdevenny merged 3 commits into
mainfrom
matt/pidns-step-attribution
Sep 17, 2026
Merged

matthewdevenny merged 3 commits into
mainfrom
matt/pidns-step-attribution

Conversation

@matthewdevenny

@matthewdevenny matthewdevenny commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #135.

What broke

On ARC runners every run lands in CodeCargo as Unknown Step (events outside step boundaries), with an empty process name on every connection. GitHub-hosted runners are fine with the same build. Evidence in #135 (same workflow, same commit): the hosted job logs Workflow step process started step_ordinal=4 … and process=MainThread pid=10084 step_ordinal=4; the ARC job logs neither, just process= pid=9190.

Why

An ARC runner is a pod with its own PID namespace. pkg/steps mixed the two numbering spaces: the Runner.Worker pid found in /proc was compared against parent->tgid in step_fork (kernel-global, never equal inside the pod → no child ever gets an ordinal → no step_boundary), seeding wrote /proc tids as keys the cgroup hooks look up by global tid, the reconciler read /proc/<global tgid>/cmdline, and map_sock_pid carried global tgids lookupProcessName cannot resolve from inside the container.

Fix

Kernel-side identity stays global everywhere; translation happens at the boundary, in the kernel.

  • map_task_nspid (global tid → tgid as numbered in the daemon's namespace), owned by tcbpf.c. step_fork maintains it on every fork — a thread copies its process's entry, a new process walks its upid chain to the namespace whose nsfs inode userspace passes in as the pidns_ino rodata (stat /proc/self/ns/pid). step_exit drops entries.
  • step_task_iter, an iter/task program: seeds the table for tasks that predate the attach and streams the process tree (global tid/tgid/ppid + namespace tgid). The kernel scopes it to the opener's namespace. pkg/steps builds seeding and container-tagging views from it and no longer scans /proc; the worker's /proc pid becomes the global tgid step_fork compares.
  • The connect/sendmsg hooks write the namespace tgid into map_sock_pid (falling back to the global tgid when there is no entry: step attribution off, or a task outside our subtree), so process names resolve again. step_child_event.tgid is now the namespace pid.
  • Kernel floor unchanged: iter/task and bpf_seq_write are 5.8, like the ring buffer. The start-time shrink list gains the new map.

On a plain VM (init namespace) the translation is the identity, so hosted runners see no behavior change.

Verification (Lima, 6.17-azure, CI's kernel)

  • sudo unshare --pid --fork --mount-proc ./bpf.test -test.run TestStepFork reproduced the ARC failure on main (child tid must be tagged immediately after fork).
  • New TestStepFork_InPidNamespace and TestStart_InPidNamespace re-execute the kernel-contract test and the full steps.Start path inside unshare --pid --fork --mount-proc; both pass, logging e.g. worker_pid=1 worker_tgid=673755 and Workflow step process started step_ordinal=101 pid=8 cmdline="sleep 5".
  • TestTaskIterRecLayoutMatchesBTF pins the record layout pkg/steps decodes; the container-tag tests drive an injected snapshot with distinct global and namespace ids.
  • go test ./... (unprivileged) and the root suites (./bpf/ ./pkg/tc/ ./pkg/network/ ./pkg/steps/ ./pkg/origin/) are green; GOOS=linux go vet, staticcheck, gofumpt clean.

Rollout

Needs a release (v2.0.0-rc.8) and an action bump. The action has a second, independent ARC problem — it cannot find the runner's _diag directory on ARC images, so ordinals stay unnamed even with this fix — addressed in code-cargo/cargowall-action (PR linked from #135).

Known gaps, unchanged by this PR

  • A process whose non-leader thread calls exec takes over the leader's tid; its map_task_step entry (and now its map_task_nspid entry) is keyed by the old tid, so it goes untagged. Pre-existing; a sched_process_exec re-key would close it.
  • ARC with a Docker sidecar (dind mode): dockerd reports pids from its namespace, which the identity check in pkg/containers already rejects. Container attribution there is out of scope.

… works on ARC runners

Inside a container (ARC runners are Kubernetes pods) the pids userspace
reads from /proc and the global pids the kernel hands to tracepoints and
bpf_get_current_pid_tgid() are different numbering spaces. pkg/steps
mixed them: the Runner.Worker pid found in /proc was compared against
parent->tgid in step_fork (never equal, so no child ever got an ordinal
and no step_boundary was ever emitted), seeding wrote /proc tids as keys
the cgroup hooks could not hit, the reconciler read /proc/<global tgid>
for boundary events, and map_sock_pid carried global tgids that
lookupProcessName could not resolve — every ARC event was unattributed
with an empty process name. GitHub-hosted VMs run in the init namespace,
which is why they were unaffected.

Kernel-side identity stays global everywhere; translation happens at the
boundary, in the kernel:

- map_task_nspid (global tid → tgid in the daemon's namespace), owned by
  tcbpf.c. step_fork maintains it for every fork (a thread copies its
  process's entry, a process walks its upid chain to the namespace whose
  nsfs inode userspace passes in as pidns_ino); step_exit drops entries.
- step_task_iter, an iter/task program: seeds the table for tasks that
  predate the attach and streams the process tree in global ids. The
  kernel scopes it to the opener's namespace. pkg/steps builds seeding
  and container-tagging views from it and no longer scans /proc; the
  worker's /proc pid is translated to the global tgid step_fork compares.
- The connect/sendmsg hooks write the namespace tgid into map_sock_pid,
  falling back to the global tgid when there is no entry (step
  attribution off, or a task outside our subtree), so process names
  resolve again. step_child_event.tgid is the namespace pid.

Kernel floor is unchanged: iter/task and bpf_seq_write are 5.8, like the
ring buffer. The step maps shrink list gains the new table.

Tests: TestStepFork and a new TestStart_TagsWorkerChild exercise the
full contract and are re-executed under unshare --pid --fork
--mount-proc, which reproduced the ARC failure exactly and now passes;
the iterator record layout is pinned against BTF; the container-tag
tests drive an injected snapshot with distinct global and namespace ids.

Signed-off-by: Matthew DeVenny <matt@codecargo.com>
Copilot AI lite review requested due to automatic review settings September 17, 2026 18:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

bpf/tcbpf.c has two unresolved moderate findings and one test-coverage nit.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This PR adds PID-namespace translation so step and socket attribution works on ARC runners.

Changes:

  • Adds BPF task iteration and namespace PID mappings.
  • Updates step discovery, container tagging, and socket attribution.
  • Adds tests, documentation, generated bindings, and map sizing updates.
File summaries
File Reviewed changes Findings
pkg/steps/steps.go Uses iterator snapshots for namespace-aware task discovery. —
pkg/steps/steps_test.go Tests parsing, tagging, and PID-namespace startup. —
design.md Documents PID-namespace handling. —
cmd/start.go Resizes the new map when attribution is disabled. —
bpf/tcbpf.c Translates socket-owner PIDs. Moderate (2 votes): stale mappings can remain after shutdown. Moderate (1 vote): startup ordering can store global PIDs before seeding. Nit (1 vote): add socket-hook namespace integration coverage.
bpf/tcbpf_bpfel.go Updates little-endian generated bindings. —
bpf/tcbpf_bpfeb.go Updates big-endian generated bindings. —
bpf/stepbpf.c Maintains namespace mappings and implements task iteration. —
bpf/stepbpf_test.go Tests namespace behavior and record layouts. —
bpf/stepbpf_bpfel.go Updates little-endian generated step bindings. —
bpf/stepbpf_bpfeb.go Updates big-endian generated step bindings. —
Review details

Files not reviewed (4)

  • bpf/stepbpf_bpfeb.go: Generated file
  • bpf/stepbpf_bpfel.go: Generated file
  • bpf/tcbpf_bpfeb.go: Generated file
  • bpf/tcbpf_bpfel.go: Generated file

Suppressed comments (2)

bpf/tcbpf.c:210

  • The TC connect/sendmsg hooks are attached in cmd/start.go before steps.Start attaches and opens step_task_iter, so map_task_nspid is empty during that startup interval. A socket that connects then permanently stores the global tgid in map_sock_pid; the later task walk does not rewrite existing socket entries, leaving its process name unresolved inside a PID namespace. Attach these hooks only after the tracker has seeded the translation table, or add a reconciliation path for sockets created in this window.
static __always_inline __u32 sock_owner_pid(void) {
    __u64 pid_tgid = bpf_get_current_pid_tgid();
    __u32 tid = (__u32)pid_tgid;
    __u32 *ns = bpf_map_lookup_elem(&map_task_nspid, &tid);
    return ns ? *ns : (__u32)(pid_tgid >> 32);

bpf/tcbpf.c:210

  • The new namespace conversion for socket owners is not covered by the BPF integration tests: they exercise map_task_nspid and boundary events in the nested namespace, but no test attaches a cg_connect*/cg_sendmsg* hook there and asserts that map_sock_pid contains the namespace PID. This is the path that restores process names for ARC socket and DNS attribution, so please extend the in-namespace test to create/connect a socket and verify the stored PID (and retain the existing init-namespace assertion).
static __always_inline __u32 sock_owner_pid(void) {
    __u64 pid_tgid = bpf_get_current_pid_tgid();
    __u32 tid = (__u32)pid_tgid;
    __u32 *ns = bpf_map_lookup_elem(&map_task_nspid, &tid);
    return ns ? *ns : (__u32)(pid_tgid >> 32);
  • Files reviewed: 7/15 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread bpf/tcbpf.c
- ns_tgid_of walks the upid chain from numbers[0] upward with a plain
  bounded index, capping level at PIDNS_MAX_LEVEL-1 and reading each
  struct upid once via bpf_core_read. The daemon's namespace appears at
  most once per chain, so the inward-out order bought nothing and cost a
  data-dependent index expression.
- task_iter_rec now has a bpf2go-generated Go mirror (-type
  task_iter_rec); parseTaskRecords and the bpf test helper decode with
  it instead of hand-written offsets, and the BTF layout test pins the
  C struct against the generated type's offsets.
- design.md states explicitly that BPF_ITER_CREATE shipped in 5.8 with
  the iter/task target and bpf_seq_write (uapi bpf.h at v5.8 lists it,
  v5.7 does not), so the kernel floor claim is unchanged.

Signed-off-by: Matthew DeVenny <matt@codecargo.com>
…d namespace

The tcbpf connect/sendmsg hooks are attached by cmd/start.go and outlive
the step tracker — on shutdown, and for the whole run when steps.Start
fails after the iterator has already seeded the table. With the
tracepoints detached nothing maintained map_task_nspid, so a recycled
tid could resolve to a dead task's process. Tracker.Close now empties
the table after detaching, which puts the hooks on their documented
global-tgid fallback.

The hooks stay attached before steps.Start on purpose (a comment now
says why): a socket connecting in that startup window records the
global tgid, but attaching later would leave the same sockets with no
pid at all, and only a process name from before the job's step can run
is affected.

TestStart_TagsWorkerChild now attaches cgroup/connect4 like start.go,
connects a UDP socket, and asserts map_sock_pid holds our namespace's
pid and map_sock_step the seeded runner tag — meaningful only in the
in-namespace run, where the two numberings differ — and that the table
is empty once the tracker is closed.

Signed-off-by: Matthew DeVenny <matt@codecargo.com>
@matthewdevenny
matthewdevenny merged commit ad7432a into main Sep 17, 2026
29 checks passed
@matthewdevenny
matthewdevenny deleted the matt/pidns-step-attribution branch September 17, 2026 21:32
matthewdevenny added a commit to code-cargo/cargowall-action that referenced this pull request Sep 17, 2026
rc.9 (code-cargo/cargowall#136) translates pids across the namespace
boundary, so step attribution resolves inside an ARC pod — the daemon
half of the _diag discovery fix on this branch. rc.8 also carries the
downgrade-record-before-sentinel ordering fix and the empty-hostname
refusal in the pattern matcher.

Digests recomputed from the published binaries and checked with
`gh attestation verify`.

Signed-off-by: Matthew DeVenny <matt@codecargo.com>
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.

Step attribution is blind inside a PID namespace: ARC runners report every event as Unknown Step

2 participants