from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
import threading
import time
from pathlib import Path
def get_current_dirty_mb() -> float | None:
if not os.path.exists("/proc/meminfo"):
return None
try:
with open("/proc/meminfo", "r", encoding="utf-8") as f:
for line in f:
if line.startswith("Dirty:"):
return round(int(line.split()[1]) / 1024.0, 1)
except Exception:
pass
return None
def sync_and_wait_dirty(max_dirty_mb: float = 1024.0, timeout_s: float = 120.0) -> float | None:
try:
subprocess.run(["sync"], check=False)
except Exception:
pass
d0 = get_current_dirty_mb()
if d0 is None:
return None
t0 = time.time()
while time.time() - t0 < timeout_s:
d_mb = get_current_dirty_mb()
if d_mb is None or d_mb <= max_dirty_mb:
return d_mb
time.sleep(0.5)
return get_current_dirty_mb()
def _linux_maj_min_for_path(p: Path) -> str | None:
try:
st = os.stat(p)
maj, min_v = os.major(st.st_dev), os.minor(st.st_dev)
if os.path.exists(f"/sys/dev/block/{maj}:{min_v}/stat"):
return f"{maj}:{min_v}"
except Exception:
pass
try:
mm = subprocess.check_output(
["findmnt", "-no", "MAJ:MIN", "--target", str(p)],
text=True,
stderr=subprocess.DEVNULL,
).strip()
if mm and os.path.exists(f"/sys/dev/block/{mm}/stat"):
return mm
except Exception:
pass
try:
src = subprocess.check_output(
["findmnt", "-no", "SOURCE", "--target", str(p)],
text=True,
stderr=subprocess.DEVNULL,
).strip()
src = src.split("[", 1)[0]
real_src = os.path.realpath(src)
dev_name = os.path.basename(real_src)
if os.path.exists(f"/sys/class/block/{dev_name}/stat"):
return f"class:{dev_name}"
except Exception:
pass
return None
_DARWIN_DISKS_CACHE: dict[tuple[str, ...], set[str]] = {}
def _darwin_whole_disks(paths: list[Path]) -> set[str]:
key = tuple(str(p) for p in paths)
if key in _DARWIN_DISKS_CACHE:
return _DARWIN_DISKS_CACHE[key]
disks = _darwin_whole_disks_uncached(paths)
_DARWIN_DISKS_CACHE[key] = disks
return disks
def _darwin_whole_disks_uncached(paths: list[Path]) -> set[str]:
disks: set[str] = set()
for p in paths:
try:
df_out = subprocess.check_output(["df", "-P", str(p)], text=True, stderr=subprocess.DEVNULL)
lines = df_out.strip().splitlines()
dev_node = lines[-1].split()[0] if len(lines) >= 2 else str(p)
out = subprocess.check_output(["diskutil", "info", dev_node], text=True, stderr=subprocess.DEVNULL)
phys = None
part_whole = None
for line in out.splitlines():
if "APFS Physical Store:" in line:
m = re.search(r"(disk\d+)", line.split(":", 1)[1])
if m:
phys = m.group(1)
elif "Part of Whole:" in line:
part_whole = line.split(":", 1)[1].strip()
if phys:
disks.add(phys)
elif part_whole:
disks.add(part_whole)
except Exception:
pass
return disks
def get_device_bytes_written(paths: list[Path] | Path) -> tuple[str | None, int | None]:
dev, written, _ = get_device_io_bytes(paths)
return dev, written
def get_device_io_bytes(paths: list[Path] | Path) -> tuple[str | None, int | None, int | None]:
if isinstance(paths, (str, Path)):
paths = [Path(paths)]
resolved = []
for p in paths:
cur = Path(p).resolve()
while not cur.exists() and cur.parent != cur:
cur = cur.parent
resolved.append(cur)
if sys.platform == "darwin":
whole_disks = _darwin_whole_disks(resolved)
if not whole_disks:
return None, None, None
try:
drv_out = subprocess.check_output(
["ioreg", "-c", "IOBlockStorageDriver", "-r", "-l", "-d", "3", "-w0"],
text=True,
stderr=subprocess.DEVNULL,
)
disk_writes: dict[str, int] = {}
disk_reads: dict[str, int] = {}
for block in drv_out.split("+-o IOBlockStorageDriver"):
m_w = re.search(r'"Bytes \(Write\)"\s*=\s*(\d+)', block)
m_r = re.search(r'"Bytes \(Read\)"\s*=\s*(\d+)', block)
if not m_w:
continue
for dname in whole_disks:
if f'"BSD Name" = "{dname}"' in block:
disk_writes[dname] = int(m_w.group(1))
disk_reads[dname] = int(m_r.group(1)) if m_r else 0
if disk_writes:
return ",".join(sorted(disk_writes.keys())), sum(disk_writes.values()), sum(disk_reads.values())
except Exception:
pass
return None, None, None
devs: dict[str, int] = {}
reads: dict[str, int] = {}
for p in resolved:
key = _linux_maj_min_for_path(p)
if not key or key in devs:
continue
stat_path = (
f"/sys/class/block/{key.split(':', 1)[1]}/stat"
if key.startswith("class:")
else f"/sys/dev/block/{key}/stat"
)
try:
fields = Path(stat_path).read_text(encoding="utf-8").split()
devs[key] = int(fields[6]) * 512 reads[key] = int(fields[2]) * 512 except Exception:
pass
if not devs:
return None, None, None
return ",".join(sorted(devs.keys())), sum(devs.values()), sum(reads.values())
def get_df_used_mb(paths: list[Path] | Path) -> float | None:
if isinstance(paths, (str, Path)):
paths = [Path(paths)]
seen_devs: set[int] = set()
total_used = 0
for raw_p in paths:
p = Path(raw_p).resolve()
while not p.exists() and p.parent != p:
p = p.parent
try:
dev = os.stat(p).st_dev
if dev in seen_devs:
continue
seen_devs.add(dev)
total_used += shutil.disk_usage(p).used
except Exception:
return None
return round(total_used / (1024.0 * 1024.0), 1) if seen_devs else None
class SystemPressureSampler:
def __init__(self, interval_s: float = 0.5):
self.interval_s = interval_s
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self.samples: list[dict] = []
def _sample_once(self) -> dict | None:
if not os.path.exists("/proc/meminfo"):
return None
sample = {
"time": time.time(),
"dirty_mb": 0.0,
"writeback_mb": 0.0,
"io_some_avg10": 0.0,
"io_full_avg10": 0.0,
"cpu_some_avg10": 0.0,
}
try:
with open("/proc/meminfo", "r", encoding="utf-8") as f:
for line in f:
if line.startswith("Dirty:"):
sample["dirty_mb"] = round(int(line.split()[1]) / 1024.0, 1)
elif line.startswith("Writeback:"):
sample["writeback_mb"] = round(int(line.split()[1]) / 1024.0, 1)
except Exception:
pass
try:
with open("/proc/pressure/io", "r", encoding="utf-8") as f:
for line in f:
m = re.search(r"avg10=([\d\.]+)", line)
if not m:
continue
if line.startswith("some "):
sample["io_some_avg10"] = float(m.group(1))
elif line.startswith("full "):
sample["io_full_avg10"] = float(m.group(1))
except Exception:
pass
try:
with open("/proc/pressure/cpu", "r", encoding="utf-8") as f:
for line in f:
if line.startswith("some "):
m = re.search(r"avg10=([\d\.]+)", line)
if m:
sample["cpu_some_avg10"] = float(m.group(1))
except Exception:
pass
return sample
def _run(self):
while not self._stop_event.is_set():
s = self._sample_once()
if s is not None:
self.samples.append(s)
self._stop_event.wait(self.interval_s)
def start(self):
self._stop_event.clear()
self.samples = []
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self) -> dict:
self._stop_event.set()
if self._thread:
self._thread.join(timeout=2.0)
if not self.samples:
return {
"samples_count": 0, "max_dirty_mb": None, "avg_dirty_mb": None,
"max_writeback_mb": None, "avg_writeback_mb": None,
"max_io_some_avg10": None, "avg_io_some_avg10": None,
"max_io_full_avg10": None, "avg_io_full_avg10": None,
"max_cpu_some_avg10": None, "avg_cpu_some_avg10": None,
}
dirty_vals = [s["dirty_mb"] for s in self.samples]
wb_vals = [s["writeback_mb"] for s in self.samples]
io_some = [s["io_some_avg10"] for s in self.samples]
io_full = [s["io_full_avg10"] for s in self.samples]
cpu_some = [s["cpu_some_avg10"] for s in self.samples]
def avg(vals: list[float]) -> float:
return round(sum(vals) / len(vals), 2) if vals else 0.0
return {
"samples_count": len(self.samples),
"max_dirty_mb": max(dirty_vals),
"avg_dirty_mb": round(avg(dirty_vals), 1),
"max_writeback_mb": max(wb_vals),
"avg_writeback_mb": round(avg(wb_vals), 1),
"max_io_some_avg10": max(io_some),
"avg_io_some_avg10": avg(io_some),
"max_io_full_avg10": max(io_full),
"avg_io_full_avg10": avg(io_full),
"max_cpu_some_avg10": max(cpu_some),
"avg_cpu_some_avg10": avg(cpu_some),
}