memtaskman 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,383 @@
1
+ """Sampling: turns OS sources into Snapshots, tracks rates and runtime identity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import threading
7
+ import time
8
+ from dataclasses import dataclass, field
9
+
10
+ import psutil
11
+
12
+ from memtaskman import runtime as rt
13
+ from memtaskman.model import (
14
+ CATEGORY_ORDER,
15
+ HINT,
16
+ Inspection,
17
+ ProcessMem,
18
+ Snapshot,
19
+ SystemMemory,
20
+ )
21
+
22
+ WINDOWS = sys.platform == "win32"
23
+ LINUX = sys.platform.startswith("linux")
24
+
25
+ if WINDOWS:
26
+ from memtaskman import win32 as native
27
+ elif LINUX:
28
+ from memtaskman import linux as native
29
+ else: # macOS & others: psutil only
30
+ native = None
31
+
32
+ GROWTH_ALPHA = 0.35 # EMA weight for the private-bytes growth rate
33
+ RUNTIME_TTL = 45.0 # seconds before re-checking a process's loaded modules
34
+ PSS_TTL = 2.0 # smaps_rollup makes the kernel walk page tables; refresh visible rows at most this often
35
+
36
+
37
+ def private_label() -> str:
38
+ if WINDOWS:
39
+ return "Private" # private commit charge (Task Manager "Commit size")
40
+ if LINUX:
41
+ return "Anon" # resident anonymous memory (RssAnon)
42
+ return "Private"
43
+
44
+
45
+ @dataclass
46
+ class _Track:
47
+ t: float
48
+ private: int
49
+ faults: int | None
50
+ growth: float | None = None
51
+ faults_ps: float | None = None
52
+
53
+
54
+ @dataclass
55
+ class _RuntimeEntry:
56
+ info: rt.RuntimeInfo | None
57
+ cmdline: list[str] = field(default_factory=list)
58
+ checked: float = 0.0
59
+
60
+
61
+ class Collector:
62
+ def __init__(self, system: bool = True):
63
+ self.want_system = system
64
+ self._pdh = native.PdhQuery() if WINDOWS and system else None
65
+ self._tracks: dict[tuple[int, float], _Track] = {}
66
+ self._runtimes: dict[tuple[int, float], _RuntimeEntry] = {}
67
+ self._pss: dict[tuple[int, float], tuple[float, int, int]] = {} # key -> (checked, pss, uss)
68
+ self._vm_prev: tuple[float, dict[str, int]] | None = None
69
+ self._first = True
70
+ self._lock = threading.RLock() # sampling and inspection run on different threads
71
+
72
+ # ------------------------------------------------------------------ snapshot
73
+ def sample(self) -> Snapshot:
74
+ with self._lock:
75
+ return self._sample()
76
+
77
+ def _sample(self) -> Snapshot:
78
+ now = time.time()
79
+ procs = self._processes(now)
80
+ system = self._system(now, procs) if self.want_system else None
81
+ for p in procs:
82
+ entry = self._runtimes.get(p.key)
83
+ if entry and entry.info:
84
+ p.runtime = entry.info.name
85
+ p.runtime_detail = entry.info.detail
86
+ cached = self._pss.get(p.key)
87
+ if cached:
88
+ _t, p.pss, p.uss = cached
89
+ return Snapshot(now, sys.platform, system, procs, private_label())
90
+
91
+ # ------------------------------------------------------------------ processes
92
+ def _processes(self, now: float) -> list[ProcessMem]:
93
+ out: list[ProcessMem] = []
94
+ seen = set()
95
+ for proc in psutil.process_iter(["pid", "name", "ppid", "create_time", "memory_info"]):
96
+ info = proc.info
97
+ mi = info.get("memory_info")
98
+ if mi is None:
99
+ continue
100
+ pid = info["pid"]
101
+ ct = info.get("create_time") or 0.0
102
+ p = ProcessMem(
103
+ pid=pid,
104
+ name=info.get("name") or f"pid {pid}",
105
+ ppid=info.get("ppid") or 0,
106
+ create_time=ct,
107
+ resident=mi.rss,
108
+ private=None,
109
+ )
110
+ faults = None
111
+ if WINDOWS:
112
+ p.private = getattr(mi, "private", None)
113
+ p.peak = getattr(mi, "peak_wset", None)
114
+ p.paged_pool = getattr(mi, "paged_pool", None)
115
+ p.nonpaged_pool = getattr(mi, "nonpaged_pool", None)
116
+ faults = getattr(mi, "num_page_faults", None)
117
+ elif LINUX:
118
+ st = native.proc_status(pid)
119
+ if "RssAnon" in st:
120
+ p.private = st["RssAnon"]
121
+ p.shared = st.get("RssFile", 0) + st.get("RssShmem", 0)
122
+ p.swapped = st.get("VmSwap")
123
+ p.peak = st.get("VmHWM")
124
+ faults = native.proc_faults(pid)
125
+ self._rates(p, faults, now)
126
+ seen.add(p.key)
127
+ out.append(p)
128
+ for k in list(self._tracks):
129
+ if k not in seen:
130
+ del self._tracks[k]
131
+ for cache in (self._runtimes, self._pss):
132
+ for k in list(cache):
133
+ if k not in seen:
134
+ del cache[k]
135
+ return out
136
+
137
+ def _rates(self, p: ProcessMem, faults: int | None, now: float) -> None:
138
+ basis = p.private if p.private is not None else p.resident
139
+ prev = self._tracks.get(p.key)
140
+ if prev is None:
141
+ self._tracks[p.key] = _Track(now, basis, faults)
142
+ return
143
+ dt = now - prev.t
144
+ if dt <= 0.05: # two samples back to back: reuse the last rates
145
+ p.growth_ps, p.faults_ps = prev.growth, prev.faults_ps
146
+ return
147
+ inst = (basis - prev.private) / dt
148
+ prev.growth = inst if prev.growth is None else GROWTH_ALPHA * inst + (1 - GROWTH_ALPHA) * prev.growth
149
+ p.growth_ps = prev.growth
150
+ if faults is not None and prev.faults is not None:
151
+ p.faults_ps = prev.faults_ps = max(0.0, (faults - prev.faults) / dt)
152
+ prev.t, prev.private, prev.faults = now, basis, faults
153
+
154
+ # ------------------------------------------------------------------ system
155
+ def _system(self, now: float, procs: list[ProcessMem]) -> SystemMemory:
156
+ vm = psutil.virtual_memory()
157
+ sm = SystemMemory(total=vm.total, available=vm.available, used=vm.total - vm.available)
158
+ try:
159
+ sw = psutil.swap_memory()
160
+ sm.swap_total, sm.swap_used = sw.total, sw.used
161
+ except Exception: # noqa: BLE001 - psutil raises varied errors on locked-down hosts
162
+ pass
163
+ if WINDOWS:
164
+ self._system_windows(sm, procs)
165
+ elif LINUX:
166
+ self._system_linux(sm, now)
167
+ else:
168
+ sm.free = getattr(vm, "free", None)
169
+ sm.cached = getattr(vm, "inactive", None)
170
+ sm.extra = {k: getattr(vm, k) for k in ("active", "inactive", "wired") if hasattr(vm, k)}
171
+ return sm
172
+
173
+ def _system_windows(self, sm: SystemMemory, procs: list[ProcessMem]) -> None:
174
+ sm.source = "PDH + GetPerformanceInfo"
175
+ perf = native.performance_info() or {}
176
+ c = self._pdh.sample() if self._pdh else {}
177
+ if c:
178
+ sb = [c.get(k) for k in ("standby_core", "standby_normal", "standby_reserve")]
179
+ if any(v is not None for v in sb):
180
+ sm.standby = int(sum(v or 0 for v in sb))
181
+ if "modified" in c:
182
+ sm.modified = int(c["modified"])
183
+ if "free_zero" in c:
184
+ sm.free = int(c["free_zero"])
185
+ if "cache" in c:
186
+ sm.cached = int(c["cache"])
187
+ if "pool_paged" in c:
188
+ sm.kernel_paged = int(c["pool_paged"])
189
+ if "pool_nonpaged" in c:
190
+ sm.kernel_nonpaged = int(c["pool_nonpaged"])
191
+ if not self._first:
192
+ sm.page_faults_ps = c.get("page_faults_ps")
193
+ sm.hard_faults_ps = c.get("pages_input_ps")
194
+ for key, label in (("driver_resident", "Driver resident"), ("code_resident", "System code resident")):
195
+ if key in c:
196
+ sm.extra[label] = int(c[key])
197
+ if perf:
198
+ sm.commit_used = perf["commit_total"]
199
+ sm.commit_limit = perf["commit_limit"]
200
+ sm.extra["Commit peak"] = perf["commit_peak"]
201
+ if sm.kernel_paged is None:
202
+ sm.kernel_paged = perf["kernel_paged"]
203
+ sm.kernel_nonpaged = perf["kernel_nonpaged"]
204
+ if sm.cached is None:
205
+ sm.cached = perf["system_cache"]
206
+ comp = next((p for p in procs if p.name.lower() in ("memory compression", "memcompression")), None)
207
+ if comp is not None:
208
+ sm.compressed = comp.resident
209
+ self._first = False
210
+
211
+ def _system_linux(self, sm: SystemMemory, now: float) -> None:
212
+ sm.source = "/proc/meminfo + /proc/vmstat"
213
+ mi = native.meminfo()
214
+ sm.free = mi.get("MemFree")
215
+ sm.cached = mi.get("Cached", 0) + mi.get("Buffers", 0) if "Cached" in mi else None
216
+ sm.compressed = mi.get("Zswap")
217
+ sm.commit_used = mi.get("Committed_AS")
218
+ sm.commit_limit = mi.get("CommitLimit")
219
+ sm.kernel_paged = mi.get("SReclaimable")
220
+ sm.kernel_nonpaged = mi.get("SUnreclaim")
221
+ sm.modified = mi.get("Dirty")
222
+ for k, label in (("Shmem", "Shmem / tmpfs"), ("AnonPages", "Anonymous"), ("PageTables", "Page tables"),
223
+ ("KernelStack", "Kernel stacks"), ("AnonHugePages", "Transparent hugepages"),
224
+ ("Zswapped", "Zswapped (uncompressed)")):
225
+ if k in mi:
226
+ sm.extra[label] = mi[k]
227
+ vs = native.vmstat()
228
+ if self._vm_prev and vs:
229
+ t0, prev = self._vm_prev
230
+ dt = now - t0
231
+ if dt > 0 and "pgfault" in vs and "pgfault" in prev:
232
+ sm.page_faults_ps = (vs["pgfault"] - prev["pgfault"]) / dt
233
+ sm.hard_faults_ps = (vs.get("pgmajfault", 0) - prev.get("pgmajfault", 0)) / dt
234
+ self._vm_prev = (now, vs)
235
+
236
+ # ------------------------------------------------------------------ runtimes
237
+ def resolve_runtimes(self, procs: list[ProcessMem], *, time_budget: float = 0.25) -> int:
238
+ """Identify runtimes for processes in the given priority order, within a time budget."""
239
+ with self._lock:
240
+ return self._resolve(procs, time_budget)
241
+
242
+ def _resolve(self, procs: list[ProcessMem], time_budget: float) -> int:
243
+ deadline = time.perf_counter() + time_budget
244
+ now = time.time()
245
+ done = 0
246
+ for p in procs:
247
+ entry = self._runtimes.get(p.key)
248
+ if entry and now - entry.checked < RUNTIME_TTL:
249
+ continue
250
+ if time.perf_counter() > deadline:
251
+ break
252
+ self._runtimes[p.key] = self._identify(p, now)
253
+ info = self._runtimes[p.key].info
254
+ p.runtime = info.name if info else None
255
+ p.runtime_detail = info.detail if info else None
256
+ done += 1
257
+ return done
258
+
259
+ def refresh_pss(self, procs: list[ProcessMem], *, time_budget: float = 0.2) -> int:
260
+ """Linux only: read PSS/USS from smaps_rollup for processes in priority order."""
261
+ if not LINUX:
262
+ return 0
263
+ with self._lock:
264
+ deadline = time.perf_counter() + time_budget
265
+ now = time.time()
266
+ done = 0
267
+ for p in procs:
268
+ cached = self._pss.get(p.key)
269
+ if cached and now - cached[0] < PSS_TTL:
270
+ p.pss, p.uss = cached[1], cached[2]
271
+ continue
272
+ if time.perf_counter() > deadline:
273
+ break
274
+ r = native.rollup(p.pid)
275
+ if r is None:
276
+ continue # other users' processes: unreadable without root, left as None
277
+ p.pss, p.uss = r
278
+ self._pss[p.key] = (now, r[0], r[1])
279
+ done += 1
280
+ return done
281
+
282
+ def _identify(self, p: ProcessMem, now: float) -> _RuntimeEntry:
283
+ mods: list[str] = []
284
+ if native is not None and p.pid > 4:
285
+ try:
286
+ mods = (native.module_names(p.pid) if WINDOWS else native.mapped_paths(p.pid)) or []
287
+ except OSError:
288
+ mods = []
289
+ try:
290
+ cmd = psutil.Process(p.pid).cmdline()
291
+ except (psutil.Error, OSError):
292
+ cmd = []
293
+ exe = p.name
294
+ return _RuntimeEntry(rt.detect(exe, mods, cmd), cmd, now)
295
+
296
+ def runtime_of(self, key: tuple[int, float]) -> tuple[rt.RuntimeInfo | None, list[str]]:
297
+ e = self._runtimes.get(key)
298
+ return (e.info, e.cmdline) if e else (None, [])
299
+
300
+ # ------------------------------------------------------------------ inspector
301
+ def inspect(self, p: ProcessMem) -> Inspection:
302
+ if native is None:
303
+ ins = Inspection(p.pid, p.name, error="address-space inspection is implemented for Windows and Linux")
304
+ else:
305
+ try:
306
+ ins = native.inspect(p.pid, p.name)
307
+ except Exception as e: # noqa: BLE001 - a racing process exit can surface many error types
308
+ ins = Inspection(p.pid, p.name, error=f"{type(e).__name__}: {e}")
309
+ ins.categories.sort(key=lambda c: CATEGORY_ORDER.index(c.name) if c.name in CATEGORY_ORDER else 99)
310
+ with self._lock:
311
+ if p.key not in self._runtimes or time.time() - self._runtimes[p.key].checked > RUNTIME_TTL:
312
+ self._runtimes[p.key] = self._identify(p, time.time())
313
+ info, cmd = self.runtime_of(p.key)
314
+ ins.cmdline = cmd
315
+ if info:
316
+ ins.evidence = [e for e in info.evidence if e[1]] + ins.evidence
317
+ elif not ins.error:
318
+ ins.evidence.insert(0, (HINT, "no known runtime recognised"))
319
+ return ins
320
+
321
+ def close(self) -> None:
322
+ if self._pdh:
323
+ self._pdh.close()
324
+
325
+
326
+ # ---------------------------------------------------------------------- aggregation
327
+
328
+
329
+ def group_by_app(procs: list[ProcessMem]) -> list[ProcessMem]:
330
+ """Sum processes sharing an executable name (e.g. every chrome.exe).
331
+
332
+ Resident totals double count pages shared between the members, so they are an
333
+ upper bound; private totals do not overlap.
334
+ """
335
+ groups: dict[str, ProcessMem] = {}
336
+ for p in procs:
337
+ k = p.name.lower()
338
+ g = groups.get(k)
339
+ if g is None:
340
+ groups[k] = ProcessMem(
341
+ pid=p.pid, name=p.name, ppid=0, create_time=0.0, resident=p.resident, private=p.private,
342
+ shared=p.shared, swapped=p.swapped, pss=p.pss, uss=p.uss, faults_ps=p.faults_ps, growth_ps=p.growth_ps,
343
+ paged_pool=p.paged_pool, nonpaged_pool=p.nonpaged_pool,
344
+ runtime=p.runtime, runtime_detail=None, count=1,
345
+ )
346
+ continue
347
+ g.count += 1
348
+ g.resident += p.resident
349
+ g.private = _add(g.private, p.private)
350
+ g.shared = _add(g.shared, p.shared)
351
+ g.swapped = _add(g.swapped, p.swapped)
352
+ g.pss = _add(g.pss, p.pss)
353
+ g.uss = _add(g.uss, p.uss)
354
+ g.faults_ps = _add(g.faults_ps, p.faults_ps)
355
+ g.growth_ps = _add(g.growth_ps, p.growth_ps)
356
+ g.paged_pool = _add(g.paged_pool, p.paged_pool)
357
+ g.nonpaged_pool = _add(g.nonpaged_pool, p.nonpaged_pool)
358
+ g.runtime = g.runtime or p.runtime
359
+ g.pid = min(g.pid, p.pid)
360
+ return list(groups.values())
361
+
362
+
363
+ def _add(a, b):
364
+ if a is None:
365
+ return b
366
+ if b is None:
367
+ return a
368
+ return a + b
369
+
370
+
371
+ SORTS = {
372
+ "resident": lambda p: p.resident,
373
+ "private": lambda p: p.private or 0,
374
+ "pss": lambda p: p.pss or 0,
375
+ "growth": lambda p: p.growth_ps or 0,
376
+ "faults": lambda p: p.faults_ps or 0,
377
+ "name": lambda p: p.name.lower(),
378
+ "pid": lambda p: p.pid,
379
+ }
380
+
381
+
382
+ def sort_procs(procs: list[ProcessMem], key: str) -> list[ProcessMem]:
383
+ return sorted(procs, key=SORTS[key], reverse=key not in ("name", "pid"))
memtaskman/diff.py ADDED
@@ -0,0 +1,58 @@
1
+ """Compare two --json snapshots: what grew, what appeared, what went away."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+
7
+ from memtaskman.fmt import fmt_bytes
8
+
9
+ _SYSTEM_FIELDS = ["used", "available", "standby", "modified", "cached", "compressed", "commit_used",
10
+ "swap_used", "kernel_paged", "kernel_nonpaged"]
11
+
12
+
13
+ def _signed(n: int) -> str:
14
+ return ("+" if n > 0 else "") + fmt_bytes(n)
15
+
16
+
17
+ def diff_snapshots(a: dict, b: dict, *, top: int = 20) -> str:
18
+ out = []
19
+ dt = b.get("time", 0) - a.get("time", 0)
20
+ out.append(f"Δt = {dt:.0f}s")
21
+ sa, sb = a.get("system") or {}, b.get("system") or {}
22
+ rows = []
23
+ for f in _SYSTEM_FIELDS:
24
+ va, vb = sa.get(f), sb.get(f)
25
+ if va is not None and vb is not None and va != vb:
26
+ rows.append(f" {f:<16} {fmt_bytes(va):>9} -> {fmt_bytes(vb):>9} ({_signed(vb - va)})")
27
+ if rows:
28
+ out.append("System:")
29
+ out += rows
30
+
31
+ def by_name(snap: dict) -> dict[str, dict]:
32
+ agg: dict[str, dict] = defaultdict(lambda: {"resident": 0, "private": 0, "count": 0})
33
+ for p in snap.get("processes", []):
34
+ g = agg[p["name"].lower()]
35
+ g["name"] = p["name"]
36
+ g["resident"] += p.get("resident") or 0
37
+ g["private"] += p.get("private") or 0
38
+ g["count"] += 1
39
+ return agg
40
+
41
+ ga, gb = by_name(a), by_name(b)
42
+ changes = []
43
+ for k in set(ga) | set(gb):
44
+ pa, pb = ga.get(k), gb.get(k)
45
+ name = (pb or pa)["name"]
46
+ ra, rb = (pa or {}).get("resident", 0), (pb or {}).get("resident", 0)
47
+ qa, qb = (pa or {}).get("private", 0), (pb or {}).get("private", 0)
48
+ tag = "new" if pa is None else "gone" if pb is None else ""
49
+ changes.append((abs(qb - qa) + abs(rb - ra), name, ra, rb, qa, qb, tag,
50
+ (pa or {}).get("count", 0), (pb or {}).get("count", 0)))
51
+ changes.sort(reverse=True)
52
+ out.append(f"Processes (grouped by name, top {top} by change):")
53
+ out.append(f" {'Name':<28} {'Resident Δ':>11} {'Private Δ':>11} {'now':>9} count")
54
+ for _w, name, ra, rb, qa, qb, tag, ca, cb in changes[:top]:
55
+ if ra == rb and qa == qb and not tag:
56
+ continue
57
+ out.append(f" {name[:28]:<28} {_signed(rb - ra):>11} {_signed(qb - qa):>11} {fmt_bytes(rb):>9} {ca}->{cb} {tag}")
58
+ return "\n".join(out)
memtaskman/fmt.py ADDED
@@ -0,0 +1,73 @@
1
+ """Human-readable formatting helpers (pure, no I/O)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ _UNITS = ["B", "K", "M", "G", "T", "P"]
6
+
7
+
8
+ def fmt_bytes(n: float | int | None, *, long: bool = False) -> str:
9
+ """Format a byte count compactly using binary units: 0, 512B, 1.5K, 12M, 3.40G."""
10
+ if n is None:
11
+ return "-"
12
+ neg = n < 0
13
+ v = float(abs(n))
14
+ i = 0
15
+ while v >= 1024 and i < len(_UNITS) - 1:
16
+ v /= 1024
17
+ i += 1
18
+ if i == 0:
19
+ s = f"{int(v)}B"
20
+ elif v >= 100:
21
+ s = f"{v:.0f}{_UNITS[i]}"
22
+ elif v >= 10:
23
+ s = f"{v:.1f}{_UNITS[i]}"
24
+ else:
25
+ s = f"{v:.2f}{_UNITS[i]}"
26
+ if long and i > 0:
27
+ s += "iB"
28
+ return ("-" if neg else "") + s
29
+
30
+
31
+ def fmt_rate(bps: float | None) -> str:
32
+ """Format a signed byte rate, e.g. +1.20M/s. Near-zero rates render as a dot."""
33
+ if bps is None:
34
+ return "-"
35
+ if abs(bps) < 1024:
36
+ return "0"
37
+ sign = "+" if bps > 0 else "-"
38
+ return f"{sign}{fmt_bytes(abs(bps))}/s"
39
+
40
+
41
+ def fmt_count_rate(n: float | None) -> str:
42
+ if n is None:
43
+ return "-"
44
+ if n < 1:
45
+ return "0"
46
+ if n >= 1_000_000:
47
+ return f"{n / 1_000_000:.1f}M"
48
+ if n >= 10_000:
49
+ return f"{n / 1000:.0f}k"
50
+ if n >= 1000:
51
+ return f"{n / 1000:.1f}k"
52
+ return f"{n:.0f}"
53
+
54
+
55
+ def pct(part: float | None, whole: float | None) -> float | None:
56
+ if part is None or not whole:
57
+ return None
58
+ return 100.0 * part / whole
59
+
60
+
61
+ def parse_size(text: str) -> int | None:
62
+ """Parse sizes as used on command lines: 4g, 512m, 2048k, 1024 (bytes), 4GB, 1.5G."""
63
+ t = text.strip().lower().rstrip("b")
64
+ if not t:
65
+ return None
66
+ mult = 1
67
+ if t[-1] in "kmgt":
68
+ mult = 1024 ** ("kmgt".index(t[-1]) + 1)
69
+ t = t[:-1]
70
+ try:
71
+ return int(float(t) * mult)
72
+ except ValueError:
73
+ return None