Skip to content

Commit 3ca07b8

Browse files
authored
Merge pull request #37 from cacheMon/claude/intelligent-euler-01hyrq
Fix unbounded cache growth, writer races, and dropped uploads; add unit tests
2 parents 8edb105 + f076dc1 commit 3ca07b8

8 files changed

Lines changed: 493 additions & 120 deletions

File tree

.github/workflows/unit-tests.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Unit Tests
2+
3+
# Runs the pure-Python unit tests (FlagMapper, PathResolver, utils). These have
4+
# no bcc/kernel dependency, so unlike the BPF compile job they run on any
5+
# runner without privileges.
6+
7+
on:
8+
push:
9+
paths:
10+
- 'src/**'
11+
- 'tests/**'
12+
- '.github/workflows/unit-tests.yml'
13+
pull_request:
14+
paths:
15+
- 'src/**'
16+
- 'tests/**'
17+
- '.github/workflows/unit-tests.yml'
18+
workflow_dispatch:
19+
20+
jobs:
21+
unit-tests:
22+
runs-on: ubuntu-latest
23+
steps:
24+
- uses: actions/checkout@v4
25+
26+
- uses: actions/setup-python@v5
27+
with:
28+
python-version: '3.11'
29+
30+
- name: Run unit tests
31+
run: python3 -m unittest discover -s tests -v

src/tracer/IOTracer.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,15 @@ def __init__(
142142
self.mmap_regions = {}
143143
self.cmdline_cache = {} # pid -> cmdline, populated on first successful read
144144

145+
# Bounded-cache maintenance. The path resolver and cmdline caches are
146+
# only evicted on PROCESS_EXEC, so over a long-running trace they would
147+
# otherwise grow without bound. Maintenance runs from the perf-callback
148+
# (polling) thread — the only thread that mutates these caches — so it
149+
# never races with event processing.
150+
self._event_count = 0
151+
self._maintenance_interval = 50000 # events between cache sweeps
152+
self._cmdline_cache_max = 100000 # hard cap on cmdline cache entries
153+
145154
if cache_sample_rate > 1:
146155
self.writer.set_cache_sampling(cache_sample_rate)
147156

@@ -191,9 +200,11 @@ def _print_event(self, cpu, data, size):
191200
data: Raw event data pointer
192201
size: Size of the event data
193202
"""
203+
self._tick_maintenance()
204+
194205
event = self.b["events"].event(data)
195206
op_name = self.flag_mapper.op_fs_types.get(event.op, "[unknown]")
196-
207+
197208
try:
198209
filename = event.filename.decode()
199210
if self.anonymous:
@@ -517,6 +528,46 @@ def _read_cmdline_cached(self, pid: int) -> str:
517528
self.cmdline_cache[pid] = result
518529
return result
519530

531+
def _tick_maintenance(self) -> None:
532+
"""
533+
Advance the event counter and run cache maintenance when it is due.
534+
535+
Called from every perf callback that runs on the polling thread and
536+
touches the long-lived caches (``_print_event``, ``_print_event_dual``,
537+
``_print_event_io_uring``) so a workload dominated by any single event
538+
family — e.g. rename/link/symlink (dual) or async io_uring I/O — still
539+
triggers eviction instead of growing the caches unbounded.
540+
"""
541+
self._event_count += 1
542+
if self._event_count % self._maintenance_interval == 0:
543+
self._run_cache_maintenance()
544+
545+
def _run_cache_maintenance(self) -> None:
546+
"""
547+
Bound the long-lived caches so an indefinite trace does not leak memory.
548+
549+
Runs from the perf-callback (polling) thread, which is the only thread
550+
that mutates ``cmdline_cache`` and the path resolver caches, so no
551+
locking is required.
552+
553+
- Delegates to ``PathResolver.cleanup_old_cache`` (otherwise never
554+
called), which prunes stale per-PID entries and caps ``inode_to_path``.
555+
- Caps ``cmdline_cache`` at ``_cmdline_cache_max`` entries. Entries are
556+
deliberately retained past process exit so that CLOSE/EXIT events
557+
buffered after the process is gone can still resolve a cmdline; when
558+
the cap is exceeded we drop the oldest half (dicts preserve insertion
559+
order), keeping the most recently seen PIDs.
560+
"""
561+
try:
562+
self.path_resolver.cleanup_old_cache()
563+
except Exception as e:
564+
if self.verbose:
565+
logger("warning", f"Path resolver cache cleanup failed: {e}")
566+
567+
if len(self.cmdline_cache) > self._cmdline_cache_max:
568+
items = list(self.cmdline_cache.items())
569+
self.cmdline_cache = dict(items[len(items) // 2:])
570+
520571
def _handle_process_exec(self, pid: int) -> None:
521572
"""
522573
Clear the mmap_regions and cmdline caches for a PID on exec.
@@ -551,6 +602,8 @@ def _print_event_dual(self, cpu, data, size):
551602
data: Raw event data pointer
552603
size: Size of the event data
553604
"""
605+
self._tick_maintenance()
606+
554607
event = self.b["events_dual"].event(data)
555608
op_name = self.flag_mapper.op_fs_types.get(event.op, "[unknown]")
556609

@@ -752,6 +805,8 @@ def _print_event_io_uring(self, cpu, data, size):
752805
data: Raw event data pointer
753806
size: Size of the event data
754807
"""
808+
self._tick_maintenance()
809+
755810
e = self.b["io_uring_events"].event(data)
756811
ts = datetime.today()
757812

@@ -1096,7 +1151,10 @@ def trace(self):
10961151
run_with_spinner("Compressing trace output", self.writer.force_flush)
10971152

10981153
if self.automatic_upload:
1099-
run_with_spinner("Uploading traces", lambda: self.upload_manager.stop_worker(False))
1154+
# Drain the upload queue before stopping the workers — passing
1155+
# False here set the stop event immediately and abandoned any
1156+
# traces still queued for upload.
1157+
run_with_spinner("Uploading traces", lambda: self.upload_manager.stop_worker(True, timeout=30))
11001158
try:
11011159
os.removedirs(self.writer.output_dir)
11021160
except OSError:

src/tracer/PathResolver.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,14 +95,14 @@ def update_process_files(self, pid: int) -> dict:
9595
files[inode] = target
9696
# Update global inode cache
9797
self.inode_to_path[inode] = target
98-
except:
98+
except OSError:
9999
continue
100-
100+
101101
self.pid_to_files[pid] = files
102102
self.last_update[pid] = current_time
103103
return files
104-
105-
except:
104+
105+
except OSError:
106106
return {}
107107

108108
def resolve_by_fd(self, pid: int, fd: int, inode: int = 0, filename: str = "") -> str:
@@ -234,8 +234,8 @@ def cleanup_old_cache(self):
234234
pids_to_remove.append(pid)
235235

236236
for pid in pids_to_remove:
237-
del self.pid_to_files[pid]
238-
del self.last_update[pid]
237+
self.pid_to_files.pop(pid, None)
238+
self.last_update.pop(pid, None)
239239

240240
# Optionally limit inode cache size
241241
if len(self.inode_to_path) > 10000:

0 commit comments

Comments
 (0)