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/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
|
+
[](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,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.
|