#135 translate pids across the namespace boundary so step attribution works on ARC runners - #136
Merged
Merged
Conversation
… 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>
There was a problem hiding this comment.
🟡 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.gobeforesteps.Startattaches and opensstep_task_iter, somap_task_nspidis empty during that startup interval. A socket that connects then permanently stores the global tgid inmap_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_nspidand boundary events in the nested namespace, but no test attaches acg_connect*/cg_sendmsg*hook there and asserts thatmap_sock_pidcontains 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.
- 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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 …andprocess=MainThread pid=10084 step_ordinal=4; the ARC job logs neither, justprocess= pid=9190.Why
An ARC runner is a pod with its own PID namespace.
pkg/stepsmixed the two numbering spaces: theRunner.Workerpid found in/procwas compared againstparent->tgidinstep_fork(kernel-global, never equal inside the pod → no child ever gets an ordinal → nostep_boundary), seeding wrote/proctids as keys the cgroup hooks look up by global tid, the reconciler read/proc/<global tgid>/cmdline, andmap_sock_pidcarried global tgidslookupProcessNamecannot 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 bytcbpf.c.step_forkmaintains 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 thepidns_inorodata (stat /proc/self/ns/pid).step_exitdrops entries.step_task_iter, aniter/taskprogram: 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/stepsbuilds seeding and container-tagging views from it and no longer scans/proc; the worker's/procpid becomes the global tgidstep_forkcompares.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.tgidis now the namespace pid.iter/taskandbpf_seq_writeare 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 TestStepForkreproduced the ARC failure onmain(child tid must be tagged immediately after fork).TestStepFork_InPidNamespaceandTestStart_InPidNamespacere-execute the kernel-contract test and the fullsteps.Startpath insideunshare --pid --fork --mount-proc; both pass, logging e.g.worker_pid=1 worker_tgid=673755andWorkflow step process started step_ordinal=101 pid=8 cmdline="sleep 5".TestTaskIterRecLayoutMatchesBTFpins the record layoutpkg/stepsdecodes; 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,gofumptclean.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
_diagdirectory 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
exectakes over the leader's tid; itsmap_task_stepentry (and now itsmap_task_nspidentry) is keyed by the old tid, so it goes untagged. Pre-existing; asched_process_execre-key would close it.dindmode): dockerd reports pids from its namespace, which the identity check inpkg/containersalready rejects. Container attribution there is out of scope.