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/win32.py ADDED
@@ -0,0 +1,390 @@
1
+ """Windows memory sources via ctypes (no extra dependencies).
2
+
3
+ * PDH ``\\Memory\\*`` counters: standby / modified / free lists, pools, fault rates.
4
+ * ``GetPerformanceInfo``: commit charge and limit.
5
+ * ``VirtualQueryEx`` walk: committed bytes per region type (image / mapped / private).
6
+ * ``QueryWorkingSet``: which of those pages are resident right now, and shared or not.
7
+ * ``EnumProcessModulesEx``: loaded DLL names for runtime identification.
8
+
9
+ Everything degrades to ``None`` when access is denied (elevated or protected
10
+ processes when running unelevated).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import bisect
16
+ import ctypes
17
+ import mmap
18
+ import os
19
+ import string
20
+ import sys
21
+ import time
22
+ from ctypes import wintypes as wt
23
+
24
+ from memtaskman.model import (
25
+ CAT_HEAP,
26
+ CAT_IMAGE,
27
+ CAT_MAPPED,
28
+ CAT_SHM,
29
+ CAT_STACK,
30
+ DERIVED,
31
+ MEASURED,
32
+ Category,
33
+ FileMapping,
34
+ Inspection,
35
+ )
36
+
37
+ assert sys.platform == "win32"
38
+
39
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
40
+ pdh = ctypes.WinDLL("pdh")
41
+
42
+ PAGE_SIZE = mmap.PAGESIZE
43
+
44
+ PROCESS_QUERY_INFORMATION = 0x0400
45
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
46
+ PROCESS_VM_READ = 0x0010
47
+ MEM_COMMIT = 0x1000
48
+ MEM_RESERVE = 0x2000
49
+ MEM_IMAGE = 0x1000000
50
+ MEM_MAPPED = 0x40000
51
+ MEM_PRIVATE = 0x20000
52
+ PAGE_GUARD = 0x100
53
+ ERROR_BAD_LENGTH = 24
54
+ LIST_MODULES_ALL = 0x03
55
+
56
+ kernel32.OpenProcess.restype = wt.HANDLE
57
+ kernel32.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]
58
+ kernel32.CloseHandle.argtypes = [wt.HANDLE]
59
+
60
+
61
+ class MEMORY_BASIC_INFORMATION(ctypes.Structure):
62
+ _fields_ = [
63
+ ("BaseAddress", ctypes.c_void_p),
64
+ ("AllocationBase", ctypes.c_void_p),
65
+ ("AllocationProtect", wt.DWORD),
66
+ ("PartitionId", wt.WORD),
67
+ ("RegionSize", ctypes.c_size_t),
68
+ ("State", wt.DWORD),
69
+ ("Protect", wt.DWORD),
70
+ ("Type", wt.DWORD),
71
+ ]
72
+
73
+
74
+ kernel32.VirtualQueryEx.restype = ctypes.c_size_t
75
+ kernel32.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MEMORY_BASIC_INFORMATION), ctypes.c_size_t]
76
+ kernel32.K32GetMappedFileNameW.restype = wt.DWORD
77
+ kernel32.K32GetMappedFileNameW.argtypes = [wt.HANDLE, ctypes.c_void_p, wt.LPWSTR, wt.DWORD]
78
+ kernel32.K32QueryWorkingSet.restype = wt.BOOL
79
+ kernel32.K32QueryWorkingSet.argtypes = [wt.HANDLE, ctypes.c_void_p, wt.DWORD]
80
+ kernel32.K32EnumProcessModulesEx.restype = wt.BOOL
81
+ kernel32.K32EnumProcessModulesEx.argtypes = [wt.HANDLE, ctypes.POINTER(wt.HMODULE), wt.DWORD, ctypes.POINTER(wt.DWORD), wt.DWORD]
82
+ kernel32.K32GetModuleBaseNameW.restype = wt.DWORD
83
+ kernel32.K32GetModuleBaseNameW.argtypes = [wt.HANDLE, wt.HMODULE, wt.LPWSTR, wt.DWORD]
84
+ kernel32.QueryDosDeviceW.restype = wt.DWORD
85
+ kernel32.QueryDosDeviceW.argtypes = [wt.LPCWSTR, wt.LPWSTR, wt.DWORD]
86
+
87
+
88
+ class PERFORMANCE_INFORMATION(ctypes.Structure):
89
+ _fields_ = [
90
+ ("cb", wt.DWORD),
91
+ ("CommitTotal", ctypes.c_size_t),
92
+ ("CommitLimit", ctypes.c_size_t),
93
+ ("CommitPeak", ctypes.c_size_t),
94
+ ("PhysicalTotal", ctypes.c_size_t),
95
+ ("PhysicalAvailable", ctypes.c_size_t),
96
+ ("SystemCache", ctypes.c_size_t),
97
+ ("KernelTotal", ctypes.c_size_t),
98
+ ("KernelPaged", ctypes.c_size_t),
99
+ ("KernelNonpaged", ctypes.c_size_t),
100
+ ("PageSize", ctypes.c_size_t),
101
+ ("HandleCount", wt.DWORD),
102
+ ("ProcessCount", wt.DWORD),
103
+ ("ThreadCount", wt.DWORD),
104
+ ]
105
+
106
+
107
+ kernel32.K32GetPerformanceInfo.restype = wt.BOOL
108
+ kernel32.K32GetPerformanceInfo.argtypes = [ctypes.POINTER(PERFORMANCE_INFORMATION), wt.DWORD]
109
+
110
+
111
+ def performance_info() -> dict[str, int] | None:
112
+ pi = PERFORMANCE_INFORMATION()
113
+ pi.cb = ctypes.sizeof(pi)
114
+ if not kernel32.K32GetPerformanceInfo(ctypes.byref(pi), pi.cb):
115
+ return None
116
+ ps = pi.PageSize
117
+ return {
118
+ "commit_total": pi.CommitTotal * ps,
119
+ "commit_limit": pi.CommitLimit * ps,
120
+ "commit_peak": pi.CommitPeak * ps,
121
+ "physical_total": pi.PhysicalTotal * ps,
122
+ "physical_available": pi.PhysicalAvailable * ps,
123
+ "system_cache": pi.SystemCache * ps,
124
+ "kernel_paged": pi.KernelPaged * ps,
125
+ "kernel_nonpaged": pi.KernelNonpaged * ps,
126
+ }
127
+
128
+
129
+ # --------------------------------------------------------------------------- PDH
130
+
131
+
132
+ class _PdhValue(ctypes.Union):
133
+ _fields_ = [("longValue", ctypes.c_long), ("doubleValue", ctypes.c_double), ("largeValue", ctypes.c_longlong)]
134
+
135
+
136
+ class PDH_FMT_COUNTERVALUE(ctypes.Structure):
137
+ _fields_ = [("CStatus", wt.DWORD), ("u", _PdhValue)]
138
+
139
+
140
+ PDH_FMT_DOUBLE = 0x00000200
141
+ PDH_FMT_NOCAP100 = 0x00008000
142
+ pdh.PdhOpenQueryW.restype = ctypes.c_long
143
+ pdh.PdhOpenQueryW.argtypes = [wt.LPCWSTR, ctypes.c_size_t, ctypes.POINTER(wt.HANDLE)]
144
+ pdh.PdhAddEnglishCounterW.restype = ctypes.c_long
145
+ pdh.PdhAddEnglishCounterW.argtypes = [wt.HANDLE, wt.LPCWSTR, ctypes.c_size_t, ctypes.POINTER(wt.HANDLE)]
146
+ pdh.PdhCollectQueryData.restype = ctypes.c_long
147
+ pdh.PdhCollectQueryData.argtypes = [wt.HANDLE]
148
+ pdh.PdhGetFormattedCounterValue.restype = ctypes.c_long
149
+ pdh.PdhGetFormattedCounterValue.argtypes = [wt.HANDLE, wt.DWORD, ctypes.POINTER(wt.DWORD), ctypes.POINTER(PDH_FMT_COUNTERVALUE)]
150
+ pdh.PdhCloseQuery.argtypes = [wt.HANDLE]
151
+
152
+ MEMORY_COUNTERS = {
153
+ "standby_core": r"\Memory\Standby Cache Core Bytes",
154
+ "standby_normal": r"\Memory\Standby Cache Normal Priority Bytes",
155
+ "standby_reserve": r"\Memory\Standby Cache Reserve Bytes",
156
+ "modified": r"\Memory\Modified Page List Bytes",
157
+ "free_zero": r"\Memory\Free & Zero Page List Bytes",
158
+ "cache": r"\Memory\Cache Bytes",
159
+ "pool_paged": r"\Memory\Pool Paged Bytes",
160
+ "pool_nonpaged": r"\Memory\Pool Nonpaged Bytes",
161
+ "driver_resident": r"\Memory\System Driver Resident Bytes",
162
+ "code_resident": r"\Memory\System Code Resident Bytes",
163
+ "page_faults_ps": r"\Memory\Page Faults/sec",
164
+ "pages_input_ps": r"\Memory\Pages Input/sec",
165
+ "pages_output_ps": r"\Memory\Pages Output/sec",
166
+ "pagefile_pct": r"\Paging File(_Total)\% Usage",
167
+ }
168
+
169
+
170
+ class PdhQuery:
171
+ """A small wrapper that keeps one PDH query open across samples."""
172
+
173
+ def __init__(self, counters: dict[str, str] = MEMORY_COUNTERS):
174
+ self.handle = wt.HANDLE()
175
+ self.counters: dict[str, wt.HANDLE] = {}
176
+ if pdh.PdhOpenQueryW(None, 0, ctypes.byref(self.handle)) != 0:
177
+ self.handle = None
178
+ return
179
+ for key, path in counters.items():
180
+ h = wt.HANDLE()
181
+ if pdh.PdhAddEnglishCounterW(self.handle, path, 0, ctypes.byref(h)) == 0:
182
+ self.counters[key] = h
183
+ pdh.PdhCollectQueryData(self.handle) # prime rate counters
184
+
185
+ def sample(self) -> dict[str, float]:
186
+ out: dict[str, float] = {}
187
+ if not self.handle or pdh.PdhCollectQueryData(self.handle) != 0:
188
+ return out
189
+ val = PDH_FMT_COUNTERVALUE()
190
+ for key, h in self.counters.items():
191
+ if pdh.PdhGetFormattedCounterValue(h, PDH_FMT_DOUBLE | PDH_FMT_NOCAP100, None, ctypes.byref(val)) == 0:
192
+ out[key] = val.u.doubleValue
193
+ return out
194
+
195
+ def close(self) -> None:
196
+ if self.handle:
197
+ pdh.PdhCloseQuery(self.handle)
198
+ self.handle = None
199
+
200
+
201
+ # --------------------------------------------------------------------------- processes
202
+
203
+
204
+ def _open(pid: int, access: int) -> int | None:
205
+ h = kernel32.OpenProcess(access, False, pid)
206
+ return h or None
207
+
208
+
209
+ def module_names(pid: int) -> list[str] | None:
210
+ """Basenames of loaded modules, or None when the process cannot be opened."""
211
+ h = _open(pid, PROCESS_QUERY_INFORMATION | PROCESS_VM_READ)
212
+ if not h:
213
+ return None
214
+ try:
215
+ count = 512
216
+ while True:
217
+ arr = (wt.HMODULE * count)()
218
+ needed = wt.DWORD()
219
+ if not kernel32.K32EnumProcessModulesEx(h, arr, ctypes.sizeof(arr), ctypes.byref(needed), LIST_MODULES_ALL):
220
+ return None
221
+ n = needed.value // ctypes.sizeof(wt.HMODULE)
222
+ if n <= count:
223
+ break
224
+ count = n + 64
225
+ buf = ctypes.create_unicode_buffer(260)
226
+ names = []
227
+ for i in range(n):
228
+ if kernel32.K32GetModuleBaseNameW(h, arr[i], buf, 260):
229
+ names.append(buf.value)
230
+ return names
231
+ finally:
232
+ kernel32.CloseHandle(h)
233
+
234
+
235
+ _device_map: list[tuple[str, str]] | None = None
236
+
237
+
238
+ def _dos_devices() -> list[tuple[str, str]]:
239
+ global _device_map
240
+ if _device_map is None:
241
+ m = []
242
+ buf = ctypes.create_unicode_buffer(1024)
243
+ for letter in string.ascii_uppercase:
244
+ if kernel32.QueryDosDeviceW(f"{letter}:", buf, 1024):
245
+ m.append((buf.value + "\\", f"{letter}:\\"))
246
+ _device_map = m
247
+ return _device_map
248
+
249
+
250
+ def device_to_dos(path: str) -> str:
251
+ for dev, drive in _dos_devices():
252
+ if path.startswith(dev):
253
+ return drive + path[len(dev):]
254
+ return path
255
+
256
+
257
+ def _walk_regions(h) -> list[tuple[int, int, int, int, int, int]]:
258
+ """(base, size, state, type, protect, allocation_base) for every region."""
259
+ regions = []
260
+ mbi = MEMORY_BASIC_INFORMATION()
261
+ addr = 0
262
+ sz = ctypes.sizeof(mbi)
263
+ limit = 1 << 47 if ctypes.sizeof(ctypes.c_void_p) == 8 else 1 << 31
264
+ while addr < limit:
265
+ if not kernel32.VirtualQueryEx(h, ctypes.c_void_p(addr), ctypes.byref(mbi), sz):
266
+ break
267
+ base = mbi.BaseAddress or 0
268
+ size = mbi.RegionSize
269
+ regions.append((base, size, mbi.State, mbi.Type, mbi.Protect, mbi.AllocationBase or 0))
270
+ nxt = base + size
271
+ if nxt <= addr:
272
+ break
273
+ addr = nxt
274
+ return regions
275
+
276
+
277
+ def _working_set(h) -> list[int] | None:
278
+ """Raw PSAPI_WORKING_SET_BLOCK values, or None on failure."""
279
+ ptr = ctypes.sizeof(ctypes.c_size_t)
280
+ count = 4096
281
+ for _ in range(6):
282
+ buf = (ctypes.c_size_t * (count + 1))()
283
+ if kernel32.K32QueryWorkingSet(h, buf, ctypes.sizeof(buf)):
284
+ n = buf[0]
285
+ return list(buf[1 : 1 + n])
286
+ if ctypes.get_last_error() != ERROR_BAD_LENGTH:
287
+ return None
288
+ count = int(buf[0] * 1.25) + 4096
289
+ return None
290
+
291
+
292
+ def inspect(pid: int, name: str, *, top_files: int = 12) -> Inspection:
293
+ t0 = time.perf_counter()
294
+ ins = Inspection(pid=pid, name=name)
295
+ h = _open(pid, PROCESS_QUERY_INFORMATION | PROCESS_VM_READ)
296
+ if not h:
297
+ ins.error = "access denied (run elevated to inspect this process)"
298
+ return ins
299
+ try:
300
+ regions = _walk_regions(h)
301
+ name_buf = ctypes.create_unicode_buffer(1024)
302
+ file_of_alloc: dict[int, str | None] = {}
303
+
304
+ cats = {c: Category(c) for c in (CAT_HEAP, CAT_STACK, CAT_IMAGE, CAT_MAPPED, CAT_SHM)}
305
+ files: dict[str, FileMapping] = {}
306
+ reserved = 0
307
+ # Committed regions only, in address order, for page attribution.
308
+ starts: list[int] = []
309
+ spans: list[tuple[int, str, str | None]] = [] # (end, category, file path)
310
+
311
+ # Thread stacks: a private allocation that contains a PAGE_GUARD region.
312
+ stack_allocs = {ab for (_b, _s, st, ty, pr, ab) in regions if st == MEM_COMMIT and ty == MEM_PRIVATE and pr & PAGE_GUARD}
313
+
314
+ for base, size, state, typ, _prot, alloc in regions:
315
+ if state == MEM_RESERVE:
316
+ reserved += size
317
+ continue
318
+ if state != MEM_COMMIT:
319
+ continue
320
+ path = None
321
+ if typ == MEM_PRIVATE:
322
+ cat = CAT_STACK if alloc in stack_allocs else CAT_HEAP
323
+ else:
324
+ if alloc not in file_of_alloc:
325
+ n = kernel32.K32GetMappedFileNameW(h, ctypes.c_void_p(base), name_buf, 1024)
326
+ file_of_alloc[alloc] = device_to_dos(name_buf.value) if n else None
327
+ path = file_of_alloc[alloc]
328
+ if typ == MEM_IMAGE:
329
+ cat = CAT_IMAGE
330
+ elif path:
331
+ cat = CAT_MAPPED
332
+ else:
333
+ cat = CAT_SHM # pagefile-backed section: shared memory
334
+ c = cats[cat]
335
+ c.mapped += size
336
+ c.regions += 1
337
+ if path:
338
+ fm = files.get(path)
339
+ if fm is None:
340
+ fm = files[path] = FileMapping(path, "image" if typ == MEM_IMAGE else "mapped")
341
+ fm.mapped += size
342
+ starts.append(base)
343
+ spans.append((base + size, cat, path))
344
+
345
+ ins.reserved = reserved
346
+ ins.evidence.append((MEASURED, f"{len(regions)} regions walked with VirtualQueryEx"))
347
+
348
+ ws = _working_set(h)
349
+ if ws is not None:
350
+ for c in cats.values():
351
+ c.resident = 0
352
+ c.resident_shared = 0
353
+ for fm in files.values():
354
+ fm.resident = 0
355
+ unattributed = 0
356
+ for entry in ws:
357
+ va = (entry >> 12) << 12
358
+ i = bisect.bisect_right(starts, va) - 1
359
+ if i < 0 or va >= spans[i][0]:
360
+ unattributed += 1
361
+ continue
362
+ _end, cat, path = spans[i]
363
+ c = cats[cat]
364
+ c.resident += PAGE_SIZE
365
+ if entry & 0x100:
366
+ c.resident_shared += PAGE_SIZE
367
+ if path:
368
+ files[path].resident += PAGE_SIZE
369
+ ins.evidence.append((DERIVED, f"{len(ws):,} resident pages attributed via QueryWorkingSet"))
370
+ if unattributed:
371
+ ins.evidence.append((DERIVED, f"{unattributed:,} resident pages are page tables / outside user regions"))
372
+ else:
373
+ ins.evidence.append((MEASURED, "working set detail unavailable; showing committed sizes only"))
374
+
375
+ ins.categories = [c for c in cats.values() if c.mapped]
376
+ key = (lambda f: f.resident or 0) if ws is not None else (lambda f: f.mapped)
377
+ ins.top_files = sorted(files.values(), key=key, reverse=True)[:top_files]
378
+ for f in ins.top_files:
379
+ f.path = _short_path(f.path)
380
+ finally:
381
+ kernel32.CloseHandle(h)
382
+ ins.elapsed = time.perf_counter() - t0
383
+ return ins
384
+
385
+
386
+ def _short_path(p: str) -> str:
387
+ win = os.environ.get("SystemRoot", r"C:\Windows")
388
+ if p.lower().startswith(win.lower()):
389
+ return "%SystemRoot%" + p[len(win):]
390
+ return p
@@ -0,0 +1,186 @@
1
+ Metadata-Version: 2.5
2
+ Name: memtaskman
3
+ Version: 0.1.0
4
+ Summary: An htop for what's inside your RAM, not just how full it is.
5
+ Author-email: Yogpod <yogpod332@gmail.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Environment :: Console
9
+ Classifier: Operating System :: MacOS
10
+ Classifier: Operating System :: Microsoft :: Windows
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Topic :: System :: Monitoring
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: psutil>=5.9
15
+ Requires-Dist: textual>=1.0
16
+ Description-Content-Type: text/markdown
17
+
18
+ # memtaskman
19
+
20
+ [![CI](https://github.com/Yogpod/memtaskman/actions/workflows/ci.yml/badge.svg)](https://github.com/Yogpod/memtaskman/actions/workflows/ci.yml)
21
+
22
+ An `htop` for what's **inside** your RAM, not just how full it is.
23
+
24
+ Task Manager tells you Chrome uses 5 GB. memtaskman tells you which of those bytes are
25
+ heap, which are DLL code shared with every other process, which are memory-mapped files
26
+ (fonts, caches, model weights), which are thread stacks, and how much is actually resident
27
+ versus merely committed. It also shows *where* the rest of your RAM is: standby cache,
28
+ modified pages, the compression store, kernel pools and commit charge.
29
+
30
+ Inspired by [gputaskman](https://github.com/shanselman/gputaskman), which does the same for VRAM.
31
+
32
+ > Early, experimental software. Windows is the primary target, Linux is supported (tested on Ubuntu under WSL2), macOS
33
+ > gets the basic psutil view without the address-space inspector.
34
+
35
+ ## What you get
36
+
37
+ - **System bar**: RAM split into *In use / Modified / Standby / Free* (Windows) or
38
+ *In use / Cache / Free* (Linux), plus commit charge and swap/pagefile bars.
39
+ - **Eight history charts**: RAM in use, commit, standby/cache, compressed, swap,
40
+ kernel pools, page faults/s and hard faults/s. `c` cycles to a single large chart.
41
+ - **System panel**: available memory, modified/dirty, standby/page cache, kernel paged
42
+ and nonpaged pools (slab on Linux), compression store, driver-resident bytes, commit peak.
43
+ - **Process table**: resident (working set / RSS), private commit (Windows) or anonymous
44
+ resident (Linux), a smoothed **growth rate** (a steadily positive number is what a leak
45
+ looks like), page faults/s, kernel pool or swap, and the identified **runtime**.
46
+ On Linux there is also a **PSS** column (resident memory with shared pages split fairly
47
+ between the processes sharing them, so the column sums to real RAM use), read from
48
+ `smaps_rollup` for the visible rows only. Other users' processes show `-` unless you run as root.
49
+ - **Group by app** (`g`): every `chrome.exe`/`brave.exe`/`Discord.exe` summed into one row.
50
+ - **Inspector** for the selected process, built from a page-level walk of its address space:
51
+
52
+ | Category | Meaning |
53
+ |---------------------|-------------------------------------------------------------------|
54
+ | Private (heap/anon) | heaps, GC heaps, JIT code, anonymous mmap — memory only it owns |
55
+ | Stacks | thread stacks |
56
+ | Images | exe / DLL / .so pages, mostly shared with other processes |
57
+ | Mapped files | memory-mapped files: font caches, databases, mmap'd model weights |
58
+ | Shared memory | pagefile-backed sections, `/dev/shm`, memfd |
59
+
60
+ For each: bytes committed/mapped, bytes resident, and how many resident bytes are shared,
61
+ plus the largest file-backed mappings and the address space that is only reserved.
62
+
63
+ ### Runtime identification
64
+
65
+ Runtimes are recognised from loaded modules, the executable and the command line, and every
66
+ claim is tagged with how strong the evidence is: **measured** (OS counter), **derived**
67
+ (computed by memtaskman), **identified** (from loaded modules), **hint** (a configured limit
68
+ such as `-Xmx` or `--max-old-space-size`, which is not usage).
69
+
70
+ Recognised: Chromium browsers and their process types (Chrome, Edge, Brave, Opera, Vivaldi,
71
+ WebView2, Electron apps: renderer / gpu / utility:network …), Firefox, Java, .NET and
72
+ .NET Framework (including chocolatey shims), Node.js / Bun / Deno, CPython (version from
73
+ the DLL, PyTorch detection), local LLM servers (Ollama, llama.cpp, LM Studio, vLLM,
74
+ Foundry Local), databases (SQL Server, PostgreSQL, MySQL, Redis, MongoDB …), hypervisor
75
+ hosts (`vmmem`/`vmmemWSL`, QEMU, VMware, VirtualBox) and the Windows compression store.
76
+
77
+ ## Install & run
78
+
79
+ ```bash
80
+ uv tool install . # from a clone; or: pipx install .
81
+ memtaskman # or: mtm
82
+ ```
83
+
84
+ Without installing:
85
+
86
+ ```bash
87
+ uv run mtm
88
+ ```
89
+
90
+ Run from an elevated terminal (or as root on Linux) to inspect processes owned by other users
91
+ and services; otherwise those show "access denied" in the inspector but still appear in the table.
92
+
93
+ ## Keys
94
+
95
+ | Key | Action |
96
+ |------------|------------------------------------------|
97
+ | ↑ / ↓ | select a process and inspect it |
98
+ | `s` | cycle sort: resident, PSS (Linux), private, growth, faults, name, pid |
99
+ | `g` | group processes by executable |
100
+ | `a` | top 50 / all processes |
101
+ | `c` | cycle charts (all, or one metric) |
102
+ | `m` | slim mode (table only) |
103
+ | `p` | pause / resume |
104
+ | `?` | explanations of every counter |
105
+ | `q` | quit |
106
+
107
+ ## Command line
108
+
109
+ ```text
110
+ mtm interactive TUI
111
+ mtm -i 2 sample every 2 seconds
112
+ mtm --once [--top N|--all] print a text report and exit
113
+ mtm --json [--top N|--all] print a JSON snapshot and exit
114
+ mtm --no-system skip system-wide counters
115
+ mtm inspect PID [--json] address-space breakdown of one process
116
+ mtm record out.jsonl [-i 5] [-n COUNT] [--all] append snapshots (JSON Lines)
117
+ mtm diff a.json b.json what grew, appeared or went away between snapshots
118
+ ```
119
+
120
+ Tip: take both snapshots with `--all` before using `diff`, otherwise processes outside the
121
+ top N look like they appeared or vanished.
122
+
123
+ ## How it measures
124
+
125
+ | Source | Platform | Used for |
126
+ |--------|----------|----------|
127
+ | PDH `\Memory\*` counters | Windows | standby (3 priorities), modified, free+zero, cache, pools, fault rates |
128
+ | `GetPerformanceInfo` | Windows | commit charge / limit / peak |
129
+ | `GetProcessMemoryInfo` (via psutil) | Windows | working set, private commit, pools, fault counts |
130
+ | `VirtualQueryEx` | Windows | every region: committed vs reserved, image / mapped / private, stacks (guard pages) |
131
+ | `GetMappedFileName` | Windows | which file backs each image/mapped region |
132
+ | `QueryWorkingSet` | Windows | every resident page and its shared bit, attributed back to regions |
133
+ | `EnumProcessModulesEx` | Windows | loaded DLLs for runtime identification |
134
+ | `/proc/meminfo`, `/proc/vmstat` | Linux | cache, dirty, slab, zswap, commit, fault rates |
135
+ | `/proc/<pid>/status`, `stat` | Linux | RssAnon / RssFile / RssShmem, VmSwap, VmHWM, faults |
136
+ | `/proc/<pid>/smaps_rollup` | Linux | PSS and USS (private clean + dirty) for visible rows, refreshed every 2 s at most |
137
+ | `/proc/<pid>/smaps`, `maps` | Linux | per-mapping Rss / Shared / Swap, loaded libraries |
138
+
139
+ Nothing reads the *contents* of another process's memory; only metadata about its pages.
140
+
141
+ Caveats:
142
+
143
+ - Resident totals in group mode double count pages shared between members (DLLs, shared
144
+ memory). Private totals do not overlap.
145
+ - On Windows, *Private* is commit charge: it can exceed the working set when pages are
146
+ paged out or compressed.
147
+ - The compression store is reported as the working set of the `MemCompression` process.
148
+ - The inspector walk takes ~0.1-1 s for very large processes; it runs in the background and
149
+ refreshes every 5 seconds.
150
+
151
+ ## Development
152
+
153
+ ```bash
154
+ uv sync
155
+ uv run python -m unittest discover -s tests -v # hardware-independent unit tests
156
+ uv run python tools/smoke.py # live check: counters, inspector, TUI, every CLI command
157
+ uv build
158
+ ```
159
+
160
+ CI (`.github/workflows/ci.yml`) runs the unit tests and the smoke test on Windows, Linux and
161
+ macOS with Python 3.11 and 3.14, then builds the wheel and runs it. To release, bump `version`
162
+ in `pyproject.toml`, then push a matching tag (`git tag v0.2.0 && git push main v0.2.0`). The release
163
+ workflow tests, builds, attaches the sdist and wheel to a GitHub release and publishes them
164
+ to PyPI.
165
+
166
+ Layout:
167
+
168
+ ```
169
+ src/memtaskman/
170
+ app.py Textual TUI
171
+ cli.py argument parsing, --once / --json / inspect / record / diff
172
+ collector.py sampling, growth & fault rates, runtime cache, group-by-app
173
+ win32.py ctypes: PDH, GetPerformanceInfo, VirtualQueryEx, QueryWorkingSet, modules
174
+ linux.py /proc parsers (pure) and readers
175
+ runtime.py runtime identification from modules + command line (pure)
176
+ model.py dataclasses shared by everything
177
+ report.py text / JSON output
178
+ diff.py snapshot comparison
179
+ fmt.py byte & rate formatting
180
+ tests/ hardware-independent unit tests
181
+ tools/smoke.py end-to-end check against the live machine (used by CI)
182
+ ```
183
+
184
+ ## License
185
+
186
+ MIT
@@ -0,0 +1,17 @@
1
+ memtaskman/__init__.py,sha256=nlqtUOtZtfy___GkpuegJVmpxqMy6-PDSmOAN2DTl_M,102
2
+ memtaskman/__main__.py,sha256=K0PT5TequdVH7TjO-rNWrQ0JuYHjRWxVC83H6R70F80,58
3
+ memtaskman/app.py,sha256=noVQXhdauHuuaAz5m08IH3tO-BJtAQCkC-2r_7kv-SI,22558
4
+ memtaskman/cli.py,sha256=NcWxFzfMNh0oKu1O9-YqZXSJMHNJTh24xRSuFZUDwPk,4871
5
+ memtaskman/collector.py,sha256=xqsiWuWyb1lqjFRmw2Kw__OipblJob_W5-2o2cKgNGo,15350
6
+ memtaskman/diff.py,sha256=CMddnpJbNeF-U3kkOZ4CCDsuNl5PB1lvfbNgZOtUVvM,2380
7
+ memtaskman/fmt.py,sha256=8qGLX-PhVbQRWNk0K15xwIJodbwYx0Nsh-JiZHsSym0,1868
8
+ memtaskman/linux.py,sha256=5T8lNLXqk8FMayxQ9G0RajY8rkcIpbrfG-IVa3c0PXY,6336
9
+ memtaskman/model.py,sha256=fMfF_CEBK4qpepNRBDlY4fEJNPkz642hH1_EqCCEE-Q,4817
10
+ memtaskman/report.py,sha256=UO_tpYwVKE4wjTuDyunci94rdU8Vf4m8Jf00oNInEKI,3677
11
+ memtaskman/runtime.py,sha256=PuqVwXINr8nB4fGfPiDKw65iXL8s_62qrJlDRskNHUA,9227
12
+ memtaskman/win32.py,sha256=2T9BKXdc3WVwew7JKV9kDOOtdbiVgAXflpZFv2hI0Bc,14200
13
+ memtaskman-0.1.0.dist-info/METADATA,sha256=FQRMPNG-tpQgR-i-VUtjBW7XFzXZRYFKyYJ90_HDoFs,9218
14
+ memtaskman-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
15
+ memtaskman-0.1.0.dist-info/entry_points.txt,sha256=tXi9SJjMmOYxmujVPSwkE9oEefC34flI_WT-uNEh074,77
16
+ memtaskman-0.1.0.dist-info/licenses/LICENSE,sha256=jGvhYSbQYXhJE1EjsyhFCB0o1ygv8GVNGrQ2WEckJm8,1063
17
+ memtaskman-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ memtaskman = memtaskman.cli:main
3
+ mtm = memtaskman.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yogpod
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.