fix(ebpf): detach uprobe links for dead inodes to stop bpf_link fd leak#172
Conversation
The static-target uprobe path attached probes via `program.attach()` and discarded the returned link id. In aya the link (and its `bpf_link` fd) stays owned inside the program's link map until it is explicitly detached, so every attached inode held its fds for the whole process lifetime. `rescan_targets` keeps discovering new inodes as npm-style auto-updates rotate the on-disk binary and as sessions come and go, but nothing ever released probes for inodes whose processes had all exited. The attachments — and their fds — therefore grew monotonically (`ebpf_uprobes_attached` only ever climbed, far past the live working set), eventually crossing the process fd limit and starving the API listener's accept(), so the HTTP port stopped responding. Fix: track the (program, link_id) pairs per attached inode and, on each rescan, detach the probes for any inode no longer backed by an on-disk path or a running target process. Detaching drops the link and closes its fd. The health gauge now falls on detach instead of ratcheting up, and `seen` is pruned to the live set so a reused inode number can be re-attached. Partial-attach failures also unwind their links instead of orphaning fds.
The unit inherited systemd's default soft limit of 1024. With the uprobe fd leak fixed the steady-state fd count is small, but a generous ceiling is cheap defense-in-depth: any future fd regression then surfaces as a logged warning with hours of margin rather than a silently dead API port once accept() can no longer get an fd. The prod unit is provisioned on the host (not tracked here) and needs the same LimitNOFILE applied there.
There was a problem hiding this comment.
The existing tests cover exe_link_has_basename, target_has_source, and resolve_target_offsets — pure helpers. There are no tests for the new GC/detach logic (rescan_targets's dead-inode path, detach_links, or attach_at's partial-failure unwind). rescan_targets takes &mut Ebpf which is hard to construct in a unit test without a loaded BPF program, so this is understandable, but the GC logic is the core of the fix and has no test coverage. Worth a Suggestion.
I have enough to write the review.
Summary
PR #172 fixes a real bpf_link fd leak in the eBPF uprobe capture source: program.attach() returned a UProbeLinkId that was previously discarded, so aya kept the link (and its bpf_link fd) alive in the program's link map for the whole process lifetime. With npm-style auto-update inode rotation, attached inodes grew monotonically and eventually exhausted the process fd limit, starving the API listener's accept(). The fix tracks (program_name, link_id) pairs per inode and detaches them when the inode is no longer backed by an on-disk path or a running process; partial-attach failures unwind their links; seen is pruned so a reused inode number can be re-attached. A second commit sets LimitNOFILE=65536 on the staging systemd unit as defense-in-depth. The aya 0.13 API surface (UProbe::attach → Result<UProbeLinkId>, UProbe::detach(link_id) → calls LinkMap::remove → link.detach() → closes the fd) checks out, and CI passed. Recommendation: APPROVE — the fix is correct, well-documented, and the only nits are non-blocking.
Blocking
(none)
Suggestions
- server/h-capture/src/ebpf/source.rs:786-812 —
seen.insert(ino)happens beforeattach_at, so a partial-attach failure (e.g.,ssl_writeattaches,ssl_read_enterfails with a transientperf_event_openEAGAIN) marks the inode asseenbut notattached. The unwound links are correctly detached (no fd leak — that part is new and good), but the inode is never re-attempted on subsequent rescans until it becomes dead and is pruned by GC. This is pre-existing behavior (the original code also inserted-then-attempted), so not a regression — but now thatattach_atdistinguishes "no offsets resolved" (deterministic) from "partial kernel failure" (transient), the transient case could retry instead of being permanently skipped. Consider: onErr(e)fromattach_at,seen.remove(&ino)so the next rescan retries. - server/h-capture/src/ebpf/source.rs:816-830 — The dead-inode GC detaches any inode not in the
liveset, whereliveis built frominode_ofreturningSomethis pass.inode_ofcallsstd::fs::metadata, which can transiently fail on a live inode during a/procrace (process executing,/proc/<pid>/exelink momentarily unreadable). On such a pass the inode is absent fromlive, GC detaches it, and the next pass re-attaches it — a detach/re-attach churn that briefly drops probes for that inode (events missed in the window) and cyclesbpf_linkfds. For a process that forks/exits rapidly this could repeat. Consider requiring two consecutive "not live" passes before detaching, or treating a metadata error (inode_ofreturnsNonebut the path exists) differently from "path gone." Low-severity since the common case is genuinely-dead inodes from auto-update rotation. - server/h-capture/src/ebpf/source.rs:819-823 — The GC collects
deadby iteratingattached.keys()and filtering againstlive, then removes them in a second loop. This is fine for the expected cardinality (a handful of target inodes), butseen.retain(|ino| live.contains(ino) || attached.contains_key(ino))(line 833) is O(|seen| × |live|) since both areHashSets of(u64,u64)— that's a hash lookup per element, so actually O(|seen|). Fine. No change needed; just confirming the complexity is bounded. - server/h-capture/src/ebpf/source.rs:464-477 —
detach_linkssilentlycontinues on a vanished program or wrong-type program. For the dead-inode GC this is correct (best-effort), but for the partial-attach unwind inattach_at(line 713), a silent skip could leave abpf_linkfd orphaned if the program object was somehow replaced between attach and unwind. In practice the program set is fixed for the process lifetime (loaded once at startup, line 126-134), so this can't happen — but atracing::warn!on thecontinuepaths would surface it if it ever did. - server/h-capture/src/ebpf/source.rs (tests) — No test coverage for the new GC behavior. The pure helpers (
exe_link_has_basename,target_has_source,resolve_target_offsets) are tested, butdetach_links,attach_at's partial-failure unwind, andrescan_targets's dead-inode pruning are untested.rescan_targetstakes&mut Ebpfwhich is hard to construct without a loaded BPF program, so unit-testing it would require a trait seam — understandable that it's not done. If afn dead_inodes(attached: &HashMap<K,V>, live: &HashSet<K>) -> Vec<K>helper were extracted from the GC loop, that pure step could be tested directly without anEbpfhandle. - scripts/staging/heron.service:35 —
LimitNOFILE=65536is reasonable, but consider whether the prod unit (provisioned out-of-band per the commit message) needs the same value documented somewhere tracked. The commit message notes "needs the same LimitNOFILE applied there" but there's no follow-up task or issue referenced. Worth confirming the prod provisioning playbook picks this up.
Questions
- server/h-capture/src/ebpf/source.rs:788 —
seen.insert(ino)beforeattach_atmeans a partial-attach failure permanently marks the inode as attempted. Is this intentional for the transient-failure case, or shouldErrfromattach_atundo theseen.insert? The pre-existing behavior was the same, so this isn't a regression — but the PR's new partial-unind logic makes the "transient" vs "deterministic" distinction visible, so it might be worth revisiting. - scripts/staging/heron.service — The commit message says "The prod unit is provisioned on the host (not tracked here) and needs the same LimitNOFILE applied there." Is there a tracked follow-up to apply this to prod, or is it a manual ops step that could be forgotten? The staging soak gate won't catch a prod-only fd-exhaustion regression since staging already has the higher limit.
Verified
- aya 0.13 API surface: read
/home/ci/.cargo/registry/src/.../aya-0.13.1/src/programs/uprobe.rs—UProbe::attachreturnsResult<UProbeLinkId, ProgramError>(line 84),UProbe::detach(link_id: UProbeLinkId)takes by value (line 103).LinkMap::remove(inlinks.rs:81-86) calls.detach()on the removed link, which closes thebpf_linkfd. The fix's premise ("aya owns the link untildetach/take_link") is correct. - Caller compatibility: searched all
attached_inodesreferences (lines 186, 191, 193, 205, 257, 261, 267, 273) — all use.is_empty()or.len(), which work on bothHashSetandHashMap. No caller depends on the oldHashSetshape. - Metric gauge:
Metric::EbpfUprobesAttachediskind: Gauge(h-common/src/internal_metrics.rs:157), so.set()is the correct operation. The PR correctly switched from implicit ratcheting to explicit.set(live_count). - Leakage check: grepped
scripts/staging/heron.servicefor RFC1918 ranges, CGNAT, plaintext credentials, andBEGIN ... PRIVATE KEY— no matches. Paths (/opt/heron/...) are generic install paths, not machine-specific. Commit message refers to "the staging VM" / "the prod host" generically per CLAUDE.md rule #5. seen.retaincorrectness: traced the GC loop —deadis computed and removed fromattachedbeforeseen.retain, soattached.contains_key(ino)in the retain only matches live (still-attached) inodes. Dead inodes are correctly pruned from bothattachedandseen, allowing a reused inode number to be re-attempted.- Partial-attach unwind:
std::mem::take(&mut links)inattach_at(line 713) emptieslinksand passes ownership todetach_links; the now-emptylinksis dropped onreturn Err(e)with no effect. No double-free or use-after-free.
🤖 Reviewed by the review bot • workflow run
Problem
The eBPF SSL-uprobe capture source leaks
bpf_linkfile descriptors. Eachattached target inode holds a handful of
bpf_linkfds (one per uprobe:ssl_write+ssl_read_enter+ssl_read_exit) for the entire processlifetime, even after every process backing that inode has exited.
Root cause is in
h-capture/src/ebpf/source.rs: the static-target attach pathcalled
program.attach(...)and discarded the returned link id. In aya 0.13 thelink (and its fd) stays owned inside the program's link map until it is
explicitly detached, so dropping the id does not release anything.
rescan_targetskeeps discovering new inodes as npm-style auto-updates rotatethe on-disk binary and as monitored sessions come and go, but nothing ever
detached probes for inodes whose processes were all gone.
The attachments — and their fds — therefore grew monotonically (the
ebpf_uprobes_attachedgauge only ever climbed, far past the live working set),eventually crossing the process fd soft limit and starving the HTTP API
listener's
accept(), so the API stopped responding.Fix
(program, link_id)pairs per attached inode (InodeLinks).running target process, and detach the probes for any inode no longer live.
Detaching drops the link and closes its fd.
seento the live set so a reused inode number can be re-attached.Net effect: the health gauge falls on detach instead of ratcheting up, and the
fd footprint tracks the live monitored set rather than growing without bound.
Dynamic
libsslprobes are intentionally left attached for the processlifetime (a fixed, startup-only set — not a growth source).
Defense-in-depth
scripts/staging/heron.servicenow setsLimitNOFILE=65536(it previouslyinherited the systemd default of 1024). The prod unit is provisioned on the host
and is not tracked here — it needs the same change applied there.
Verification
the link id,
&mut Program -> &mut UProbe,detach(link_id), the per-inodelink-tracking collections) against the real
aya 0.13.1crate on Linux —cargo checkclean.--features ebpfbuild + load soak runs on merge via staging-soak(the BPF toolchain isn't available on the dev machine).
Post-deploy runtime check
After rollout, confirm the gauge oscillates around the active set instead of
climbing, and that
bpf_linkfds fall as sessions exit:ebpf_uprobes_attachedshould track the live target set, not increase forever.bpf_linkentries under the process fd table should drop when monitoredsessions end.