Skip to content

Cross-OS portability: fix BPF load failures, guard/symbol drift, arm64 gaps, and installer breakage#92

Merged
1a1a11a merged 5 commits into
mainfrom
jason/sweet-turing-mxmtx4
Jul 4, 2026
Merged

Cross-OS portability: fix BPF load failures, guard/symbol drift, arm64 gaps, and installer breakage#92
1a1a11a merged 5 commits into
mainfrom
jason/sweet-turing-mxmtx4

Conversation

@1a1a11a

@1a1a11a 1a1a11a commented Jul 4, 2026

Copy link
Copy Markdown
Member

Problem

The tracer kept breaking on different Linux OSes. The trigger for this PR was a hard load abort:

Loading BPF program... \ LLVM ERROR: line 3018: Invalid usage of the XADD return value
Aborted

That specific bug is fixed in the first two commits, and a full three-track portability audit (compile-time, attach-time, environment) followed — every finding below was adversarially verified against the actual code and kernel/BCC history before being fixed, and the fix batch itself went through an adversarial review pass before push.

Fixes by failure class

Hard load failures (whole tracer aborts)

  • XADD return value (req_id generator): __sync_fetch_and_add's return value compiles to a BPF XADD that can't return its old value on all kernels/LLVM. Replaced with a per-CPU counter + CPU id in the high 16 bits (also removes a duplicate-id race).
  • skb:kfree_skb read args->reason unguarded — field exists only on ≥ 5.17 (5.15.58+ LTS backports); on anything older, --network aborted the entire load. Now gated by sniffing the tracepoint format file (-DHAS_SKB_DROP_REASON), same mechanism as HAS_CMD_FLAGS, so the gate can never disagree with the generated args struct.
  • tcp:tcp_retransmit_skb read args->state unguarded — field added in 4.20, never backported to RHEL 8's 4.18 (-DHAS_TCP_RETRANSMIT_STATE).
  • BTF special-field placeholders collided with modern BCC's vendored uapistruct bpf_timer {}; under < 5.17 is a redefinition error on stock Ubuntu 22.04 (5.15 + bcc 0.24). Replaced version-guarded placeholders with unconditional tag renames to private placeholder structs — correct on every bcc/kernel pair.
  • bpf_probe_read_kernel/user(_str) on kernel < 5.5 + bcc 0.12–0.14 (stock Ubuntu 20.04) — compiles to helper ids the old verifier rejects (invalid func unknown#113). Added #ifndef-wrapped legacy-helper macros under a < 5.5 guard (BCC ≥ 0.15's own injected defines win when present).
  • bpf_get_current_cgroup_id() unguarded (4.18+ helper) — silently killed the vfs read/write/open probes on older kernels; now a compat macro returning 0.
  • build_dentry_path loops on pre-5.3 verifiers — a rolled loop (LLVM-dependent; a real deployment logged loop not unrolled) is a hard back-edge rejection before 5.3. Readdir now degrades to basename-only there instead of risking the whole program.

Guard/symbol drift (probe families silently dead or emitting garbage)

  • Five folio cache handlers guarded >= 5.17 while their symbols exist from 5.16 → hit/miss/dirty/writeback probes never attached on 5.16.
  • Readahead handler prototype matched no kernel at all: guarded ≥ 5.17 but typed for pre-5.10 __do_page_cache_readahead; on ≥ 5.10 it misread the readahead_control* as the mapping (garbage rows). Split into three correctly-typed variants (< 5.10, do_page_cache_ra ≥ 5.10, page_cache_ra_order ≥ 5.18).
  • trace_shrink_folio_list reads no args but was guarded ≥ 5.17 — reclaim tracing was dead on every older kernel including 5.15 LTS. Guard removed.
  • trace_cache_drop_folio args misaligned on every kernel: declared (mapping, folio) but __filemap_remove_folio is (folio, shadow). Fixed prototype + guard lowered 5.18 → 5.17.
  • vfs_rename read through a hand-rolled struct mirror that broke on < 5.12 (inode internals read as dentries) and on 6.18+ (kernel merged the mnt_idmap fields, moving new_dentry from offset 40 → 32; the stale read landed on delegated_inode → garbage rename events). Now reads the real struct renamedata from <linux/fs.h> (BCC recomputes offsets per kernel), with a 6-arg variant for < 5.12.

arm64 feature loss

  • __arm64_sys_mremap branch + pt_regs-unwrap variant (regs[0..3]): MREMAP events were silently absent on all arm64.
  • __arm64_sys_io_uring_enter branch + variant: the old fallback chain leaned on __io_uring_enter/__sys_io_uring_enter, which never existed in any mainline kernel — arm64 got no io_uring ENTER events at all. Dead branches removed.

Environment / installer (why installs "succeeded" then nothing worked)

  • Python 3.10-only annotation syntax (X | None, list[str], no future import) crashed at import on the stock interpreters of RHEL 8/9, Debian 11, Ubuntu 20.04, Amazon Linux — while install.sh enforced only "3.6+". Added from __future__ import annotations to the six affected modules; floor raised to the real minimum (3.7).
  • systemd service was dead-on-arrival: the unit passes --output, which argparse didn't define → exit 2 → crash-loop on every systemd distro. Added a real --output DIR option (subparser uses SUPPRESS so iotrc --output /x dev keeps /x).
  • WSL2/cloud installs aborted before even installing bcc: apt-get install bpfcc-tools linux-headers-$(uname -r) fused into one fatal transaction under set -e. Headers are now a separate non-fatal step with a CONFIG_IKHEADERS-aware warning and a WSL2 pointer.
  • Debian installer appended the sid repo to stable (partial-upgrade risk); bpfcc has shipped in stable since buster. Removed.
  • Amazon Linux 2023 case added (AL2 is EOL and clearly rejected); kernel-devel-$(uname -r) on dnf paths; git install covers rocky/alma/amzn.
  • Missing /etc/machine-id crashed the tracer at startup; now falls back to the dbus path, treats empty/"uninitialized" as missing (container hash-collision), and persists a generated id as last resort.
  • BPF source resolved relative to __file__ (a foreign checkout at the CWD could silently shadow the installed prober.c via BCC's lookup order).
  • Failure UX: the misleading "Your device is incompatible" message replaced; the diagnostics dump now prints actionable hints (missing headers incl. IKHEADERS, Secure Boot lockdown read from /sys/kernel/security/lockdown, old bcc). A pre-compile breadcrumb file (mkstemp, O_EXCL — not a predictable /tmp name) survives native LLVM aborts that bypass Python exception handling entirely, and skips subprocess probes on the happy path.
  • 32-bit statfs ctypes layout fixed (c_long/c_ulong); README documents PEP 668 pip breakage and --output; docs corrected (mainline block_rq_complete never had cmd_flags; BCC compiles in-process via libclang).

CI hardening

bpf_smoke.py now mirrors the loader exactly (both trace mounts, network -D gates) and attaches every cache-probe family as ordered fallback chains identical to KernelProbeTracker — a guard/symbol mismatch now fails CI instead of shipping. (The flat version of this check was itself caught by the pre-push adversarial review: old page-API symbols still exist on 6.x as folio-compat wrappers, which would have made CI red on every run.)

Verified NOT bugs (no change; documented so nobody re-chases them)

  • The 256-byte __builtin_memset/memcpy sites can never lower to libcalls (the real cliff is 1024 bytes, uniform across LLVM 7→main; a _Static_assert now guards it).
  • The CWD-relative BPF path did not break sudo iotrc from other directories (BCC's argv[0] fallback rescues it) — fixed only as shadowing hardening.
  • Direct file->f_path dereferences are safe (BCC's rewriter instruments them).

Known follow-up (documented in-code, deliberately not in this PR)

-mllvm -bpf-stack-size=4096 masks LLVM's 512-byte stack diagnostic while the kernel verifier still enforces 512/frame; struct data_t (~408 B) is stack-allocated in ~26 handlers. The durable fix (per-CPU scratch conversion handler-by-handler, then dropping the flag) needs runtime validation on real hardware and is out of scope here.

Testing

  • 119/119 unit tests pass; bash/Python syntax and preprocessor balance verified.
  • CI bpf-compile (compiles + loads + attaches on the runner kernel) validates the modern-kernel path, now with much wider probe coverage.
  • Old-kernel/arm64 paths are compile-gated and were verified against kernel history by independent adversarial verifiers (~1.9M tokens of verification across 31 agents); they cannot be executed in this sandbox — flagging for a smoke run on RHEL 8/Ubuntu 20.04/arm64 hardware if available before release.

🤖 Generated with Claude Code

Loading the prober aborted with:
  LLVM ERROR: Invalid usage of the XADD return value

block_rq_issue assigned req_id via __sync_fetch_and_add(gen, 1), whose
return value compiles to a BPF XADD instruction that cannot return its
old value on all kernels/LLVM versions, causing the loader to abort.

Replace the global atomic counter with a per-CPU counter and fold the
CPU id into the high 16 bits of req_id. BPF programs run with preemption
disabled, so the per-CPU read-increment-store needs no atomic, and the
CPU-id prefix keeps ids unique across CPUs. This is stronger than a
read-then-lock_xadd workaround, which would race and hand out duplicate
ids under concurrent block I/O, defeating req_id's purpose of
disambiguating distinct I/Os that reuse the same (dev, sector).

req_id is consumed only as an opaque unique identifier in the CSV output
(ordering uses the separate ts field), so dropping strict global
monotonicity has no downstream impact. Comments updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y9in1s3yxA9ZWLKmfQyUa
Copilot AI review requested due to automatic review settings July 4, 2026 10:24

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request refactors the request ID generation mechanism in the BPF prober to use a per-CPU array instead of a global atomic counter. This change avoids portability issues with the BPF XADD instruction on older kernels and LLVM versions. The unique request ID is now constructed by combining the CPU ID in the high 16 bits with a per-CPU sequence counter in the low 48 bits. There are no review comments, so I have no feedback to provide.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes an eBPF verifier/load failure in the block probe caused by using the return value of __sync_fetch_and_add() (which can compile to an unsupported XADD-return sequence in some kernel/LLVM combinations). It changes the request-id generator to a per-CPU counter and encodes the CPU id into req_id to preserve uniqueness without relying on atomics that require returning the old value.

Changes:

  • Replace the global BPF_ARRAY request-id counter with a BPF_PERCPU_ARRAY counter.
  • Generate req_id as (cpu_id << 48) | per_cpu_seq and update struct/map comments to reflect the new scheme.
  • Update block_rq_issue to use read-increment-store on the per-CPU counter instead of __sync_fetch_and_add().

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/tracer/prober/prober.c Outdated
claude added 4 commits July 4, 2026 10:27
Make the high-16-bits CPU field explicit at the point of use so the
encoding matches its doc comment and no longer relies on silent shift
overflow to bound the CPU id. Behaviorally equivalent for realistic CPU
counts; the mask just documents the field width in the code itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y9in1s3yxA9ZWLKmfQyUa
…robes

Batch 1 of the cross-OS portability audit: every fix below was independently
verified against kernel history and the actual code paths.

Load blockers (--network aborted the whole tracer):
- skb:kfree_skb read args->reason unconditionally; the field only exists on
  kernels >= 5.17 (backported to 5.15.58 LTS). On anything older BCC cannot
  generate the member and the single BPF() compile fails. Now gated by
  HAS_SKB_DROP_REASON, set by sniffing the tracepoint format file - the same
  mechanism as the existing HAS_CMD_FLAGS - so the gate can never disagree
  with the generated args struct and backports are handled for free.
- tcp:tcp_retransmit_skb read args->state, which was added in 4.20 and never
  backported to RHEL 8's 4.18; gated by HAS_TCP_RETRANSMIT_STATE the same way.
- The format sniffing now checks both debugfs and tracefs mounts; previously
  a tracefs-only system silently lost HAS_CMD_FLAGS.

Guard/symbol mismatches (probe families silently dead or emitting garbage):
- Five folio cache handlers were guarded >= 5.17 while their symbols exist
  from 5.16 (where core MM already calls them): hit/miss/dirty/writeback
  probes never attached on 5.16 kernels. Guards lowered to 5.16.
- trace_do_page_cache_readahead's prototype (mapping, file, offset, nr)
  matches only pre-5.10 __do_page_cache_readahead, but was guarded >= 5.17:
  on modern kernels it attached to do_page_cache_ra and misread the
  readahead_control pointer as the mapping (garbage rows); on < 5.17 it
  didn't compile at all. Split into three correctly-typed variants
  (legacy < 5.10, do_page_cache_ra >= 5.10, page_cache_ra_order >= 5.18),
  each attached to its matching symbol.
- trace_shrink_folio_list reads no function arguments, so its >= 5.17 guard
  only disabled reclaim tracing on older kernels (shrink_page_list exists
  there); guard removed.
- trace_cache_drop_folio declared (mapping, folio) but its attach target
  __filemap_remove_folio is (folio, shadow) - args were misaligned on every
  kernel, emitting garbage drop rows. Fixed the prototype, read the mapping
  from folio->mapping, and lowered the guard 5.18 -> 5.17 (the symbol's
  actual introduction).

arm64 feature loss:
- mremap tracing attached only __x64_sys_mremap/sys_mremap; on arm64 the
  wrapper is __arm64_sys_mremap, so MREMAP events were silently absent.
  Added the Python branch plus a pt_regs-unwrapping arm64 variant
  (args in regs[0..3] per SC_ARM64_REGS_TO_ARGS).
- io_uring_enter's fallback chain leaned on __io_uring_enter and
  __sys_io_uring_enter, neither of which ever existed in mainline (the
  syscall is only reachable via the per-arch SYSCALL_DEFINE wrappers), so
  arm64 got no io_uring ENTER events at all. Added
  __arm64_sys_io_uring_enter + arm64 unwrap variant and dropped the two
  provably-dead branches.

CI: bpf_smoke.py now mirrors the loader's format sniffing (both trace mounts,
network gates) and attaches every folio/page cache handler conditionally on
its symbol, so a guard/symbol mismatch fails CI instead of shipping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y9in1s3yxA9ZWLKmfQyUa
Batch 2 of the cross-OS portability audit (compile-time class). Each fix was
adversarially verified against kernel/BCC history before being applied.

- Replace the version-guarded BTF special-field placeholders (bpf_timer/
  bpf_wq/bpf_task_work) with unconditional tag renames to private empty
  placeholder structs. Which side defines the real struct depends on BOTH
  the kernel AND the installed BCC (BCC force-includes a vendored uapi
  snapshot that defines bpf_timer since 0.23), so no LINUX_VERSION_CODE
  guard can be right for every pair: "struct bpf_timer {}" under < 5.17 was
  a hard redefinition error on stock Ubuntu 22.04 (5.15 + bcc 0.24). The
  rename is correct on every combination because the placeholder tag is
  ours alone.

- Map bpf_probe_read_kernel/user(_str) to the legacy bpf_probe_read(_str)
  helpers on kernels < 5.5 (ifndef-wrapped, so BCC >= 0.15's own injected
  compat defines win when present). The file uses the 5.5+ helper family
  ~210 times; on bcc 0.12-0.14 (stock Ubuntu 20.04) those names compile to
  helper ids a < 5.5 verifier rejects at load ("invalid func unknown#113"),
  killing the whole program.

- Guard bpf_get_current_cgroup_id() (kernel 4.18+) behind a compat macro
  returning 0 on older kernels; previously the vfs read/write/open probes
  silently failed to attach on < 4.18.

- Gate build_dentry_path's d_parent walk on kernel >= 5.3: pre-5.3
  verifiers reject any loop the compiler leaves rolled (an LLVM-dependent
  outcome — a real deployment logged "loop not unrolled" here), and fully
  unrolled it can breach the pre-5.2 4096-insn cap. Older kernels degrade
  the readdir probe to basename-only instead of risking the whole program.

- Read vfs_rename's argument via the REAL struct renamedata from
  <linux/fs.h> instead of a hand-rolled fixed-offset mirror, and add the
  6-arg variant for kernels < 5.12. The mirror silently broke twice: on
  < 5.12 (inode internals read as dentries) and on 6.18+ (the kernel merged
  the two mnt_idmap fields, moving new_dentry from offset 40 to 32, so the
  stale read landed on delegated_inode and every rename event carried
  garbage new-side data). BCC recompiles against the running kernel's
  headers, so the real type stays correct automatically.

- Add a static assert keeping FILENAME_MAX_LEN <= 1024: constant
  memset/memcpy above that cannot be inlined by the BPF backend on any
  LLVM (deterministic compile failure if ever raised).

Verified NOT bugs during the audit (no change needed): the existing
256-byte builtins can never lower to libcalls (the cliff is 1024, uniform
across LLVM 7..main); BCC's rewriter handles the direct file->f_path
dereferences; all map types used are >= 4.10 features.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y9in1s3yxA9ZWLKmfQyUa
…tics

Batch 3 of the cross-OS portability audit (environment class). Each fix was
adversarially verified before being applied.

Python compatibility (blocked RHEL 8/9, Debian 11, Ubuntu 20.04, Amazon
Linux stock interpreters entirely):
- Add `from __future__ import annotations` to the six modules using PEP 604
  (`X | None`) / PEP 585 (`list[str]`) annotation syntax; without it import
  crashes on Python <= 3.9 before argparse even runs. No runtime annotation
  introspection exists in the codebase, so lazy annotations are safe.
- Raise install.sh's Python floor from 3.6 to the real minimum 3.7
  (time.time_ns, subprocess text=), with an RHEL 8 AppStream pointer.

Installer (install.sh):
- Stop appending the Debian SID repo to stable systems: bpfcc-tools has
  shipped in stable main since buster, and an unpinned sid entry risks
  partial upgrades to unstable on the next apt upgrade.
- Split kernel-headers install from the bcc install and make it non-fatal:
  the fused `apt-get install bpfcc-tools linux-headers-$(uname -r)` under
  set -e aborted the ENTIRE install (bcc included) on WSL2 and stale cloud
  images where the exact headers package does not exist. The warning now
  checks /lib/modules/.../build and CONFIG_IKHEADERS and points WSL2 users
  at the right fix.
- Add an Amazon Linux case (AL2023 via dnf incl. kernel-devel; AL2 is EOL
  and rejected with a clear message) and install kernel-devel-$(uname -r)
  on the dnf paths (covers install_weak_deps=False setups).
- Correct the false wrapper comment claiming iotrc must run from the repo
  root.

Startup robustness:
- capture_machine_id(): fall back /etc/machine-id -> /var/lib/dbus/
  machine-id -> persisted generated id; treat empty/"uninitialized"
  content as missing (container images collide on hash("") otherwise).
  Previously a missing /etc/machine-id crashed the tracer at startup.
- iotrc.py: resolve the BPF source relative to __file__ instead of the
  CWD. BCC's argv[0] fallback made the old relative path work, but it
  checks the CWD first, so a foreign checkout could silently shadow the
  installed prober.c.
- Add a real --output DIR option (main parser and dev subcommand). The
  systemd unit has always passed --output; argparse exited 2 and the
  service crash-looped on every systemd distro.

Failure diagnostics (what users see when BPF load fails):
- Replace the misleading "Your device is incompatible" line and print
  actionable remediation hints derived from the collected diagnostics:
  missing kernel headers (incl. CONFIG_IKHEADERS), Secure Boot lockdown
  (now read from /sys/kernel/security/lockdown, which never appears on the
  cmdline), and very old bcc.
- Write a pre-compile breadcrumb diagnostics file that is deleted on
  success: a native LLVM abort (e.g. the recently fixed "LLVM ERROR:
  Invalid usage of the XADD return value") kills the process inside libbcc
  without raising, bypassing the except path entirely - the breadcrumb
  still leaves a shareable report.
- SystemSnapper's cmd_flags diagnostic now checks tracefs as well as
  debugfs, matching the loader.

Data-inventory correctness:
- FilesystemSnapper's ctypes statfs layout now uses c_long/c_ulong so it
  matches the glibc ABI on 32-bit userlands too (f_type was misread on
  big-endian 32-bit, failing pseudo-fs detection open).

Docs: README notes PEP 668 (externally-managed pip) and documents
--output; COMPATIBILITY_FIXES.md corrected (mainline block_rq_complete has
never exposed cmd_flags; BCC compiles in-process via libclang, it does not
shell out).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y9in1s3yxA9ZWLKmfQyUa
@1a1a11a 1a1a11a changed the title Fix BPF load failure: avoid XADD return value for block req_id Cross-OS portability: fix BPF load failures, guard/symbol drift, arm64 gaps, and installer breakage Jul 4, 2026
@1a1a11a 1a1a11a merged commit 423bf74 into main Jul 4, 2026
4 checks passed
@1a1a11a 1a1a11a deleted the jason/sweet-turing-mxmtx4 branch July 4, 2026 14:10
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.

3 participants