From 9ba8ebd6e8acde40ed333ae1827ffd6f538e461f Mon Sep 17 00:00:00 2001 From: FtlC-ian Date: Fri, 6 Mar 2026 08:55:31 -0600 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20memory=20leak=20in=20parse=5Fpowerme?= =?UTF-8?q?trics=20=E2=80=94=20stop=20reading=20entire=20file=20every=20ti?= =?UTF-8?q?ck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Bug asitop leaks memory because `parse_powermetrics()` (called every second by default) reads the *entire* `/tmp/asitop_powermetrics{timecode}` file into memory on every call: data = fp.read() # reads entire file data = data.split(b'\x00') # splits entire contents into memory powermetrics_parse = plistlib.loads(data[-1]) # only last entry used `powermetrics` continuously appends new plist samples separated by null bytes (`\x00`) to that file. The file never gets truncated, so after days of running it can grow into the gigabyte range. Reading and splitting gigabytes of data every second causes the process RSS to balloon accordingly. **Observed:** 6 GB+ RSS on an M4 Mac mini after ~3 days of continuous use. ## Fix Seek to the end of the file and read *backwards* in 64 KB chunks until at least two null-byte separators are found. Only those final segments are ever loaded into memory, so memory usage stays constant regardless of how long asitop has been running. A 64 KB chunk is larger than any single powermetrics plist entry, so in practice only one or two iterations are needed to find the two most recent samples — keeping the per-tick I/O tiny and the in-process footprint bounded. The fallback to the second-to-last entry (needed when powermetrics is mid-write on the last entry) is preserved: we now iterate over segments from most-recent to oldest and return the first one that parses successfully. ## Not changed - `HChart.datapoints` in dashing already uses `deque(maxlen=500)`, so the chart lists are already bounded — no change needed there. - The powermetrics temp file itself continues to grow on disk (truncating it safely while powermetrics holds it open is complex and error-prone); the file lives in `/tmp` and is cleaned on reboot. --- asitop/utils.py | 88 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 65 insertions(+), 23 deletions(-) diff --git a/asitop/utils.py b/asitop/utils.py index 1e7b9ce..7056ace 100644 --- a/asitop/utils.py +++ b/asitop/utils.py @@ -8,32 +8,74 @@ def parse_powermetrics(path='/tmp/asitop_powermetrics', timecode="0"): - data = None + """Parse the most recent plist entry from the powermetrics output file. + + powermetrics appends plist entries separated by null bytes (\\x00) to a + continuously growing file. The previous implementation read the *entire* + file on every call (once per second by default), then split all contents + into memory. After multi-day runs the file grows into gigabytes, causing + asitop to consume 6 GB+ RSS (observed on an M4 Mac mini after ~3 days). + + This implementation seeks to the end of the file and reads *backwards* in + 64 KB chunks until it has found at least two null-byte-separated segments. + Only those final segments are loaded into memory, so memory usage stays + constant regardless of how long asitop has been running. + """ + CHUNK_SIZE = 65536 # 64 KB — larger than any single powermetrics plist entry + try: - with open(path+timecode, 'rb') as fp: - data = fp.read() - data = data.split(b'\x00') - powermetrics_parse = plistlib.loads(data[-1]) - thermal_pressure = parse_thermal_pressure(powermetrics_parse) - cpu_metrics_dict = parse_cpu_metrics(powermetrics_parse) - gpu_metrics_dict = parse_gpu_metrics(powermetrics_parse) - #bandwidth_metrics = parse_bandwidth_metrics(powermetrics_parse) - bandwidth_metrics = None - timestamp = powermetrics_parse["timestamp"] - return cpu_metrics_dict, gpu_metrics_dict, thermal_pressure, bandwidth_metrics, timestamp - except Exception as e: - if data: - if len(data) > 1: - powermetrics_parse = plistlib.loads(data[-2]) - thermal_pressure = parse_thermal_pressure(powermetrics_parse) - cpu_metrics_dict = parse_cpu_metrics(powermetrics_parse) - gpu_metrics_dict = parse_gpu_metrics(powermetrics_parse) - #bandwidth_metrics = parse_bandwidth_metrics(powermetrics_parse) - bandwidth_metrics = None - timestamp = powermetrics_parse["timestamp"] - return cpu_metrics_dict, gpu_metrics_dict, thermal_pressure, bandwidth_metrics, timestamp + with open(path + timecode, 'rb') as fp: + fp.seek(0, 2) + file_size = fp.tell() + + if file_size == 0: + return False + + # Read backwards in chunks until we have ≥2 null-byte separators. + # With ≥2 separators we get ≥3 segments, guaranteeing both a + # primary candidate (segments[-1]) and a fallback (segments[-2]). + tail = b'' + pos = file_size + + while pos > 0: + read_size = min(CHUNK_SIZE, pos) + pos -= read_size + fp.seek(pos) + tail = fp.read(read_size) + tail + if tail.count(b'\x00') >= 2: + break + + segments = tail.split(b'\x00') + + except Exception: return False + def _parse_segment(segment): + """Parse a single plist segment, returning the metrics tuple or None.""" + segment = segment.strip(b'\x00') + if not segment: + return None + try: + powermetrics_parse = plistlib.loads(segment) + thermal_pressure = parse_thermal_pressure(powermetrics_parse) + cpu_metrics_dict = parse_cpu_metrics(powermetrics_parse) + gpu_metrics_dict = parse_gpu_metrics(powermetrics_parse) + # bandwidth_metrics = parse_bandwidth_metrics(powermetrics_parse) + bandwidth_metrics = None + timestamp = powermetrics_parse["timestamp"] + return cpu_metrics_dict, gpu_metrics_dict, thermal_pressure, bandwidth_metrics, timestamp + except Exception: + return None + + # Try segments from most-recent to oldest; the very last segment may be + # a partial write in progress, so fall back to earlier complete entries. + for segment in reversed(segments): + result = _parse_segment(segment) + if result is not None: + return result + + return False + def clear_console(): command = 'clear' From d6e23c14b1bb5b9b9be5027916cb66e0614dd104 Mon Sep 17 00:00:00 2001 From: FtlC-ian Date: Fri, 6 Mar 2026 09:05:48 -0600 Subject: [PATCH 2/3] style: replace wildcard import with explicit imports Fixes Codacy F405 (may be undefined from star imports) by explicitly importing parse_thermal_pressure, parse_bandwidth_metrics, parse_cpu_metrics, and parse_gpu_metrics from parsers module. --- asitop/utils.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/asitop/utils.py b/asitop/utils.py index 7056ace..da6a39f 100644 --- a/asitop/utils.py +++ b/asitop/utils.py @@ -3,7 +3,12 @@ import subprocess from subprocess import PIPE import psutil -from .parsers import * +from .parsers import ( + parse_thermal_pressure, + parse_bandwidth_metrics, + parse_cpu_metrics, + parse_gpu_metrics, +) import plistlib From a4bff1b2326d1344392023dffcc7c9f6b5d3f336 Mon Sep 17 00:00:00 2001 From: FtlC-ian Date: Fri, 6 Mar 2026 09:07:21 -0600 Subject: [PATCH 3/3] style: remove unused parse_bandwidth_metrics import Bandwidth metrics parsing has been commented out upstream since Apple removed memory bandwidth from powermetrics. Removes the unused import to fix Codacy F401. --- asitop/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/asitop/utils.py b/asitop/utils.py index da6a39f..0de05b6 100644 --- a/asitop/utils.py +++ b/asitop/utils.py @@ -5,7 +5,6 @@ import psutil from .parsers import ( parse_thermal_pressure, - parse_bandwidth_metrics, parse_cpu_metrics, parse_gpu_metrics, )