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.
- memtaskman/__init__.py +3 -0
- memtaskman/__main__.py +3 -0
- memtaskman/app.py +505 -0
- memtaskman/cli.py +135 -0
- memtaskman/collector.py +383 -0
- memtaskman/diff.py +58 -0
- memtaskman/fmt.py +73 -0
- memtaskman/linux.py +198 -0
- memtaskman/model.py +136 -0
- memtaskman/report.py +85 -0
- memtaskman/runtime.py +224 -0
- memtaskman/win32.py +390 -0
- memtaskman-0.1.0.dist-info/METADATA +186 -0
- memtaskman-0.1.0.dist-info/RECORD +17 -0
- memtaskman-0.1.0.dist-info/WHEEL +4 -0
- memtaskman-0.1.0.dist-info/entry_points.txt +3 -0
- memtaskman-0.1.0.dist-info/licenses/LICENSE +21 -0
memtaskman/linux.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Linux memory sources: /proc/meminfo, /proc/vmstat, /proc/<pid>/{status,stat,smaps}.
|
|
2
|
+
|
|
3
|
+
Parsers are pure functions over text so they can be tested on any OS.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import PurePosixPath
|
|
12
|
+
|
|
13
|
+
from memtaskman.fmt import fmt_bytes
|
|
14
|
+
from memtaskman.model import (
|
|
15
|
+
CAT_HEAP,
|
|
16
|
+
CAT_IMAGE,
|
|
17
|
+
CAT_MAPPED,
|
|
18
|
+
CAT_OTHER,
|
|
19
|
+
CAT_SHM,
|
|
20
|
+
CAT_STACK,
|
|
21
|
+
DERIVED,
|
|
22
|
+
MEASURED,
|
|
23
|
+
Category,
|
|
24
|
+
FileMapping,
|
|
25
|
+
Inspection,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_kb_table(text: str) -> dict[str, int]:
|
|
30
|
+
"""Parse 'Key: 1234 kB' lines (meminfo, status, smaps) into bytes."""
|
|
31
|
+
out: dict[str, int] = {}
|
|
32
|
+
for line in text.splitlines():
|
|
33
|
+
key, sep, rest = line.partition(":")
|
|
34
|
+
if not sep:
|
|
35
|
+
continue
|
|
36
|
+
parts = rest.split()
|
|
37
|
+
if not parts or not parts[0].isdigit():
|
|
38
|
+
continue
|
|
39
|
+
v = int(parts[0])
|
|
40
|
+
if len(parts) > 1 and parts[1].lower() == "kb":
|
|
41
|
+
v *= 1024
|
|
42
|
+
out[key.strip()] = v
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_vmstat(text: str) -> dict[str, int]:
|
|
47
|
+
out = {}
|
|
48
|
+
for line in text.splitlines():
|
|
49
|
+
k, _, v = line.partition(" ")
|
|
50
|
+
if v.strip().isdigit():
|
|
51
|
+
out[k] = int(v)
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_stat_faults(text: str) -> int | None:
|
|
56
|
+
"""minflt + majflt from /proc/<pid>/stat (comm may contain spaces/parens)."""
|
|
57
|
+
r = text.rfind(")")
|
|
58
|
+
if r < 0:
|
|
59
|
+
return None
|
|
60
|
+
fields = text[r + 2 :].split()
|
|
61
|
+
# fields[0] is state (field 3); minflt is field 10, majflt field 12.
|
|
62
|
+
try:
|
|
63
|
+
return int(fields[7]) + int(fields[9])
|
|
64
|
+
except (IndexError, ValueError):
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_HEADER = re.compile(r"^([0-9a-f]+)-([0-9a-f]+)\s+(\S+)\s+\S+\s+\S+\s+\d+\s*(.*)$")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_smaps(text: str) -> list[dict]:
|
|
72
|
+
"""One dict per mapping: start, end, perms, path, plus kB fields in bytes."""
|
|
73
|
+
maps: list[dict] = []
|
|
74
|
+
cur: dict | None = None
|
|
75
|
+
for line in text.splitlines():
|
|
76
|
+
m = _HEADER.match(line)
|
|
77
|
+
if m:
|
|
78
|
+
cur = {"start": int(m.group(1), 16), "end": int(m.group(2), 16), "perms": m.group(3), "path": m.group(4).strip()}
|
|
79
|
+
maps.append(cur)
|
|
80
|
+
continue
|
|
81
|
+
if cur is None:
|
|
82
|
+
continue
|
|
83
|
+
key, sep, rest = line.partition(":")
|
|
84
|
+
parts = rest.split()
|
|
85
|
+
if sep and parts and parts[0].isdigit() and len(parts) > 1 and parts[1] == "kB":
|
|
86
|
+
cur[key.strip()] = int(parts[0]) * 1024
|
|
87
|
+
return maps
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def categorize(path: str, perms: str) -> str:
|
|
91
|
+
if not path or path.startswith("[anon"):
|
|
92
|
+
return CAT_HEAP
|
|
93
|
+
if path == "[heap]":
|
|
94
|
+
return CAT_HEAP
|
|
95
|
+
if path.startswith("[stack"):
|
|
96
|
+
return CAT_STACK
|
|
97
|
+
if path.startswith("["):
|
|
98
|
+
return CAT_OTHER # vdso, vvar, vsyscall
|
|
99
|
+
if path.startswith(("/dev/shm/", "/memfd:", "memfd:", "/SYSV", "/dev/zero")):
|
|
100
|
+
return CAT_SHM
|
|
101
|
+
name = PurePosixPath(path).name
|
|
102
|
+
if "x" in perms or re.search(r"\.so(\.|$)", name):
|
|
103
|
+
return CAT_IMAGE
|
|
104
|
+
if path.startswith("/dev/"):
|
|
105
|
+
return CAT_OTHER
|
|
106
|
+
return CAT_MAPPED
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def summarize_smaps(maps: list[dict], top_files: int = 12) -> tuple[list[Category], list[FileMapping]]:
|
|
110
|
+
cats: dict[str, Category] = {}
|
|
111
|
+
files: dict[str, FileMapping] = {}
|
|
112
|
+
for m in maps:
|
|
113
|
+
cat = categorize(m["path"], m["perms"])
|
|
114
|
+
c = cats.setdefault(cat, Category(cat, resident=0, resident_shared=0, swapped=0))
|
|
115
|
+
size = m.get("Size", m["end"] - m["start"])
|
|
116
|
+
rss = m.get("Rss", 0)
|
|
117
|
+
c.mapped += size
|
|
118
|
+
c.resident += rss
|
|
119
|
+
c.resident_shared += m.get("Shared_Clean", 0) + m.get("Shared_Dirty", 0)
|
|
120
|
+
c.swapped += m.get("Swap", 0)
|
|
121
|
+
c.regions += 1
|
|
122
|
+
if cat in (CAT_IMAGE, CAT_MAPPED, CAT_SHM) and m["path"]:
|
|
123
|
+
f = files.setdefault(m["path"], FileMapping(m["path"], "image" if cat == CAT_IMAGE else "mapped", 0, 0))
|
|
124
|
+
f.mapped += size
|
|
125
|
+
f.resident += rss
|
|
126
|
+
top = sorted(files.values(), key=lambda f: f.resident or 0, reverse=True)[:top_files]
|
|
127
|
+
return list(cats.values()), top
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --------------------------------------------------------------------------- live readers
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _read(path: str) -> str | None:
|
|
134
|
+
try:
|
|
135
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
136
|
+
return f.read()
|
|
137
|
+
except OSError:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def meminfo() -> dict[str, int]:
|
|
142
|
+
return parse_kb_table(_read("/proc/meminfo") or "")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def vmstat() -> dict[str, int]:
|
|
146
|
+
return parse_vmstat(_read("/proc/vmstat") or "")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def proc_status(pid: int) -> dict[str, int]:
|
|
150
|
+
return parse_kb_table(_read(f"/proc/{pid}/status") or "")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def proc_faults(pid: int) -> int | None:
|
|
154
|
+
t = _read(f"/proc/{pid}/stat")
|
|
155
|
+
return parse_stat_faults(t) if t else None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def rollup(pid: int) -> tuple[int, int] | None:
|
|
159
|
+
"""(PSS, USS) from /proc/<pid>/smaps_rollup, or None when unreadable."""
|
|
160
|
+
r = parse_kb_table(_read(f"/proc/{pid}/smaps_rollup") or "")
|
|
161
|
+
if "Pss" not in r:
|
|
162
|
+
return None
|
|
163
|
+
return r["Pss"], r.get("Private_Clean", 0) + r.get("Private_Dirty", 0)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def mapped_paths(pid: int) -> list[str] | None:
|
|
167
|
+
t = _read(f"/proc/{pid}/maps")
|
|
168
|
+
if t is None:
|
|
169
|
+
return None
|
|
170
|
+
names = set()
|
|
171
|
+
for line in t.splitlines():
|
|
172
|
+
parts = line.split(None, 5)
|
|
173
|
+
if len(parts) == 6 and parts[5].startswith("/"):
|
|
174
|
+
names.add(PurePosixPath(parts[5].strip()).name)
|
|
175
|
+
return sorted(names)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def inspect(pid: int, name: str, *, top_files: int = 12) -> Inspection:
|
|
179
|
+
t0 = time.perf_counter()
|
|
180
|
+
ins = Inspection(pid=pid, name=name)
|
|
181
|
+
text = _read(f"/proc/{pid}/smaps")
|
|
182
|
+
if text is None:
|
|
183
|
+
ins.error = "cannot read smaps: process exited, or owned by another user (run with sudo)"
|
|
184
|
+
return ins
|
|
185
|
+
maps = parse_smaps(text)
|
|
186
|
+
ins.categories, ins.top_files = summarize_smaps(maps, top_files)
|
|
187
|
+
ins.evidence.append((MEASURED, f"{len(maps)} mappings from /proc/{pid}/smaps"))
|
|
188
|
+
r = rollup(pid)
|
|
189
|
+
if r:
|
|
190
|
+
ins.evidence.append((MEASURED, f"PSS {fmt_bytes(r[0])}: resident with shared pages split between sharers"))
|
|
191
|
+
ins.evidence.append((MEASURED, f"USS {fmt_bytes(r[1])}: freed if this process exits"))
|
|
192
|
+
ins.evidence.append((DERIVED, "categories assigned from mapping names and permissions"))
|
|
193
|
+
ins.elapsed = time.perf_counter() - t0
|
|
194
|
+
return ins
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def is_linux() -> bool:
|
|
198
|
+
return os.path.exists("/proc/meminfo")
|
memtaskman/model.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Plain data types shared by collectors, the UI and reports.
|
|
2
|
+
|
|
3
|
+
Every number is in bytes (or events per second for *_ps fields). ``None`` means
|
|
4
|
+
"this platform/privilege level could not measure it" and is never silently
|
|
5
|
+
turned into zero.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
# Evidence levels, strongest first. Shown next to every inspector claim.
|
|
14
|
+
MEASURED = "measured" # read straight from an OS counter
|
|
15
|
+
DERIVED = "derived" # computed by us from OS data (e.g. page-by-page working-set walk)
|
|
16
|
+
IDENTIFIED = "identified" # runtime recognised from loaded modules / executable
|
|
17
|
+
HINT = "hint" # configuration from the command line; a limit, not usage
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class SystemMemory:
|
|
22
|
+
total: int
|
|
23
|
+
available: int
|
|
24
|
+
used: int # total - available
|
|
25
|
+
free: int | None = None # free + zeroed pages (Win) / MemFree (Linux)
|
|
26
|
+
standby: int | None = None # Windows standby list (cache that is instantly reclaimable)
|
|
27
|
+
modified: int | None = None # Windows modified list (dirty, must be written before reuse)
|
|
28
|
+
cached: int | None = None # file cache (Win system cache WS / Linux Cached+Buffers)
|
|
29
|
+
compressed: int | None = None # Windows compression store WS / Linux zswap pool
|
|
30
|
+
commit_used: int | None = None
|
|
31
|
+
commit_limit: int | None = None
|
|
32
|
+
swap_total: int | None = None
|
|
33
|
+
swap_used: int | None = None
|
|
34
|
+
kernel_paged: int | None = None # Win paged pool / Linux reclaimable slab
|
|
35
|
+
kernel_nonpaged: int | None = None # Win nonpaged pool / Linux unreclaimable slab
|
|
36
|
+
page_faults_ps: float | None = None
|
|
37
|
+
hard_faults_ps: float | None = None # pages read from disk per second
|
|
38
|
+
extra: dict[str, int] = field(default_factory=dict) # platform-specific labelled values
|
|
39
|
+
source: str = "psutil"
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> dict[str, Any]:
|
|
42
|
+
return asdict(self)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ProcessMem:
|
|
47
|
+
pid: int
|
|
48
|
+
name: str
|
|
49
|
+
ppid: int
|
|
50
|
+
create_time: float
|
|
51
|
+
resident: int # working set (Win) / RSS (Linux, macOS)
|
|
52
|
+
private: int | None # private commit (Win) / anonymous resident (Linux) / None
|
|
53
|
+
shared: int | None = None # resident file-backed + shmem (Linux)
|
|
54
|
+
swapped: int | None = None # VmSwap (Linux)
|
|
55
|
+
pss: int | None = None # proportional set size: shared pages split between sharers (Linux)
|
|
56
|
+
uss: int | None = None # unique set size: freed if the process exits (Linux)
|
|
57
|
+
peak: int | None = None # peak working set / VmHWM
|
|
58
|
+
paged_pool: int | None = None # Windows kernel pool charged to the process
|
|
59
|
+
nonpaged_pool: int | None = None
|
|
60
|
+
faults_ps: float | None = None
|
|
61
|
+
growth_ps: float | None = None # smoothed private-bytes growth rate, B/s
|
|
62
|
+
runtime: str | None = None
|
|
63
|
+
runtime_detail: str | None = None
|
|
64
|
+
count: int = 1 # >1 when this row aggregates several processes (group-by-app view)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def key(self) -> tuple[int, float]:
|
|
68
|
+
return (self.pid, self.create_time)
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> dict[str, Any]:
|
|
71
|
+
return asdict(self)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Snapshot:
|
|
76
|
+
time: float
|
|
77
|
+
platform: str
|
|
78
|
+
system: SystemMemory | None
|
|
79
|
+
processes: list[ProcessMem]
|
|
80
|
+
private_label: str = "Private" # what "private" means on this platform
|
|
81
|
+
|
|
82
|
+
def to_dict(self) -> dict[str, Any]:
|
|
83
|
+
return {
|
|
84
|
+
"version": 1,
|
|
85
|
+
"time": self.time,
|
|
86
|
+
"platform": self.platform,
|
|
87
|
+
"private_label": self.private_label,
|
|
88
|
+
"system": self.system.to_dict() if self.system else None,
|
|
89
|
+
"processes": [p.to_dict() for p in self.processes],
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class Category:
|
|
95
|
+
"""One slice of a process's address space."""
|
|
96
|
+
|
|
97
|
+
name: str
|
|
98
|
+
mapped: int = 0 # committed (Win) / mapped size (Linux)
|
|
99
|
+
resident: int | None = None
|
|
100
|
+
resident_shared: int | None = None
|
|
101
|
+
swapped: int | None = None
|
|
102
|
+
regions: int = 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class FileMapping:
|
|
107
|
+
path: str
|
|
108
|
+
kind: str # "image" or "mapped"
|
|
109
|
+
mapped: int = 0
|
|
110
|
+
resident: int | None = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class Inspection:
|
|
115
|
+
pid: int
|
|
116
|
+
name: str
|
|
117
|
+
categories: list[Category] = field(default_factory=list)
|
|
118
|
+
top_files: list[FileMapping] = field(default_factory=list)
|
|
119
|
+
reserved: int | None = None # address space reserved but not committed (Win)
|
|
120
|
+
evidence: list[tuple[str, str]] = field(default_factory=list) # (level, text)
|
|
121
|
+
cmdline: list[str] = field(default_factory=list)
|
|
122
|
+
error: str | None = None
|
|
123
|
+
elapsed: float = 0.0
|
|
124
|
+
|
|
125
|
+
def to_dict(self) -> dict[str, Any]:
|
|
126
|
+
return asdict(self)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# Fixed category names so Windows and Linux line up in the UI.
|
|
130
|
+
CAT_IMAGE = "Images (exe/dll/.so)"
|
|
131
|
+
CAT_MAPPED = "Mapped files"
|
|
132
|
+
CAT_SHM = "Shared memory"
|
|
133
|
+
CAT_HEAP = "Private (heap/anon)"
|
|
134
|
+
CAT_STACK = "Stacks"
|
|
135
|
+
CAT_OTHER = "Other"
|
|
136
|
+
CATEGORY_ORDER = [CAT_HEAP, CAT_STACK, CAT_IMAGE, CAT_MAPPED, CAT_SHM, CAT_OTHER]
|
memtaskman/report.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Plain-text and JSON output for --once / --json / inspect."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from memtaskman.fmt import fmt_bytes, fmt_count_rate, fmt_rate, pct
|
|
8
|
+
from memtaskman.model import Inspection, Snapshot, SystemMemory
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def system_lines(sm: SystemMemory) -> list[str]:
|
|
12
|
+
lines = []
|
|
13
|
+
up = pct(sm.used, sm.total)
|
|
14
|
+
lines.append(f"RAM {fmt_bytes(sm.used)} / {fmt_bytes(sm.total)} used ({up:.0f}%), {fmt_bytes(sm.available)} available")
|
|
15
|
+
parts = []
|
|
16
|
+
if sm.modified is not None:
|
|
17
|
+
parts.append(f"modified {fmt_bytes(sm.modified)}")
|
|
18
|
+
if sm.standby is not None:
|
|
19
|
+
parts.append(f"standby {fmt_bytes(sm.standby)}")
|
|
20
|
+
if sm.free is not None:
|
|
21
|
+
parts.append(f"free {fmt_bytes(sm.free)}")
|
|
22
|
+
if sm.cached is not None:
|
|
23
|
+
parts.append(f"file cache {fmt_bytes(sm.cached)}")
|
|
24
|
+
if sm.compressed is not None:
|
|
25
|
+
parts.append(f"compressed {fmt_bytes(sm.compressed)}")
|
|
26
|
+
if parts:
|
|
27
|
+
lines.append(" " + ", ".join(parts))
|
|
28
|
+
if sm.commit_used is not None and sm.commit_limit:
|
|
29
|
+
lines.append(f"Commit {fmt_bytes(sm.commit_used)} / {fmt_bytes(sm.commit_limit)} ({pct(sm.commit_used, sm.commit_limit):.0f}%)")
|
|
30
|
+
if sm.swap_total:
|
|
31
|
+
lines.append(f"Swap {fmt_bytes(sm.swap_used)} / {fmt_bytes(sm.swap_total)}")
|
|
32
|
+
if sm.kernel_paged is not None:
|
|
33
|
+
lines.append(f"Kernel paged/reclaimable {fmt_bytes(sm.kernel_paged)}, nonpaged/unreclaimable {fmt_bytes(sm.kernel_nonpaged)}")
|
|
34
|
+
if sm.page_faults_ps is not None:
|
|
35
|
+
lines.append(f"Faults {fmt_count_rate(sm.page_faults_ps)}/s, hard {fmt_count_rate(sm.hard_faults_ps)}/s")
|
|
36
|
+
for k, v in sm.extra.items():
|
|
37
|
+
lines.append(f" {k}: {fmt_bytes(v)}")
|
|
38
|
+
return lines
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render_text(snap: Snapshot, *, top: int | None = 25) -> str:
|
|
42
|
+
out: list[str] = []
|
|
43
|
+
if snap.system:
|
|
44
|
+
out += system_lines(snap.system)
|
|
45
|
+
out.append("")
|
|
46
|
+
procs = sorted(snap.processes, key=lambda p: p.resident, reverse=True)
|
|
47
|
+
if top:
|
|
48
|
+
procs = procs[:top]
|
|
49
|
+
pl = snap.private_label
|
|
50
|
+
has_pss = any(p.pss is not None for p in procs)
|
|
51
|
+
pss_h = f" {'PSS':>9}" if has_pss else ""
|
|
52
|
+
out.append(f"{'PID':>7} {'Name':<28} {'Resident':>9}{pss_h} {pl:>9} {'Growth':>10} {'Faults/s':>8} Runtime")
|
|
53
|
+
for p in procs:
|
|
54
|
+
rt = f"{p.runtime or ''} {p.runtime_detail or ''}".strip()
|
|
55
|
+
pss = f" {fmt_bytes(p.pss):>9}" if has_pss else ""
|
|
56
|
+
out.append(
|
|
57
|
+
f"{p.pid:>7} {p.name[:28]:<28} {fmt_bytes(p.resident):>9}{pss} {fmt_bytes(p.private):>9} "
|
|
58
|
+
f"{fmt_rate(p.growth_ps):>10} {fmt_count_rate(p.faults_ps):>8} {rt}"
|
|
59
|
+
)
|
|
60
|
+
return "\n".join(out)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def render_inspection(ins: Inspection) -> str:
|
|
64
|
+
out = [f"{ins.name} (pid {ins.pid})"]
|
|
65
|
+
if ins.error:
|
|
66
|
+
out.append(f" error: {ins.error}")
|
|
67
|
+
if ins.categories:
|
|
68
|
+
out.append(f" {'Category':<22} {'Mapped':>9} {'Resident':>9} {'Shared':>9}")
|
|
69
|
+
for c in ins.categories:
|
|
70
|
+
out.append(f" {c.name:<22} {fmt_bytes(c.mapped):>9} {fmt_bytes(c.resident):>9} {fmt_bytes(c.resident_shared):>9}")
|
|
71
|
+
if ins.reserved:
|
|
72
|
+
out.append(f" {'Reserved (uncommitted)':<22} {fmt_bytes(ins.reserved):>9}")
|
|
73
|
+
if ins.top_files:
|
|
74
|
+
out.append(" Top files:")
|
|
75
|
+
for f in ins.top_files:
|
|
76
|
+
out.append(f" {fmt_bytes(f.resident):>9} res {fmt_bytes(f.mapped):>9} map {f.path}")
|
|
77
|
+
for level, text in ins.evidence:
|
|
78
|
+
out.append(f" [{level}] {text}")
|
|
79
|
+
if ins.cmdline:
|
|
80
|
+
out.append(" cmd: " + " ".join(ins.cmdline)[:400])
|
|
81
|
+
return "\n".join(out)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def to_json(obj) -> str:
|
|
85
|
+
return json.dumps(obj.to_dict() if hasattr(obj, "to_dict") else obj, indent=2)
|
memtaskman/runtime.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Identify what kind of runtime owns a process, from evidence only.
|
|
2
|
+
|
|
3
|
+
Pure functions: input is the executable name, loaded module basenames and the
|
|
4
|
+
command line; output is a label plus evidence lines. Nothing here touches the OS,
|
|
5
|
+
so it is fully unit-testable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import PurePath
|
|
13
|
+
from typing import Iterable
|
|
14
|
+
|
|
15
|
+
from memtaskman.fmt import fmt_bytes, parse_size
|
|
16
|
+
from memtaskman.model import HINT, IDENTIFIED, MEASURED
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class RuntimeInfo:
|
|
21
|
+
name: str
|
|
22
|
+
detail: str = ""
|
|
23
|
+
evidence: list[tuple[str, str]] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def label(self) -> str:
|
|
27
|
+
return f"{self.name} {self.detail}".strip()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_BROWSERS = {
|
|
31
|
+
"chrome": "Chrome",
|
|
32
|
+
"msedge": "Edge",
|
|
33
|
+
"msedgewebview2": "WebView2",
|
|
34
|
+
"brave": "Brave",
|
|
35
|
+
"opera": "Opera",
|
|
36
|
+
"vivaldi": "Vivaldi",
|
|
37
|
+
"chromium": "Chromium",
|
|
38
|
+
"arc": "Arc",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_VM_HOSTS = {
|
|
42
|
+
"vmmem": "Hyper-V / WSL2 guest RAM",
|
|
43
|
+
"vmmemwsl": "WSL2 guest RAM",
|
|
44
|
+
"vmwp": "Hyper-V worker",
|
|
45
|
+
"qemu-system-x86_64": "QEMU guest RAM",
|
|
46
|
+
"qemu-system-aarch64": "QEMU guest RAM",
|
|
47
|
+
"vmware-vmx": "VMware guest RAM",
|
|
48
|
+
"virtualboxvm": "VirtualBox guest RAM",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_DATABASES = {
|
|
52
|
+
"sqlservr": "SQL Server",
|
|
53
|
+
"postgres": "PostgreSQL",
|
|
54
|
+
"mysqld": "MySQL",
|
|
55
|
+
"mariadbd": "MariaDB",
|
|
56
|
+
"redis-server": "Redis",
|
|
57
|
+
"mongod": "MongoDB",
|
|
58
|
+
"memcached": "memcached",
|
|
59
|
+
"clickhouse": "ClickHouse",
|
|
60
|
+
"elasticsearch": "Elasticsearch",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_LLM = {
|
|
64
|
+
"ollama": "Ollama",
|
|
65
|
+
"ollama_llama_server": "Ollama",
|
|
66
|
+
"llama-server": "llama.cpp",
|
|
67
|
+
"llama-cli": "llama.cpp",
|
|
68
|
+
"lm studio": "LM Studio",
|
|
69
|
+
"lms": "LM Studio",
|
|
70
|
+
"foundry": "Foundry Local",
|
|
71
|
+
"vllm": "vLLM",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def stem(name: str) -> str:
|
|
76
|
+
"""Lowercase basename without a trailing .exe."""
|
|
77
|
+
base = PurePath(name.replace("\\", "/")).name.lower()
|
|
78
|
+
return base[:-4] if base.endswith(".exe") else base
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _flag(cmdline: list[str], *names: str) -> str | None:
|
|
82
|
+
"""Value of --name=value / --name value / -Xname style flags."""
|
|
83
|
+
for i, arg in enumerate(cmdline):
|
|
84
|
+
for n in names:
|
|
85
|
+
if arg == n and i + 1 < len(cmdline):
|
|
86
|
+
return cmdline[i + 1]
|
|
87
|
+
if arg.startswith(n + "="):
|
|
88
|
+
return arg[len(n) + 1 :]
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _has_module(mods: set[str], *patterns: str) -> str | None:
|
|
93
|
+
for m in mods:
|
|
94
|
+
for p in patterns:
|
|
95
|
+
if re.fullmatch(p, m):
|
|
96
|
+
return m
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def detect(exe: str, modules: Iterable[str] = (), cmdline: Iterable[str] = ()) -> RuntimeInfo | None:
|
|
101
|
+
name = stem(exe)
|
|
102
|
+
mods = {m.lower() for m in modules}
|
|
103
|
+
args = list(cmdline)
|
|
104
|
+
|
|
105
|
+
# --- Hypervisors: one process standing in for an entire guest OS ---------------
|
|
106
|
+
if name in _VM_HOSTS:
|
|
107
|
+
ev = [(IDENTIFIED, f"{exe} hosts a virtual machine; its memory is the guest's RAM")]
|
|
108
|
+
if name in {"vmmem", "vmmemwsl"}:
|
|
109
|
+
ev.append((HINT, "WSL2: cap it with [wsl2] memory=... in %UserProfile%\\.wslconfig"))
|
|
110
|
+
return RuntimeInfo("VM", _VM_HOSTS[name], ev)
|
|
111
|
+
if name in {"memory compression", "memcompression"}:
|
|
112
|
+
return RuntimeInfo("Kernel", "compression store",
|
|
113
|
+
[(MEASURED, "Windows keeps compressed pages from all processes here")])
|
|
114
|
+
if name in {"system", "registry", "secure system", "kernel_task", "kthreadd"}:
|
|
115
|
+
return RuntimeInfo("Kernel", "", [(IDENTIFIED, "kernel / system process")])
|
|
116
|
+
|
|
117
|
+
# --- Browsers & Electron (Chromium multi-process model) -------------------------
|
|
118
|
+
ptype = _flag(args, "--type")
|
|
119
|
+
chromium_mod = _has_module(mods, r"chrome_elf\.dll", r"chrome\.dll", r"msedge\.dll", r"libcef\.(dll|so)",
|
|
120
|
+
r"msedge_elf\.dll", r"electron.*")
|
|
121
|
+
if ptype or chromium_mod or name in _BROWSERS:
|
|
122
|
+
brand = _BROWSERS.get(name)
|
|
123
|
+
if brand is None:
|
|
124
|
+
brand = "Electron" if (ptype or chromium_mod) else "Chromium"
|
|
125
|
+
sub = _flag(args, "--utility-sub-type")
|
|
126
|
+
detail = ptype or "browser"
|
|
127
|
+
if detail == "gpu-process":
|
|
128
|
+
detail = "gpu"
|
|
129
|
+
if sub:
|
|
130
|
+
detail += ":" + sub.split(".")[-1].replace("Service", "").lower()
|
|
131
|
+
ev = [(IDENTIFIED, f"Chromium process type '{ptype or 'browser'}'"
|
|
132
|
+
+ (f" (module {chromium_mod})" if chromium_mod else ""))]
|
|
133
|
+
js = _flag(args, "--js-flags")
|
|
134
|
+
if js:
|
|
135
|
+
m = re.search(r"--max-old-space-size=(\d+)", js)
|
|
136
|
+
if m:
|
|
137
|
+
ev.append((HINT, f"V8 heap limit {fmt_bytes(int(m.group(1)) * 1024 * 1024)} (--js-flags)"))
|
|
138
|
+
if ptype == "renderer":
|
|
139
|
+
ev.append((HINT, "renderers hold one or more tabs/frames; V8 heap + DOM + images"))
|
|
140
|
+
return RuntimeInfo(brand, detail, ev)
|
|
141
|
+
|
|
142
|
+
# --- Firefox ---------------------------------------------------------------------
|
|
143
|
+
if name in {"firefox", "librewolf", "waterfox"} or _has_module(mods, r"xul\.dll", r"libxul\.so"):
|
|
144
|
+
detail = "content" if "-contentproc" in args else "parent"
|
|
145
|
+
if "-contentproc" in args and args:
|
|
146
|
+
last = args[-1]
|
|
147
|
+
if last in {"tab", "gpu", "socket", "rdd", "utility", "gmplugin"}:
|
|
148
|
+
detail = last
|
|
149
|
+
return RuntimeInfo("Firefox", detail, [(IDENTIFIED, "Gecko (xul) process")])
|
|
150
|
+
|
|
151
|
+
# --- Managed runtimes -------------------------------------------------------------
|
|
152
|
+
jvm = _has_module(mods, r"jvm\.dll", r"libjvm\.(so|dylib)")
|
|
153
|
+
if jvm or name in {"java", "javaw"}:
|
|
154
|
+
ev = [(IDENTIFIED, f"JVM ({jvm or exe})")]
|
|
155
|
+
for a in args:
|
|
156
|
+
if a.startswith("-Xmx"):
|
|
157
|
+
size = parse_size(a[4:])
|
|
158
|
+
ev.append((HINT, f"max heap {fmt_bytes(size)} (-Xmx)" if size else f"max heap {a[4:]} (-Xmx)"))
|
|
159
|
+
elif a.startswith("-XX:MaxRAMPercentage="):
|
|
160
|
+
ev.append((HINT, f"max heap {a.split('=', 1)[1]}% of RAM"))
|
|
161
|
+
main = next((a for a in args if a.endswith(".jar")), "")
|
|
162
|
+
return RuntimeInfo("Java", PurePath(main.replace("\\", "/")).name if main else "", ev)
|
|
163
|
+
|
|
164
|
+
clr = _has_module(mods, r"coreclr\.dll", r"libcoreclr\.(so|dylib)", r"clr\.dll", r"mscorwks\.dll")
|
|
165
|
+
if clr:
|
|
166
|
+
flavour = ".NET" if "coreclr" in clr else ".NET Framework"
|
|
167
|
+
ev = [(IDENTIFIED, f"CLR loaded ({clr}); private bytes include the GC heap")]
|
|
168
|
+
if _has_module(mods, r"wpfgfx.*\.dll", r"presentationcore.*"):
|
|
169
|
+
ev.append((IDENTIFIED, "WPF UI"))
|
|
170
|
+
return RuntimeInfo(flavour, "", ev)
|
|
171
|
+
|
|
172
|
+
node = _has_module(mods, r"libnode\.(dll|so.*|dylib)", r"node\.dll")
|
|
173
|
+
if node or name in {"node", "bun", "deno"}:
|
|
174
|
+
label = {"bun": "Bun", "deno": "Deno"}.get(name, "Node.js")
|
|
175
|
+
ev = [(IDENTIFIED, f"{label} ({node or exe})")]
|
|
176
|
+
v = _flag(args, "--max-old-space-size")
|
|
177
|
+
if v and v.isdigit():
|
|
178
|
+
ev.append((HINT, f"V8 heap limit {fmt_bytes(int(v) * 1024 * 1024)}"))
|
|
179
|
+
script = next((a for a in args[1:] if not a.startswith("-")), "")
|
|
180
|
+
return RuntimeInfo(label, PurePath(script.replace("\\", "/")).name if script else "", ev)
|
|
181
|
+
|
|
182
|
+
py = _has_module(mods, r"python3\d*\.dll", r"libpython3[\d.]*\.(so.*|dylib)")
|
|
183
|
+
if py or re.fullmatch(r"pythonw?(3(\.\d+)?)?", name):
|
|
184
|
+
ver = ""
|
|
185
|
+
if py:
|
|
186
|
+
m = re.search(r"python(\d)\.?(\d+)", py)
|
|
187
|
+
if m:
|
|
188
|
+
ver = f"{m.group(1)}.{m.group(2)}"
|
|
189
|
+
script = next((a for a in args[1:] if not a.startswith("-")), "")
|
|
190
|
+
if "-m" in args:
|
|
191
|
+
i = args.index("-m")
|
|
192
|
+
if i + 1 < len(args):
|
|
193
|
+
script = args[i + 1]
|
|
194
|
+
ev = [(IDENTIFIED, f"CPython {ver}".strip() + (f" ({py})" if py else ""))]
|
|
195
|
+
if _has_module(mods, r"torch_cpu\.dll", r"libtorch_cpu\.so", r"c10\.dll"):
|
|
196
|
+
ev.append((IDENTIFIED, "PyTorch loaded (tensors on CPU count as private bytes)"))
|
|
197
|
+
return RuntimeInfo(f"Python {ver}".strip(), PurePath(script.replace("\\", "/")).name if script else "", ev)
|
|
198
|
+
|
|
199
|
+
# --- Local LLM servers (CPU-offloaded weights live in RAM) -------------------------
|
|
200
|
+
for key, label in _LLM.items():
|
|
201
|
+
if name == key or name.startswith(key + "-"):
|
|
202
|
+
ev = [(IDENTIFIED, f"{label} server")]
|
|
203
|
+
model = _flag(args, "-m", "--model")
|
|
204
|
+
if model:
|
|
205
|
+
ev.append((HINT, f"model {PurePath(model.replace(chr(92), '/')).name}"))
|
|
206
|
+
if _flag(args, "--mlock") is not None or "--mlock" in args:
|
|
207
|
+
ev.append((HINT, "--mlock: weights pinned in RAM"))
|
|
208
|
+
if "--no-mmap" in args:
|
|
209
|
+
ev.append((HINT, "--no-mmap: weights copied into private memory"))
|
|
210
|
+
else:
|
|
211
|
+
ev.append((HINT, "weights are usually mmap'd: they appear under Mapped files"))
|
|
212
|
+
return RuntimeInfo("LLM", label, ev)
|
|
213
|
+
|
|
214
|
+
# --- Databases ----------------------------------------------------------------------
|
|
215
|
+
if name in _DATABASES:
|
|
216
|
+
ev = [(IDENTIFIED, _DATABASES[name])]
|
|
217
|
+
mm = _flag(args, "--maxmemory")
|
|
218
|
+
if mm:
|
|
219
|
+
ev.append((HINT, f"maxmemory {mm}"))
|
|
220
|
+
if name == "sqlservr":
|
|
221
|
+
ev.append((HINT, "SQL Server grows its buffer pool to 'max server memory'"))
|
|
222
|
+
return RuntimeInfo("DB", _DATABASES[name], ev)
|
|
223
|
+
|
|
224
|
+
return None
|