Skip to content

ebpf.plugin: fix PID accounting shared-memory pool leak and 100% CPU spin#22232

Merged
stelfrag merged 3 commits into
netdata:masterfrom
ktsaou:fix-ebpf-shm-pool-leak
Apr 21, 2026
Merged

ebpf.plugin: fix PID accounting shared-memory pool leak and 100% CPU spin#22232
stelfrag merged 3 commits into
netdata:masterfrom
ktsaou:fix-ebpf-shm-pool-leak

Conversation

@ktsaou

@ktsaou ktsaou commented Apr 20, 2026

Copy link
Copy Markdown
Member

Summary

Fixes a slow-burn leak in ebpf.plugin's per-PID shared-memory accounting pool (/dev/shm/netdata_shm_integration_ebpf, 32,768 slots). On a workstation with ~1,300 live PIDs and normal process churn, the pool fills completely within ~15 hours (observed: current = 32,768, ≈96% stale entries). Once full, each module's apps-read loop enters an infinite loop and pegs one CPU core at 100% indefinitely.

The hang is the visible symptom; the leak is the cause. This PR fixes both, plus several related secondary bugs found during the investigation.

Evidence (reproduced on a live process)

  • ebpf.plugin pid 3228547, thread EBPF_READ_FD sampled 3× at 1 s intervals via gdb, alternating between bpf_map_get_next_key and bpf_map_lookup_elem at ebpf_fd.c:770-774, never exiting the loop.
  • (gdb) p ebpf_stat_values{total = 32768, current = 32768}.
  • ps fax | wc -l → 1,307. Roughly 31,461 slots (96%) were stale.
  • /dev/shm/netdata_shm_integration_ebpf = 14 MB = 32,768 × sizeof(netdata_ebpf_pid_stats_t).

Root cause

netdata_ebpf_get_shm_pointer_unsafe() is an allocate-AND-set-bit API, but it is called from two incompatible contexts:

  1. BPF-map iteration (ebpf_read_*_apps_table) — paired with netdata_ebpf_reset_shm_pointer_unsafe() via kill(pid, 0) == ESRCH. Correct producer/consumer.
  2. Apps & cgroup aggregation helpers (*_sum_pids, *_update_*_cgroup, *_resume_apps_data) — iterate live-PID lists built from /proc and cgroup snapshots and call the same allocating accessor. No reset path.

Every second, for every live PID seen in /proc or a tracked cgroup, every enabled eBPF module unconditionally sets its bit in that PID's shm slot. When the PID later dies:

  • The module's BPF-map iteration clears its bit and deletes the PID from the kernel BPF map in the same cycle.
  • The next cycle's iteration can no longer find that PID in its BPF map, so the bit cannot be cleared again if another aggregation path re-sets it.
  • On the same cycle the *_sum_pids / *_update_*_cgroup helpers immediately re-set the bit for the dying PID (still present in the apps/cgroup list for one more moment).
  • Result: bits accumulate, threads never reaches 0, ebpf_find_pid_shm_del_unsafe never decrements current, slots pin forever.

Once the pool is full, get_shm_pointer_unsafe() returns NULL. The eight apps-read loops had a bare continue; after the NULL check, skipping key = next_key;, so bpf_map_get_next_key() re-returned the same key forever and the thread spun at 100% CPU.

Changes

Fix What Why
A Split alloc from lookup. New netdata_ebpf_lookup_shm_pointer_unsafe(pid) returns slot or NULL with no allocation and no bit-set. Replace get_shm_pointer_unsafe with the new function at 16 aggregation call sites in 8 modules. Keep get_shm_pointer_unsafe at the 8 BPF-map iteration sites. Aggregation paths also gate on the module bit being set. Removes the root cause: aggregation can no longer allocate or dirty slots it cannot clean up.
B Replace bare continue; with goto end_*_loop; in all 8 apps-read loops so key = next_key; always runs. Prevents the 100% CPU infinite loop even in the rare case the pool is legitimately exhausted.
C Zero freshly-allocated slots. ebpf_find_or_create_index_pid() now memsets the slot on the create branch. Compacted tails and PID reuse cannot leak stale bits or per-module counters into new occupants.
D Fix kill() error-check pattern. Five modules used if (kill(pid, 0)), which treats EPERM (cross-UID processes) as "process is dead". Switched to if (kill(pid, 0) == -1 && errno == ESRCH). Also applied to ebpf_parse_proc_files(). Prevents ebpf.plugin from erroneously deleting the kernel BPF map entry and zeroing shm data for live processes owned by other users.
E Collapse ebpf_reset_specific_pid_data() to ebpf_del_pid_entry(pid) and remove unused ebpf_release_pid_data. thread_collecting only ever has NETDATA_EBPF_PIDS_PROC_FILE set, so the idx < PROC_FILE switch was unreachable.
F New netdata_ebpf_sweep_shm_for_module_unsafe(idx) called in every *_exit under shm_mutex_ebpf_integration. On module shutdown / runtime disable, the shm bits this module set are cleared, so its slots can be reclaimed instead of staying pinned.
G Zero the whole mapped shm region on init + shm_unlink and sem_unlink on cleanup (and sem_unlink before sem_open on init). shm_open(O_CREAT) on an existing object neither truncates nor zeros it, and sem_open(O_CREAT) ignores the initial value when the named semaphore already exists. Without this, a crashed previous instance could leave the semaphore at 0 and the next run would spin on sem_timedwait timeouts, or keep ghost PID data in the unused [current, total) range of /dev/shm.
H In get_shm_pointer_unsafe(), drop the redundant current >= total early guard. ebpf_find_or_create_index_pid() already returns existing slots unconditionally and only rejects new allocations when the pool is full. The old guard made already-tracked PIDs unreachable the moment the pool filled, blocking module-bit updates/clears. (Copilot review.)
I Socket apps iteration: move bpf_map_delete_elem(fd, &key) out of the if (local_pid) { ... } branch; it is conditioned only on the socket's own deleted signal. With a full shm pool, local_pid == NULL caused dead socket tuples to stay in the kernel map forever, growing unbounded. (Copilot review.)

Test plan

  • cmake --build build --target ebpf.plugin on Arch Linux / gcc 15, builds clean. No new warnings in the changed files (only the pre-existing vendored-Judy -Wstringop-overflow warnings remain).
  • Live-install verification: shm counter on the new binary settles proportional to BPF-observed PIDs (e.g. 261 slots used vs 1,269 live PIDs on the reporter's workstation), instead of growing monotonically toward 32,768.
  • Long-run soak test on a production-like host.
  • Verify no regression in eBPF apps/cgroup charts under collect pid = real parent (default) and collect pid = all modes.
  • Verify module restart (e.g. toggle fd off then on via config) leaves ebpf_stat_values.current consistent.

Notes

  • Root-cause analysis independently confirmed by five separate reviewers (codex gpt-5.4, GLM-5.1, Qwen3.5-Plus, MiniMax-M2.7, an opus-class agent) all converging on the alloc-without-reset asymmetry.
  • Copilot raised five post-opening review points; all have been addressed: unused bool *created out-param removed; early current >= total guard removed from get_shm_pointer_unsafe; sem_unlink added to cleanup and pre-open; socket kernel-map delete moved out of the local_pid branch; this notes section now reflects that shm_unlink/sem_unlink are part of this PR (originally deferred, then rolled in as part of fix G).

…spin

The eBPF plugin's per-PID shared-memory pool (32,768 slots backing
/dev/shm/netdata_shm_integration_ebpf) monotonically fills on any host
with normal process churn, and once the pool is exhausted each module's
apps-read loop spins one CPU core at 100% indefinitely.

ROOT CAUSE

netdata_ebpf_get_shm_pointer_unsafe() is an allocate-AND-set-bit API,
but it is called from two incompatible contexts:

  1. The per-module BPF map iteration (ebpf_read_*_apps_table), which
     is paired with netdata_ebpf_reset_shm_pointer_unsafe() via the
     kill(pid, 0) == ESRCH branch. This is a correct producer/consumer.

  2. The apps and cgroup aggregation helpers (*_sum_pids,
     *_update_*_cgroup, *_resume_apps_data), which iterate live-PID
     lists built from /proc and cgroup snapshots and call the same
     allocating accessor. These paths have NO reset counterpart.

Every second, for every live PID seen in /proc or a tracked cgroup,
every enabled eBPF module unconditionally sets its bit in the shm
slot for that PID. A dead PID's bit is only cleared by the owning
module's BPF-map iteration - but the BPF map's entry was already
deleted in the same cycle (by bpf_map_delete_elem inside
reset_shm_pointer_unsafe), so the next iteration cannot find the PID
and the bit is stuck forever. The slot therefore never reaches
threads == 0 and is never compacted out. Over 15h on a workstation
with ~1,300 live PIDs the pool reaches 100% with ~96% stale entries.

Once the pool is full, get_shm_pointer_unsafe() returns NULL. Every
apps_read loop had a bare `continue;` after the NULL check, which
skipped the `key = next_key;` advance, so bpf_map_get_next_key()
re-returned the same key forever and the thread spun at 100% CPU.

FIXES

  A. Split allocation from lookup. Introduce
     netdata_ebpf_lookup_shm_pointer_unsafe(pid) that returns the
     existing slot or NULL and sets no bit. Replace the 16 aggregation
     call sites with the new API. Keep get_shm_pointer_unsafe() at the
     8 BPF-map iteration call sites where allocation is legitimate and
     paired with reset. Aggregation paths additionally gate on the
     module bit being set so they do not consume zero-initialised
     per-module sub-structs.

  B. Replace `continue;` with `goto end_*_loop;` in all 8 apps-read
     loops so `key = next_key;` always runs, preventing the infinite
     loop even when the pool is legitimately exhausted.

  C. Zero freshly-allocated slots. ebpf_find_or_create_index_pid()
     now memset()s the slot on the create branch, so compacted tails
     and PID reuse cannot leak stale bits or stale per-module
     counters into new occupants.

  D. Use the correct kill() error check. Five modules used
     `if (kill(pid, 0))`, which treats EPERM (cross-UID processes) as
     "process is dead" and erroneously deletes the live process's
     kernel BPF map entry and zeroes its shm data. Switched all of
     them to `if (kill(pid, 0) == -1 && errno == ESRCH)`, matching
     the two modules that already did it correctly. Also applied to
     ebpf_parse_proc_files().

  E. Collapse ebpf_reset_specific_pid_data() to its effective body.
     thread_collecting only ever has NETDATA_EBPF_PIDS_PROC_FILE set,
     so the `idx < PROC_FILE` switch was dead code. The function now
     just forwards to ebpf_del_pid_entry(). The only other reader of
     that bitmask, ebpf_release_pid_data(), had zero callers and was
     removed.

  F. Sweep the shm pool on module exit. New helper
     netdata_ebpf_sweep_shm_for_module_unsafe() clears this module's
     bit across every shm slot and lets the existing compaction path
     release slots that become empty. All eight per-PID modules now
     call it under the shm semaphore in their *_exit path, so runtime
     module restarts no longer leak bits.

VERIFICATION

  - Root cause was captured on a live ebpf.plugin pinned at 64% CPU.
    gdb showed an EBPF_READ_FD thread permanently alternating
    bpf_map_get_next_key/bpf_map_lookup_elem at ebpf_fd.c:770-774,
    and ebpf_stat_values = {total = 32768, current = 32768} while
    ps showed 1,307 live PIDs.

  - The diagnosis was independently confirmed by a parallel review
    (codex, qwen, glm, minimax, opus), all converging on the
    alloc-without-reset asymmetry in the aggregation paths.

  - The modified tree builds cleanly (`cmake --build build --target
    ebpf.plugin`). No new warnings in the changed files; only the
    pre-existing vendored-Judy stringop-overflow warnings remain.
@ktsaou ktsaou requested a review from thiagoftsm as a code owner April 20, 2026 09:19
@github-actions github-actions Bot added area/collectors Everything related to data collection collectors/ebpf labels Apr 20, 2026
@ktsaou ktsaou requested review from Copilot and stelfrag April 20, 2026 09:25

@cubic-dev-ai cubic-dev-ai Bot 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.

No issues found across 12 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

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

Fixes ebpf.plugin per-PID shared-memory pool exhaustion (stale slots pinned indefinitely) and the resulting 100% CPU spin during BPF-map iteration by separating allocation from lookup, ensuring iteration always advances keys, and adding module-bit cleanup on module shutdown.

Changes:

  • Introduces a non-allocating PID shm accessor (netdata_ebpf_lookup_shm_pointer_unsafe) and updates aggregation paths to avoid allocating/pinning shm slots.
  • Prevents infinite loops in apps-read iterations by ensuring the map-iteration key always advances even when shm lookup/allocation fails.
  • Improves shm slot lifecycle correctness (zero newly allocated slots, correct kill(pid, 0) ESRCH checks, sweep module bits on exit, remove unreachable PID reset logic).

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/collectors/ebpf.plugin/ebpf_vfs.c Uses lookup-only shm access in aggregation, fixes iteration control-flow, adds module-bit sweep on exit, corrects ESRCH handling.
src/collectors/ebpf.plugin/ebpf_swap.c Same shm lookup/bit-gating + loop fix + exit sweep + ESRCH correction for swap module.
src/collectors/ebpf.plugin/ebpf_socket.c Fixes loop advance logic in socket PID iteration, uses lookup-only shm access for aggregation/cgroup, adds exit sweep.
src/collectors/ebpf.plugin/ebpf_shm.c Switches aggregation to lookup-only shm access, fixes loop control-flow, adds exit sweep, corrects ESRCH handling.
src/collectors/ebpf.plugin/ebpf_process.c Uses lookup-only shm access for aggregation, fixes loop control-flow, adds exit sweep, corrects ESRCH handling.
src/collectors/ebpf.plugin/ebpf_fd.c Uses lookup-only shm access for aggregation, fixes loop control-flow, adds exit sweep.
src/collectors/ebpf.plugin/ebpf_dcstat.c Uses lookup-only shm access for aggregation, fixes loop control-flow, adds exit sweep.
src/collectors/ebpf.plugin/ebpf_cachestat.c Uses lookup-only shm access for aggregation, fixes loop control-flow, adds exit sweep, corrects ESRCH handling.
src/collectors/ebpf.plugin/ebpf_apps.h Removes unreachable per-module PID cleanup logic and collapses to effective behavior.
src/collectors/ebpf.plugin/ebpf_apps.c Fixes kill(pid, 0) handling to only treat ESRCH as “dead PID”.
src/collectors/collectors-ipc/ebpf-ipc.h Adds declarations for lookup-only shm accessor and module-bit sweep helper.
src/collectors/collectors-ipc/ebpf-ipc.c Implements lookup-only shm accessor, module-bit sweep, and zeroing of newly allocated shm slots.

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

Comment thread src/collectors/collectors-ipc/ebpf-ipc.c Outdated
Close two residual issues around the per-PID shared-memory integration
pool, spotted post-merge:

1. The POSIX shm object `netdata_shm_integration_ebpf` was neither
   truncated on open nor unlinked on shutdown. `shm_open(..., O_CREAT)`
   on an existing object just reopens it, and `ftruncate` to the
   previous size is a no-op, so the 14 MB mapped region retained the
   previous plugin run's bytes. The slot-allocation path memsets each
   new slot, so this was not a correctness bug — but every slot in
   `[current, total)` kept stale PID numbers and per-module counters
   until system reboot. Now:
   - `netdata_integration_initialize_shm()` memsets the whole mapped
     region immediately after `nd_mmap`, so the next plugin instance
     always starts from a clean slab.
   - `netdata_integration_cleanup_shm()` calls `shm_unlink` so a
     clean shutdown removes the backing object from `/dev/shm`.

2. Drop the unused `bool *created` out-parameter on
   `ebpf_find_or_create_index_pid()`. It was added when the memset
   was factored out of the allocation path; since the memset now
   lives inside the function, no caller needs to know whether the
   slot was freshly allocated. Reported by Copilot on PR netdata#22232.
@ktsaou

ktsaou commented Apr 20, 2026

Copy link
Copy Markdown
Member Author

Addressed the Copilot review comment:

  • Dropped the unused bool *created out-parameter from ebpf_find_or_create_index_pid() (commit 11b2c8e). The memset stays inside the function on the create branch, so no caller needs to know whether the slot was freshly allocated.

Also closes the POSIX shm hygiene gap flagged during post-merge review (same commit):

  • netdata_integration_initialize_shm() now memsets the whole mmap'd region right after nd_mmap, so an inherited /dev/shm/netdata_shm_integration_ebpf from a previous plugin run never leaks bytes into the new run's slots at [current, total).
  • netdata_integration_cleanup_shm() now calls shm_unlink so a clean shutdown removes the backing object from /dev/shm instead of persisting 14 MB of stale data until reboot.

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

Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.


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

Comment thread src/collectors/collectors-ipc/ebpf-ipc.c
Comment thread src/collectors/collectors-ipc/ebpf-ipc.c
Comment thread src/collectors/collectors-ipc/ebpf-ipc.c Outdated
Comment thread src/collectors/ebpf.plugin/ebpf_socket.c Outdated
Four residual issues spotted by Copilot after the initial round of
changes. All valid.

1. get_shm_pointer_unsafe() no longer bails out when the pool is full.
   The caller path for an already-tracked PID must still reach its
   slot so module bits can be updated or cleared. The inner
   ebpf_find_or_create_index_pid() already returns existing slots
   unconditionally and only rejects *new* allocations when full, so
   the redundant outer guard was both dead weight and actively
   harmful — it made existing PIDs unreachable the moment the pool
   filled.

2. Semaphore lifecycle. netdata_integration_initialize_shm() now
   sem_unlinks NETDATA_EBPF_SHM_INTEGRATION_NAME before sem_open, and
   netdata_integration_cleanup_shm() sem_unlinks on the way out. If
   a previous plugin instance crashed while holding the semaphore,
   sem_open(O_CREAT) on the same name would reuse the existing
   semaphore at its last value (O_CREAT's initial-value argument is
   ignored when the semaphore already exists), and the next run
   would spin on sem_timedwait() timeouts forever. Unlinking
   guarantees the initial value of 1 is honoured.

3. Socket apps iteration: the kernel-map delete for a dead socket
   tuple used to run inside `if (local_pid) { ... }`. When the shm
   pool is full and local_pid is NULL, `deleted` sockets were left
   in the BPF map to be re-iterated every cycle, growing the map
   unbounded. Moved bpf_map_delete_elem(fd, &key) outside the
   local_pid branch; it is conditioned only on `deleted`, which
   captures the socket's own freshness signal and is independent of
   shm pool state. (For SOCKET_IDX the generic
   reset_shm_pointer_unsafe() intentionally skips the delete, since
   the socket map is keyed by a tuple, not by pid — comment added.)
@ktsaou

ktsaou commented Apr 20, 2026

Copy link
Copy Markdown
Member Author

Addressed all four Copilot review comments in commit bf9ffddccb:

  • ebpf-ipc.c:117 — current >= total early guard in get_shm_pointer_unsafe. Removed. The inner ebpf_find_or_create_index_pid() already returns existing slots unconditionally and only rejects new allocations when the pool is full, so the outer guard was dead weight and actively harmful — it made already-tracked PIDs unreachable the moment the pool filled. Good catch.

  • ebpf-ipc.c:181 — sem_unlink missing. netdata_integration_cleanup_shm() now calls sem_unlink(NETDATA_EBPF_SHM_INTEGRATION_NAME) alongside the shm_unlink, and netdata_integration_initialize_shm() also sem_unlinks before sem_open so a previously-crashed instance that left the semaphore at 0 can't stall the next run on sem_timedwait timeouts.

  • ebpf-ipc.c:180 — PR description. Updated to reflect that shm_unlink/sem_unlink are part of this PR (originally deferred, rolled into fix G).

  • ebpf_socket.c:1910 — socket map delete stuck inside if (local_pid). Moved bpf_map_delete_elem(fd, &key) out of the local_pid branch; it is now conditioned only on the socket's own deleted freshness signal. With a full shm pool this prevents dead socket tuples from piling up in the kernel map.

@thiagoftsm thiagoftsm 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.

After few hours running PR did not present issues. LGTM!

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

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.


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

@stelfrag stelfrag merged commit bd38550 into netdata:master Apr 21, 2026
156 checks passed
stelfrag pushed a commit to stelfrag/netdata that referenced this pull request Apr 22, 2026
…spin (netdata#22232)

* ebpf.plugin: fix PID accounting shared-memory pool leak and 100% CPU spin

The eBPF plugin's per-PID shared-memory pool (32,768 slots backing
/dev/shm/netdata_shm_integration_ebpf) monotonically fills on any host
with normal process churn, and once the pool is exhausted each module's
apps-read loop spins one CPU core at 100% indefinitely.

ROOT CAUSE

netdata_ebpf_get_shm_pointer_unsafe() is an allocate-AND-set-bit API,
but it is called from two incompatible contexts:

  1. The per-module BPF map iteration (ebpf_read_*_apps_table), which
     is paired with netdata_ebpf_reset_shm_pointer_unsafe() via the
     kill(pid, 0) == ESRCH branch. This is a correct producer/consumer.

  2. The apps and cgroup aggregation helpers (*_sum_pids,
     *_update_*_cgroup, *_resume_apps_data), which iterate live-PID
     lists built from /proc and cgroup snapshots and call the same
     allocating accessor. These paths have NO reset counterpart.

Every second, for every live PID seen in /proc or a tracked cgroup,
every enabled eBPF module unconditionally sets its bit in the shm
slot for that PID. A dead PID's bit is only cleared by the owning
module's BPF-map iteration - but the BPF map's entry was already
deleted in the same cycle (by bpf_map_delete_elem inside
reset_shm_pointer_unsafe), so the next iteration cannot find the PID
and the bit is stuck forever. The slot therefore never reaches
threads == 0 and is never compacted out. Over 15h on a workstation
with ~1,300 live PIDs the pool reaches 100% with ~96% stale entries.

Once the pool is full, get_shm_pointer_unsafe() returns NULL. Every
apps_read loop had a bare `continue;` after the NULL check, which
skipped the `key = next_key;` advance, so bpf_map_get_next_key()
re-returned the same key forever and the thread spun at 100% CPU.

FIXES

  A. Split allocation from lookup. Introduce
     netdata_ebpf_lookup_shm_pointer_unsafe(pid) that returns the
     existing slot or NULL and sets no bit. Replace the 16 aggregation
     call sites with the new API. Keep get_shm_pointer_unsafe() at the
     8 BPF-map iteration call sites where allocation is legitimate and
     paired with reset. Aggregation paths additionally gate on the
     module bit being set so they do not consume zero-initialised
     per-module sub-structs.

  B. Replace `continue;` with `goto end_*_loop;` in all 8 apps-read
     loops so `key = next_key;` always runs, preventing the infinite
     loop even when the pool is legitimately exhausted.

  C. Zero freshly-allocated slots. ebpf_find_or_create_index_pid()
     now memset()s the slot on the create branch, so compacted tails
     and PID reuse cannot leak stale bits or stale per-module
     counters into new occupants.

  D. Use the correct kill() error check. Five modules used
     `if (kill(pid, 0))`, which treats EPERM (cross-UID processes) as
     "process is dead" and erroneously deletes the live process's
     kernel BPF map entry and zeroes its shm data. Switched all of
     them to `if (kill(pid, 0) == -1 && errno == ESRCH)`, matching
     the two modules that already did it correctly. Also applied to
     ebpf_parse_proc_files().

  E. Collapse ebpf_reset_specific_pid_data() to its effective body.
     thread_collecting only ever has NETDATA_EBPF_PIDS_PROC_FILE set,
     so the `idx < PROC_FILE` switch was dead code. The function now
     just forwards to ebpf_del_pid_entry(). The only other reader of
     that bitmask, ebpf_release_pid_data(), had zero callers and was
     removed.

  F. Sweep the shm pool on module exit. New helper
     netdata_ebpf_sweep_shm_for_module_unsafe() clears this module's
     bit across every shm slot and lets the existing compaction path
     release slots that become empty. All eight per-PID modules now
     call it under the shm semaphore in their *_exit path, so runtime
     module restarts no longer leak bits.

VERIFICATION

  - Root cause was captured on a live ebpf.plugin pinned at 64% CPU.
    gdb showed an EBPF_READ_FD thread permanently alternating
    bpf_map_get_next_key/bpf_map_lookup_elem at ebpf_fd.c:770-774,
    and ebpf_stat_values = {total = 32768, current = 32768} while
    ps showed 1,307 live PIDs.

  - The diagnosis was independently confirmed by a parallel review
    (codex, qwen, glm, minimax, opus), all converging on the
    alloc-without-reset asymmetry in the aggregation paths.

  - The modified tree builds cleanly (`cmake --build build --target
    ebpf.plugin`). No new warnings in the changed files; only the
    pre-existing vendored-Judy stringop-overflow warnings remain.

* ebpf.plugin: wipe shm on init, unlink on cleanup, drop unused out-param

Close two residual issues around the per-PID shared-memory integration
pool, spotted post-merge:

1. The POSIX shm object `netdata_shm_integration_ebpf` was neither
   truncated on open nor unlinked on shutdown. `shm_open(..., O_CREAT)`
   on an existing object just reopens it, and `ftruncate` to the
   previous size is a no-op, so the 14 MB mapped region retained the
   previous plugin run's bytes. The slot-allocation path memsets each
   new slot, so this was not a correctness bug — but every slot in
   `[current, total)` kept stale PID numbers and per-module counters
   until system reboot. Now:
   - `netdata_integration_initialize_shm()` memsets the whole mapped
     region immediately after `nd_mmap`, so the next plugin instance
     always starts from a clean slab.
   - `netdata_integration_cleanup_shm()` calls `shm_unlink` so a
     clean shutdown removes the backing object from `/dev/shm`.

2. Drop the unused `bool *created` out-parameter on
   `ebpf_find_or_create_index_pid()`. It was added when the memset
   was factored out of the allocation path; since the memset now
   lives inside the function, no caller needs to know whether the
   slot was freshly allocated. Reported by Copilot on PR netdata#22232.

* ebpf.plugin: address Copilot findings on 22232

Four residual issues spotted by Copilot after the initial round of
changes. All valid.

1. get_shm_pointer_unsafe() no longer bails out when the pool is full.
   The caller path for an already-tracked PID must still reach its
   slot so module bits can be updated or cleared. The inner
   ebpf_find_or_create_index_pid() already returns existing slots
   unconditionally and only rejects *new* allocations when full, so
   the redundant outer guard was both dead weight and actively
   harmful — it made existing PIDs unreachable the moment the pool
   filled.

2. Semaphore lifecycle. netdata_integration_initialize_shm() now
   sem_unlinks NETDATA_EBPF_SHM_INTEGRATION_NAME before sem_open, and
   netdata_integration_cleanup_shm() sem_unlinks on the way out. If
   a previous plugin instance crashed while holding the semaphore,
   sem_open(O_CREAT) on the same name would reuse the existing
   semaphore at its last value (O_CREAT's initial-value argument is
   ignored when the semaphore already exists), and the next run
   would spin on sem_timedwait() timeouts forever. Unlinking
   guarantees the initial value of 1 is honoured.

3. Socket apps iteration: the kernel-map delete for a dead socket
   tuple used to run inside `if (local_pid) { ... }`. When the shm
   pool is full and local_pid is NULL, `deleted` sockets were left
   in the BPF map to be re-iterated every cycle, growing the map
   unbounded. Moved bpf_map_delete_elem(fd, &key) outside the
   local_pid branch; it is conditioned only on `deleted`, which
   captures the socket's own freshness signal and is independent of
   shm pool state. (For SOCKET_IDX the generic
   reset_shm_pointer_unsafe() intentionally skips the delete, since
   the socket map is keyed by a tuple, not by pid — comment added.)

(cherry picked from commit bd38550)
@stelfrag stelfrag mentioned this pull request Apr 22, 2026
nedi-app Bot pushed a commit that referenced this pull request Apr 24, 2026
…spin (#22232)

* ebpf.plugin: fix PID accounting shared-memory pool leak and 100% CPU spin

The eBPF plugin's per-PID shared-memory pool (32,768 slots backing
/dev/shm/netdata_shm_integration_ebpf) monotonically fills on any host
with normal process churn, and once the pool is exhausted each module's
apps-read loop spins one CPU core at 100% indefinitely.

ROOT CAUSE

netdata_ebpf_get_shm_pointer_unsafe() is an allocate-AND-set-bit API,
but it is called from two incompatible contexts:

  1. The per-module BPF map iteration (ebpf_read_*_apps_table), which
     is paired with netdata_ebpf_reset_shm_pointer_unsafe() via the
     kill(pid, 0) == ESRCH branch. This is a correct producer/consumer.

  2. The apps and cgroup aggregation helpers (*_sum_pids,
     *_update_*_cgroup, *_resume_apps_data), which iterate live-PID
     lists built from /proc and cgroup snapshots and call the same
     allocating accessor. These paths have NO reset counterpart.

Every second, for every live PID seen in /proc or a tracked cgroup,
every enabled eBPF module unconditionally sets its bit in the shm
slot for that PID. A dead PID's bit is only cleared by the owning
module's BPF-map iteration - but the BPF map's entry was already
deleted in the same cycle (by bpf_map_delete_elem inside
reset_shm_pointer_unsafe), so the next iteration cannot find the PID
and the bit is stuck forever. The slot therefore never reaches
threads == 0 and is never compacted out. Over 15h on a workstation
with ~1,300 live PIDs the pool reaches 100% with ~96% stale entries.

Once the pool is full, get_shm_pointer_unsafe() returns NULL. Every
apps_read loop had a bare `continue;` after the NULL check, which
skipped the `key = next_key;` advance, so bpf_map_get_next_key()
re-returned the same key forever and the thread spun at 100% CPU.

FIXES

  A. Split allocation from lookup. Introduce
     netdata_ebpf_lookup_shm_pointer_unsafe(pid) that returns the
     existing slot or NULL and sets no bit. Replace the 16 aggregation
     call sites with the new API. Keep get_shm_pointer_unsafe() at the
     8 BPF-map iteration call sites where allocation is legitimate and
     paired with reset. Aggregation paths additionally gate on the
     module bit being set so they do not consume zero-initialised
     per-module sub-structs.

  B. Replace `continue;` with `goto end_*_loop;` in all 8 apps-read
     loops so `key = next_key;` always runs, preventing the infinite
     loop even when the pool is legitimately exhausted.

  C. Zero freshly-allocated slots. ebpf_find_or_create_index_pid()
     now memset()s the slot on the create branch, so compacted tails
     and PID reuse cannot leak stale bits or stale per-module
     counters into new occupants.

  D. Use the correct kill() error check. Five modules used
     `if (kill(pid, 0))`, which treats EPERM (cross-UID processes) as
     "process is dead" and erroneously deletes the live process's
     kernel BPF map entry and zeroes its shm data. Switched all of
     them to `if (kill(pid, 0) == -1 && errno == ESRCH)`, matching
     the two modules that already did it correctly. Also applied to
     ebpf_parse_proc_files().

  E. Collapse ebpf_reset_specific_pid_data() to its effective body.
     thread_collecting only ever has NETDATA_EBPF_PIDS_PROC_FILE set,
     so the `idx < PROC_FILE` switch was dead code. The function now
     just forwards to ebpf_del_pid_entry(). The only other reader of
     that bitmask, ebpf_release_pid_data(), had zero callers and was
     removed.

  F. Sweep the shm pool on module exit. New helper
     netdata_ebpf_sweep_shm_for_module_unsafe() clears this module's
     bit across every shm slot and lets the existing compaction path
     release slots that become empty. All eight per-PID modules now
     call it under the shm semaphore in their *_exit path, so runtime
     module restarts no longer leak bits.

VERIFICATION

  - Root cause was captured on a live ebpf.plugin pinned at 64% CPU.
    gdb showed an EBPF_READ_FD thread permanently alternating
    bpf_map_get_next_key/bpf_map_lookup_elem at ebpf_fd.c:770-774,
    and ebpf_stat_values = {total = 32768, current = 32768} while
    ps showed 1,307 live PIDs.

  - The diagnosis was independently confirmed by a parallel review
    (codex, qwen, glm, minimax, opus), all converging on the
    alloc-without-reset asymmetry in the aggregation paths.

  - The modified tree builds cleanly (`cmake --build build --target
    ebpf.plugin`). No new warnings in the changed files; only the
    pre-existing vendored-Judy stringop-overflow warnings remain.

* ebpf.plugin: wipe shm on init, unlink on cleanup, drop unused out-param

Close two residual issues around the per-PID shared-memory integration
pool, spotted post-merge:

1. The POSIX shm object `netdata_shm_integration_ebpf` was neither
   truncated on open nor unlinked on shutdown. `shm_open(..., O_CREAT)`
   on an existing object just reopens it, and `ftruncate` to the
   previous size is a no-op, so the 14 MB mapped region retained the
   previous plugin run's bytes. The slot-allocation path memsets each
   new slot, so this was not a correctness bug — but every slot in
   `[current, total)` kept stale PID numbers and per-module counters
   until system reboot. Now:
   - `netdata_integration_initialize_shm()` memsets the whole mapped
     region immediately after `nd_mmap`, so the next plugin instance
     always starts from a clean slab.
   - `netdata_integration_cleanup_shm()` calls `shm_unlink` so a
     clean shutdown removes the backing object from `/dev/shm`.

2. Drop the unused `bool *created` out-parameter on
   `ebpf_find_or_create_index_pid()`. It was added when the memset
   was factored out of the allocation path; since the memset now
   lives inside the function, no caller needs to know whether the
   slot was freshly allocated. Reported by Copilot on PR #22232.

* ebpf.plugin: address Copilot findings on 22232

Four residual issues spotted by Copilot after the initial round of
changes. All valid.

1. get_shm_pointer_unsafe() no longer bails out when the pool is full.
   The caller path for an already-tracked PID must still reach its
   slot so module bits can be updated or cleared. The inner
   ebpf_find_or_create_index_pid() already returns existing slots
   unconditionally and only rejects *new* allocations when full, so
   the redundant outer guard was both dead weight and actively
   harmful — it made existing PIDs unreachable the moment the pool
   filled.

2. Semaphore lifecycle. netdata_integration_initialize_shm() now
   sem_unlinks NETDATA_EBPF_SHM_INTEGRATION_NAME before sem_open, and
   netdata_integration_cleanup_shm() sem_unlinks on the way out. If
   a previous plugin instance crashed while holding the semaphore,
   sem_open(O_CREAT) on the same name would reuse the existing
   semaphore at its last value (O_CREAT's initial-value argument is
   ignored when the semaphore already exists), and the next run
   would spin on sem_timedwait() timeouts forever. Unlinking
   guarantees the initial value of 1 is honoured.

3. Socket apps iteration: the kernel-map delete for a dead socket
   tuple used to run inside `if (local_pid) { ... }`. When the shm
   pool is full and local_pid is NULL, `deleted` sockets were left
   in the BPF map to be re-iterated every cycle, growing the map
   unbounded. Moved bpf_map_delete_elem(fd, &key) outside the
   local_pid branch; it is conditioned only on `deleted`, which
   captures the socket's own freshness signal and is independent of
   shm pool state. (For SOCKET_IDX the generic
   reset_shm_pointer_unsafe() intentionally skips the delete, since
   the socket map is keyed by a tuple, not by pid — comment added.)
Ferroin pushed a commit that referenced this pull request Apr 27, 2026
…spin (#22232)

* ebpf.plugin: fix PID accounting shared-memory pool leak and 100% CPU spin

The eBPF plugin's per-PID shared-memory pool (32,768 slots backing
/dev/shm/netdata_shm_integration_ebpf) monotonically fills on any host
with normal process churn, and once the pool is exhausted each module's
apps-read loop spins one CPU core at 100% indefinitely.

ROOT CAUSE

netdata_ebpf_get_shm_pointer_unsafe() is an allocate-AND-set-bit API,
but it is called from two incompatible contexts:

  1. The per-module BPF map iteration (ebpf_read_*_apps_table), which
     is paired with netdata_ebpf_reset_shm_pointer_unsafe() via the
     kill(pid, 0) == ESRCH branch. This is a correct producer/consumer.

  2. The apps and cgroup aggregation helpers (*_sum_pids,
     *_update_*_cgroup, *_resume_apps_data), which iterate live-PID
     lists built from /proc and cgroup snapshots and call the same
     allocating accessor. These paths have NO reset counterpart.

Every second, for every live PID seen in /proc or a tracked cgroup,
every enabled eBPF module unconditionally sets its bit in the shm
slot for that PID. A dead PID's bit is only cleared by the owning
module's BPF-map iteration - but the BPF map's entry was already
deleted in the same cycle (by bpf_map_delete_elem inside
reset_shm_pointer_unsafe), so the next iteration cannot find the PID
and the bit is stuck forever. The slot therefore never reaches
threads == 0 and is never compacted out. Over 15h on a workstation
with ~1,300 live PIDs the pool reaches 100% with ~96% stale entries.

Once the pool is full, get_shm_pointer_unsafe() returns NULL. Every
apps_read loop had a bare `continue;` after the NULL check, which
skipped the `key = next_key;` advance, so bpf_map_get_next_key()
re-returned the same key forever and the thread spun at 100% CPU.

FIXES

  A. Split allocation from lookup. Introduce
     netdata_ebpf_lookup_shm_pointer_unsafe(pid) that returns the
     existing slot or NULL and sets no bit. Replace the 16 aggregation
     call sites with the new API. Keep get_shm_pointer_unsafe() at the
     8 BPF-map iteration call sites where allocation is legitimate and
     paired with reset. Aggregation paths additionally gate on the
     module bit being set so they do not consume zero-initialised
     per-module sub-structs.

  B. Replace `continue;` with `goto end_*_loop;` in all 8 apps-read
     loops so `key = next_key;` always runs, preventing the infinite
     loop even when the pool is legitimately exhausted.

  C. Zero freshly-allocated slots. ebpf_find_or_create_index_pid()
     now memset()s the slot on the create branch, so compacted tails
     and PID reuse cannot leak stale bits or stale per-module
     counters into new occupants.

  D. Use the correct kill() error check. Five modules used
     `if (kill(pid, 0))`, which treats EPERM (cross-UID processes) as
     "process is dead" and erroneously deletes the live process's
     kernel BPF map entry and zeroes its shm data. Switched all of
     them to `if (kill(pid, 0) == -1 && errno == ESRCH)`, matching
     the two modules that already did it correctly. Also applied to
     ebpf_parse_proc_files().

  E. Collapse ebpf_reset_specific_pid_data() to its effective body.
     thread_collecting only ever has NETDATA_EBPF_PIDS_PROC_FILE set,
     so the `idx < PROC_FILE` switch was dead code. The function now
     just forwards to ebpf_del_pid_entry(). The only other reader of
     that bitmask, ebpf_release_pid_data(), had zero callers and was
     removed.

  F. Sweep the shm pool on module exit. New helper
     netdata_ebpf_sweep_shm_for_module_unsafe() clears this module's
     bit across every shm slot and lets the existing compaction path
     release slots that become empty. All eight per-PID modules now
     call it under the shm semaphore in their *_exit path, so runtime
     module restarts no longer leak bits.

VERIFICATION

  - Root cause was captured on a live ebpf.plugin pinned at 64% CPU.
    gdb showed an EBPF_READ_FD thread permanently alternating
    bpf_map_get_next_key/bpf_map_lookup_elem at ebpf_fd.c:770-774,
    and ebpf_stat_values = {total = 32768, current = 32768} while
    ps showed 1,307 live PIDs.

  - The diagnosis was independently confirmed by a parallel review
    (codex, qwen, glm, minimax, opus), all converging on the
    alloc-without-reset asymmetry in the aggregation paths.

  - The modified tree builds cleanly (`cmake --build build --target
    ebpf.plugin`). No new warnings in the changed files; only the
    pre-existing vendored-Judy stringop-overflow warnings remain.

* ebpf.plugin: wipe shm on init, unlink on cleanup, drop unused out-param

Close two residual issues around the per-PID shared-memory integration
pool, spotted post-merge:

1. The POSIX shm object `netdata_shm_integration_ebpf` was neither
   truncated on open nor unlinked on shutdown. `shm_open(..., O_CREAT)`
   on an existing object just reopens it, and `ftruncate` to the
   previous size is a no-op, so the 14 MB mapped region retained the
   previous plugin run's bytes. The slot-allocation path memsets each
   new slot, so this was not a correctness bug — but every slot in
   `[current, total)` kept stale PID numbers and per-module counters
   until system reboot. Now:
   - `netdata_integration_initialize_shm()` memsets the whole mapped
     region immediately after `nd_mmap`, so the next plugin instance
     always starts from a clean slab.
   - `netdata_integration_cleanup_shm()` calls `shm_unlink` so a
     clean shutdown removes the backing object from `/dev/shm`.

2. Drop the unused `bool *created` out-parameter on
   `ebpf_find_or_create_index_pid()`. It was added when the memset
   was factored out of the allocation path; since the memset now
   lives inside the function, no caller needs to know whether the
   slot was freshly allocated. Reported by Copilot on PR #22232.

* ebpf.plugin: address Copilot findings on 22232

Four residual issues spotted by Copilot after the initial round of
changes. All valid.

1. get_shm_pointer_unsafe() no longer bails out when the pool is full.
   The caller path for an already-tracked PID must still reach its
   slot so module bits can be updated or cleared. The inner
   ebpf_find_or_create_index_pid() already returns existing slots
   unconditionally and only rejects *new* allocations when full, so
   the redundant outer guard was both dead weight and actively
   harmful — it made existing PIDs unreachable the moment the pool
   filled.

2. Semaphore lifecycle. netdata_integration_initialize_shm() now
   sem_unlinks NETDATA_EBPF_SHM_INTEGRATION_NAME before sem_open, and
   netdata_integration_cleanup_shm() sem_unlinks on the way out. If
   a previous plugin instance crashed while holding the semaphore,
   sem_open(O_CREAT) on the same name would reuse the existing
   semaphore at its last value (O_CREAT's initial-value argument is
   ignored when the semaphore already exists), and the next run
   would spin on sem_timedwait() timeouts forever. Unlinking
   guarantees the initial value of 1 is honoured.

3. Socket apps iteration: the kernel-map delete for a dead socket
   tuple used to run inside `if (local_pid) { ... }`. When the shm
   pool is full and local_pid is NULL, `deleted` sockets were left
   in the BPF map to be re-iterated every cycle, growing the map
   unbounded. Moved bpf_map_delete_elem(fd, &key) outside the
   local_pid branch; it is conditioned only on `deleted`, which
   captures the socket's own freshness signal and is independent of
   shm pool state. (For SOCKET_IDX the generic
   reset_shm_pointer_unsafe() intentionally skips the delete, since
   the socket map is keyed by a tuple, not by pid — comment added.)

(cherry picked from commit bd38550)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/collectors Everything related to data collection collectors/ebpf

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants