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/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