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/__init__.py
ADDED
memtaskman/__main__.py
ADDED
memtaskman/app.py
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
"""Textual TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import deque
|
|
7
|
+
from pathlib import PureWindowsPath
|
|
8
|
+
|
|
9
|
+
from rich.console import Group
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
from textual import work
|
|
13
|
+
from textual.app import App, ComposeResult
|
|
14
|
+
from textual.binding import Binding
|
|
15
|
+
from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
|
|
16
|
+
from textual.screen import ModalScreen
|
|
17
|
+
from textual.widgets import DataTable, Footer, Sparkline, Static
|
|
18
|
+
|
|
19
|
+
from memtaskman.collector import LINUX, WINDOWS, Collector, group_by_app, sort_procs
|
|
20
|
+
from memtaskman.fmt import fmt_bytes, fmt_count_rate, fmt_rate, pct
|
|
21
|
+
from memtaskman.model import DERIVED, HINT, IDENTIFIED, MEASURED, Inspection, ProcessMem, Snapshot, SystemMemory
|
|
22
|
+
|
|
23
|
+
HISTORY = 120
|
|
24
|
+
TOP_N = 50
|
|
25
|
+
INSPECT_EVERY = 5.0
|
|
26
|
+
|
|
27
|
+
C_USED = "#4e9af1"
|
|
28
|
+
C_MOD = "#e5a50a"
|
|
29
|
+
C_STANDBY = "#57a773"
|
|
30
|
+
C_FREE = "#3a3f4b"
|
|
31
|
+
C_COMMIT = "#b48ead"
|
|
32
|
+
C_SWAP = "#d08770"
|
|
33
|
+
|
|
34
|
+
CHARTS = [
|
|
35
|
+
("used", "RAM in use", "bytes"),
|
|
36
|
+
("commit", "Commit charge", "bytes"),
|
|
37
|
+
("cache", "Standby / cache", "bytes"),
|
|
38
|
+
("compressed", "Compressed", "bytes"),
|
|
39
|
+
("swap", "Swap / pagefile used", "bytes"),
|
|
40
|
+
("kernel", "Kernel pools", "bytes"),
|
|
41
|
+
("faults", "Page faults/s", "rate"),
|
|
42
|
+
("hard", "Hard faults/s (disk)", "rate"),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
LEVEL_STYLE = {MEASURED: "green", DERIVED: "cyan", IDENTIFIED: "magenta", HINT: "yellow"}
|
|
46
|
+
|
|
47
|
+
HELP = f"""\
|
|
48
|
+
[b]memtaskman[/b] — what is inside your RAM
|
|
49
|
+
|
|
50
|
+
[b]System bar[/b]
|
|
51
|
+
[{C_USED}]■ In use[/] pages owned by processes, the kernel and drivers
|
|
52
|
+
[{C_MOD}]■ Modified[/] dirty pages that must be written to disk before reuse
|
|
53
|
+
[{C_STANDBY}]■ Standby/cache[/] file cache; instantly reusable, so it is "available"
|
|
54
|
+
[{C_FREE}]■ Free[/] zeroed / free pages (Linux: MemFree)
|
|
55
|
+
Commit memory promised to processes (RAM + pagefile); hitting the limit = out of memory
|
|
56
|
+
Compressed Windows compression store / Linux zswap pool
|
|
57
|
+
|
|
58
|
+
[b]Process columns[/b]
|
|
59
|
+
Resident working set / RSS: this process's pages in RAM right now (shared pages counted in every sharer)
|
|
60
|
+
Private Windows: private commit (heap, stacks; may be partly paged out)
|
|
61
|
+
Anon Linux: resident anonymous memory (RssAnon)
|
|
62
|
+
PSS Linux: resident, with each shared page split between its sharers; sums to real RAM use
|
|
63
|
+
(read from smaps_rollup for visible rows; blank for other users' processes unless run as root)
|
|
64
|
+
Growth smoothed change of Private per second — a steadily positive value is how leaks look
|
|
65
|
+
Faults/s page faults per second (soft + hard)
|
|
66
|
+
Pool/Swap Windows: kernel pool charged to the process / Linux: swapped-out bytes
|
|
67
|
+
|
|
68
|
+
[b]Inspector[/b] walks the selected process's address space:
|
|
69
|
+
Images exe/DLL/.so code+data, mostly shared with other processes
|
|
70
|
+
Mapped memory-mapped files (fonts, caches, model weights loaded with mmap)
|
|
71
|
+
Shared mem pagefile-backed sections, /dev/shm, memfd
|
|
72
|
+
Private heap, GC heaps, JIT code, anonymous mmap
|
|
73
|
+
Mapped = committed (Win) / mapped (Linux); Resident = in RAM; Shared = resident pages shared with others
|
|
74
|
+
Evidence: [green]measured[/] OS counter · [cyan]derived[/] computed by memtaskman ·
|
|
75
|
+
[magenta]identified[/] from loaded modules · [yellow]hint[/] a configured limit, not usage
|
|
76
|
+
|
|
77
|
+
[b]Keys[/b] ↑/↓ select s sort g group by app a top/all c charts m slim p pause ? help q quit
|
|
78
|
+
[dim]Esc to close[/dim]"""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class HelpScreen(ModalScreen):
|
|
82
|
+
BINDINGS = [Binding("escape,question_mark,q", "app.pop_screen", "Close")]
|
|
83
|
+
|
|
84
|
+
def compose(self) -> ComposeResult:
|
|
85
|
+
with VerticalScroll(id="help"):
|
|
86
|
+
yield Static(HELP)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def stacked_bar(segments: list[tuple[str, int, str]], total: int, width: int) -> Text:
|
|
90
|
+
t = Text()
|
|
91
|
+
width = max(10, width)
|
|
92
|
+
used_cells = 0
|
|
93
|
+
for i, (_label, value, color) in enumerate(segments):
|
|
94
|
+
if i == len(segments) - 1:
|
|
95
|
+
cells = width - used_cells
|
|
96
|
+
else:
|
|
97
|
+
cells = round(width * max(0, value) / total) if total else 0
|
|
98
|
+
cells = min(cells, width - used_cells)
|
|
99
|
+
t.append("█" * cells, style=color)
|
|
100
|
+
used_cells += cells
|
|
101
|
+
return t
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def ram_segments(sm: SystemMemory) -> list[tuple[str, int, str]]:
|
|
105
|
+
if sm.standby is not None: # Windows
|
|
106
|
+
mod = sm.modified or 0
|
|
107
|
+
in_use = max(0, sm.used - mod)
|
|
108
|
+
free = sm.free if sm.free is not None else max(0, sm.total - in_use - mod - sm.standby)
|
|
109
|
+
return [("In use", in_use, C_USED), ("Modified", mod, C_MOD), ("Standby", sm.standby, C_STANDBY), ("Free", free, C_FREE)]
|
|
110
|
+
if sm.cached is not None and sm.free is not None: # Linux
|
|
111
|
+
in_use = max(0, sm.total - sm.free - sm.cached)
|
|
112
|
+
return [("In use", in_use, C_USED), ("Cache", sm.cached, C_STANDBY), ("Free", sm.free, C_FREE)]
|
|
113
|
+
return [("Used", sm.used, C_USED), ("Available", sm.available, C_FREE)]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class MemTaskManApp(App):
|
|
117
|
+
TITLE = "memtaskman"
|
|
118
|
+
CSS = """
|
|
119
|
+
Screen { layout: vertical; }
|
|
120
|
+
#top { height: auto; padding: 0 1; }
|
|
121
|
+
#charts { grid-size: 4 2; grid-gutter: 0 2; height: 8; padding: 0 1; }
|
|
122
|
+
#charts.single { grid-size: 1 1; height: 10; }
|
|
123
|
+
.chart { height: 4; }
|
|
124
|
+
#charts.single .chart { height: 10; }
|
|
125
|
+
.chart Sparkline { height: 1fr; }
|
|
126
|
+
.chart .label { height: 1; color: $text-muted; }
|
|
127
|
+
#body { height: 1fr; }
|
|
128
|
+
#procs { width: 1fr; }
|
|
129
|
+
#side { width: 64; border-left: tall $panel; }
|
|
130
|
+
#system { height: auto; padding: 0 1; }
|
|
131
|
+
#inspector { height: 1fr; padding: 0 1; }
|
|
132
|
+
.slim #charts, .slim #side { display: none; }
|
|
133
|
+
HelpScreen { align: center middle; }
|
|
134
|
+
#help { width: 100; max-width: 95%; height: 80%; border: round $accent; background: $surface; padding: 1 2; }
|
|
135
|
+
"""
|
|
136
|
+
BINDINGS = [
|
|
137
|
+
Binding("q", "quit", "Quit"),
|
|
138
|
+
Binding("s", "sort", "Sort"),
|
|
139
|
+
Binding("g", "group", "Group"),
|
|
140
|
+
Binding("a", "toggle_all", "Top/All"),
|
|
141
|
+
Binding("c", "charts", "Charts"),
|
|
142
|
+
Binding("m", "mode", "Slim"),
|
|
143
|
+
Binding("p", "pause", "Pause"),
|
|
144
|
+
Binding("question_mark", "help", "Help", key_display="?"),
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
def __init__(self, interval: float = 1.0, system: bool = True):
|
|
148
|
+
super().__init__()
|
|
149
|
+
self.interval = max(0.25, interval)
|
|
150
|
+
self.collector = Collector(system=system)
|
|
151
|
+
self.history: dict[str, deque[float]] = {k: deque(maxlen=HISTORY) for k, _, _ in CHARTS}
|
|
152
|
+
self.sort_keys = ["resident"] + (["pss"] if LINUX else []) + ["private", "growth", "faults", "name", "pid"]
|
|
153
|
+
self.sort_key = "resident"
|
|
154
|
+
self.show_all = False
|
|
155
|
+
self.grouped = False
|
|
156
|
+
self.paused = False
|
|
157
|
+
self.chart_mode = -1 # -1 = all charts, else index into CHARTS
|
|
158
|
+
self.snapshot: Snapshot | None = None
|
|
159
|
+
self.rows: dict[str, ProcessMem] = {}
|
|
160
|
+
self.selected: str | None = None
|
|
161
|
+
self.last_inspect = 0.0
|
|
162
|
+
self.inspection: Inspection | None = None
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------ layout
|
|
165
|
+
def compose(self) -> ComposeResult:
|
|
166
|
+
yield Static(id="top")
|
|
167
|
+
with Grid(id="charts"):
|
|
168
|
+
for key, label, _ in CHARTS:
|
|
169
|
+
with Vertical(classes="chart", id=f"chart-{key}"):
|
|
170
|
+
yield Static(label, classes="label", id=f"label-{key}")
|
|
171
|
+
yield Sparkline([], summary_function=max, id=f"spark-{key}")
|
|
172
|
+
with Horizontal(id="body"):
|
|
173
|
+
yield DataTable(id="procs", cursor_type="row", zebra_stripes=True)
|
|
174
|
+
with Vertical(id="side"):
|
|
175
|
+
yield Static(id="system")
|
|
176
|
+
with VerticalScroll(id="inspector"):
|
|
177
|
+
yield Static("Select a process to see what its memory is made of.", id="inspect-body")
|
|
178
|
+
yield Footer()
|
|
179
|
+
|
|
180
|
+
def on_mount(self) -> None:
|
|
181
|
+
self._setup_columns()
|
|
182
|
+
self.set_interval(self.interval, self.tick)
|
|
183
|
+
self.tick()
|
|
184
|
+
|
|
185
|
+
def _setup_columns(self) -> None:
|
|
186
|
+
t = self.query_one("#procs", DataTable)
|
|
187
|
+
t.clear(columns=True)
|
|
188
|
+
pl = self.collector_private_label()
|
|
189
|
+
last = "Pool" if WINDOWS else "Swap"
|
|
190
|
+
first = "Procs" if self.grouped else "PID"
|
|
191
|
+
t.add_column(first, key="pid", width=7)
|
|
192
|
+
t.add_column("Name", key="name", width=26)
|
|
193
|
+
t.add_column("Resident", key="resident", width=9)
|
|
194
|
+
if LINUX:
|
|
195
|
+
t.add_column("PSS", key="pss", width=9)
|
|
196
|
+
t.add_column(pl, key="private", width=9)
|
|
197
|
+
t.add_column("Growth", key="growth", width=10)
|
|
198
|
+
t.add_column("Faults/s", key="faults", width=8)
|
|
199
|
+
t.add_column(last, key="extra", width=7)
|
|
200
|
+
t.add_column("Runtime", key="runtime", width=24)
|
|
201
|
+
|
|
202
|
+
def collector_private_label(self) -> str:
|
|
203
|
+
from memtaskman.collector import private_label
|
|
204
|
+
|
|
205
|
+
return private_label()
|
|
206
|
+
|
|
207
|
+
# ------------------------------------------------------------------ sampling
|
|
208
|
+
def tick(self) -> None:
|
|
209
|
+
if not self.paused:
|
|
210
|
+
self.sample()
|
|
211
|
+
|
|
212
|
+
@work(thread=True, exclusive=True, group="sample")
|
|
213
|
+
def sample(self) -> None:
|
|
214
|
+
snap = self.collector.sample()
|
|
215
|
+
visible = self._ordered(snap.processes)[: TOP_N if not self.show_all else None]
|
|
216
|
+
self.collector.resolve_runtimes(visible + sort_procs(snap.processes, "resident"), time_budget=0.3)
|
|
217
|
+
self.collector.refresh_pss(visible, time_budget=0.2)
|
|
218
|
+
self.call_from_thread(self.apply, snap)
|
|
219
|
+
|
|
220
|
+
def apply(self, snap: Snapshot) -> None:
|
|
221
|
+
self.snapshot = snap
|
|
222
|
+
if snap.system:
|
|
223
|
+
self._push_history(snap.system)
|
|
224
|
+
self.render_top()
|
|
225
|
+
self.render_charts()
|
|
226
|
+
self.render_system()
|
|
227
|
+
self.render_table()
|
|
228
|
+
if self.selected and time.monotonic() - self.last_inspect > INSPECT_EVERY:
|
|
229
|
+
self._inspect_selected()
|
|
230
|
+
|
|
231
|
+
def _push_history(self, sm: SystemMemory) -> None:
|
|
232
|
+
vals = {
|
|
233
|
+
"used": sm.used,
|
|
234
|
+
"commit": sm.commit_used,
|
|
235
|
+
"cache": sm.standby if sm.standby is not None else sm.cached,
|
|
236
|
+
"compressed": sm.compressed,
|
|
237
|
+
"swap": sm.swap_used,
|
|
238
|
+
"kernel": (sm.kernel_paged or 0) + (sm.kernel_nonpaged or 0) if sm.kernel_paged is not None else None,
|
|
239
|
+
"faults": sm.page_faults_ps,
|
|
240
|
+
"hard": sm.hard_faults_ps,
|
|
241
|
+
}
|
|
242
|
+
for k, v in vals.items():
|
|
243
|
+
if v is not None:
|
|
244
|
+
self.history[k].append(float(v))
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------------------ rendering
|
|
247
|
+
def render_top(self) -> None:
|
|
248
|
+
snap = self.snapshot
|
|
249
|
+
top = self.query_one("#top", Static)
|
|
250
|
+
width = max(20, top.size.width - 2 if top.size.width else 100)
|
|
251
|
+
lines: list[Text] = []
|
|
252
|
+
status = Text()
|
|
253
|
+
status.append("memtaskman", style="bold")
|
|
254
|
+
status.append(f" sort:{self.sort_key} {'grouped by app' if self.grouped else 'per process'}"
|
|
255
|
+
f" {'all' if self.show_all else f'top {TOP_N}'} {len(snap.processes)} processes", style="dim")
|
|
256
|
+
if self.paused:
|
|
257
|
+
status.append(" PAUSED", style="bold yellow")
|
|
258
|
+
lines.append(status)
|
|
259
|
+
sm = snap.system
|
|
260
|
+
if sm:
|
|
261
|
+
bar_w = width - 30
|
|
262
|
+
segs = ram_segments(sm)
|
|
263
|
+
line = Text(f"{'RAM':<7}")
|
|
264
|
+
line.append_text(stacked_bar(segs, sm.total, bar_w))
|
|
265
|
+
line.append(f" {fmt_bytes(sm.used)}/{fmt_bytes(sm.total)} {pct(sm.used, sm.total):.0f}%")
|
|
266
|
+
lines.append(line)
|
|
267
|
+
legend = Text(" " * 7)
|
|
268
|
+
for label, value, color in segs:
|
|
269
|
+
legend.append("■ ", style=color)
|
|
270
|
+
legend.append(f"{label} {fmt_bytes(value)} ")
|
|
271
|
+
if sm.compressed is not None:
|
|
272
|
+
legend.append(f"Compressed {fmt_bytes(sm.compressed)} ", style="dim")
|
|
273
|
+
lines.append(legend)
|
|
274
|
+
if sm.commit_used is not None and sm.commit_limit:
|
|
275
|
+
line = Text(f"{'Commit':<7}")
|
|
276
|
+
line.append_text(stacked_bar([("c", sm.commit_used, C_COMMIT), ("r", sm.commit_limit - sm.commit_used, C_FREE)],
|
|
277
|
+
sm.commit_limit, bar_w))
|
|
278
|
+
line.append(f" {fmt_bytes(sm.commit_used)}/{fmt_bytes(sm.commit_limit)} {pct(sm.commit_used, sm.commit_limit):.0f}%")
|
|
279
|
+
lines.append(line)
|
|
280
|
+
if sm.swap_total:
|
|
281
|
+
line = Text(f"{'Swap':<7}")
|
|
282
|
+
line.append_text(stacked_bar([("s", sm.swap_used or 0, C_SWAP), ("f", sm.swap_total - (sm.swap_used or 0), C_FREE)],
|
|
283
|
+
sm.swap_total, bar_w))
|
|
284
|
+
line.append(f" {fmt_bytes(sm.swap_used)}/{fmt_bytes(sm.swap_total)}")
|
|
285
|
+
lines.append(line)
|
|
286
|
+
top.update(Text("\n").join(lines))
|
|
287
|
+
|
|
288
|
+
def render_charts(self) -> None:
|
|
289
|
+
for i, (key, label, kind) in enumerate(CHARTS):
|
|
290
|
+
data = list(self.history[key])
|
|
291
|
+
cell = self.query_one(f"#chart-{key}")
|
|
292
|
+
cell.display = self.chart_mode in (-1, i)
|
|
293
|
+
if not cell.display:
|
|
294
|
+
continue
|
|
295
|
+
cur = data[-1] if data else None
|
|
296
|
+
if kind == "bytes":
|
|
297
|
+
val = fmt_bytes(cur)
|
|
298
|
+
rng = f" [{fmt_bytes(min(data))}-{fmt_bytes(max(data))}]" if len(data) > 1 else ""
|
|
299
|
+
else:
|
|
300
|
+
val = fmt_count_rate(cur)
|
|
301
|
+
rng = f" [peak {fmt_count_rate(max(data))}]" if data else ""
|
|
302
|
+
self.query_one(f"#label-{key}", Static).update(f"{label}: [b]{val}[/b][dim]{rng}[/dim]")
|
|
303
|
+
self.query_one(f"#spark-{key}", Sparkline).data = data or [0]
|
|
304
|
+
|
|
305
|
+
def render_system(self) -> None:
|
|
306
|
+
sm = self.snapshot.system if self.snapshot else None
|
|
307
|
+
w = self.query_one("#system", Static)
|
|
308
|
+
if not sm:
|
|
309
|
+
w.update("[dim]system counters disabled[/dim]")
|
|
310
|
+
return
|
|
311
|
+
t = Table.grid(padding=(0, 1))
|
|
312
|
+
t.add_column(style="dim")
|
|
313
|
+
t.add_column(justify="right")
|
|
314
|
+
t.add_column(style="dim")
|
|
315
|
+
rows = [
|
|
316
|
+
("Available", sm.available, "free + reclaimable cache"),
|
|
317
|
+
("Modified" if WINDOWS else "Dirty", sm.modified, "waiting to be written"),
|
|
318
|
+
("Standby" if WINDOWS else "Page cache", sm.standby if WINDOWS else sm.cached, "cached file data"),
|
|
319
|
+
("Kernel paged" if WINDOWS else "Slab reclaim", sm.kernel_paged, ""),
|
|
320
|
+
("Kernel nonpaged" if WINDOWS else "Slab unreclaim", sm.kernel_nonpaged, "can't be paged out"),
|
|
321
|
+
("Compressed", sm.compressed, "compression store" if WINDOWS else "zswap pool"),
|
|
322
|
+
]
|
|
323
|
+
rows += [(k, v, "") for k, v in sm.extra.items()]
|
|
324
|
+
for label, value, note in rows:
|
|
325
|
+
if value is not None:
|
|
326
|
+
t.add_row(label, fmt_bytes(value), note)
|
|
327
|
+
w.update(Group(Text("System", style="bold"), t))
|
|
328
|
+
|
|
329
|
+
def _ordered(self, procs: list[ProcessMem]) -> list[ProcessMem]:
|
|
330
|
+
if self.grouped:
|
|
331
|
+
procs = group_by_app(procs)
|
|
332
|
+
return sort_procs(procs, self.sort_key)
|
|
333
|
+
|
|
334
|
+
def _row_key(self, p: ProcessMem) -> str:
|
|
335
|
+
return f"g:{p.name.lower()}" if self.grouped else f"p:{p.pid}:{p.create_time}"
|
|
336
|
+
|
|
337
|
+
def render_table(self) -> None:
|
|
338
|
+
t = self.query_one("#procs", DataTable)
|
|
339
|
+
procs = self._ordered(self.snapshot.processes)
|
|
340
|
+
if not self.show_all:
|
|
341
|
+
procs = procs[:TOP_N]
|
|
342
|
+
if self.selected and not any(self._row_key(p) == self.selected for p in procs):
|
|
343
|
+
# Keep the selected process visible even when it drops out of the top N.
|
|
344
|
+
sel = next((p for p in self._ordered(self.snapshot.processes) if self._row_key(p) == self.selected), None)
|
|
345
|
+
if sel:
|
|
346
|
+
procs.append(sel)
|
|
347
|
+
scroll_y = t.scroll_y
|
|
348
|
+
t.clear()
|
|
349
|
+
self.rows = {}
|
|
350
|
+
for p in procs:
|
|
351
|
+
k = self._row_key(p)
|
|
352
|
+
self.rows[k] = p
|
|
353
|
+
growth = Text(fmt_rate(p.growth_ps), justify="right")
|
|
354
|
+
if p.growth_ps and p.growth_ps > 1024 * 1024:
|
|
355
|
+
growth.stylize("bold red")
|
|
356
|
+
elif p.growth_ps and p.growth_ps > 64 * 1024:
|
|
357
|
+
growth.stylize("yellow")
|
|
358
|
+
elif p.growth_ps and p.growth_ps < -64 * 1024:
|
|
359
|
+
growth.stylize("green")
|
|
360
|
+
extra = (p.paged_pool or 0) + (p.nonpaged_pool or 0) if WINDOWS and p.paged_pool is not None else p.swapped
|
|
361
|
+
rt = f"{p.runtime or ''} {p.runtime_detail or ''}".strip()
|
|
362
|
+
cells = [
|
|
363
|
+
Text(f"×{p.count}" if self.grouped else str(p.pid), justify="right"),
|
|
364
|
+
p.name,
|
|
365
|
+
Text(fmt_bytes(p.resident), justify="right"),
|
|
366
|
+
]
|
|
367
|
+
if LINUX:
|
|
368
|
+
cells.append(Text(fmt_bytes(p.pss), justify="right"))
|
|
369
|
+
t.add_row(
|
|
370
|
+
*cells,
|
|
371
|
+
Text(fmt_bytes(p.private), justify="right"),
|
|
372
|
+
growth,
|
|
373
|
+
Text(fmt_count_rate(p.faults_ps), justify="right"),
|
|
374
|
+
Text(fmt_bytes(extra) if extra is not None else "-", justify="right"),
|
|
375
|
+
Text(rt, style="magenta" if p.runtime else ""),
|
|
376
|
+
key=k,
|
|
377
|
+
)
|
|
378
|
+
if self.selected in self.rows:
|
|
379
|
+
t.move_cursor(row=t.get_row_index(self.selected), animate=False, scroll=False)
|
|
380
|
+
t.scroll_to(y=scroll_y, animate=False)
|
|
381
|
+
|
|
382
|
+
# ------------------------------------------------------------------ inspector
|
|
383
|
+
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
384
|
+
t = event.data_table
|
|
385
|
+
key = event.row_key.value if event.row_key else None
|
|
386
|
+
# Ignore stale events from clear()/rebuild: only the row under the cursor counts.
|
|
387
|
+
try:
|
|
388
|
+
current = t.coordinate_to_cell_key(t.cursor_coordinate).row_key.value
|
|
389
|
+
except Exception: # noqa: BLE001 - empty table
|
|
390
|
+
return
|
|
391
|
+
if key != current or key == self.selected:
|
|
392
|
+
return
|
|
393
|
+
self.selected = key
|
|
394
|
+
self.query_one("#inspect-body", Static).update(Text(f"Inspecting {self.rows[key].name} …", style="dim"))
|
|
395
|
+
self._inspect_selected()
|
|
396
|
+
|
|
397
|
+
def _inspect_selected(self) -> None:
|
|
398
|
+
if not self.selected or not self.snapshot:
|
|
399
|
+
return
|
|
400
|
+
p = self.rows.get(self.selected)
|
|
401
|
+
if p is None:
|
|
402
|
+
return
|
|
403
|
+
if self.grouped:
|
|
404
|
+
members = [q for q in self.snapshot.processes if q.name.lower() == p.name.lower()]
|
|
405
|
+
if not members:
|
|
406
|
+
return
|
|
407
|
+
target = max(members, key=lambda q: q.resident)
|
|
408
|
+
else:
|
|
409
|
+
target = p
|
|
410
|
+
self.last_inspect = time.monotonic()
|
|
411
|
+
self.inspect_worker(target, p.count)
|
|
412
|
+
|
|
413
|
+
@work(thread=True, exclusive=True, group="inspect")
|
|
414
|
+
def inspect_worker(self, p: ProcessMem, group_count: int) -> None:
|
|
415
|
+
ins = self.collector.inspect(p)
|
|
416
|
+
self.call_from_thread(self.show_inspection, ins, group_count)
|
|
417
|
+
|
|
418
|
+
def show_inspection(self, ins: Inspection, group_count: int = 1) -> None:
|
|
419
|
+
self.inspection = ins
|
|
420
|
+
parts: list = []
|
|
421
|
+
head = Text()
|
|
422
|
+
head.append(f"{ins.name}", style="bold")
|
|
423
|
+
head.append(f" pid {ins.pid}", style="dim")
|
|
424
|
+
if group_count > 1:
|
|
425
|
+
head.append(f" (largest of {group_count})", style="dim")
|
|
426
|
+
parts.append(head)
|
|
427
|
+
if ins.error:
|
|
428
|
+
parts.append(Text(ins.error, style="red"))
|
|
429
|
+
if ins.categories:
|
|
430
|
+
total_res = sum(c.resident or 0 for c in ins.categories) or 1
|
|
431
|
+
t = Table(box=None, padding=(0, 1), show_edge=False, header_style="bold dim")
|
|
432
|
+
t.add_column("What", no_wrap=True)
|
|
433
|
+
t.add_column("Mapped", justify="right")
|
|
434
|
+
t.add_column("Resident", justify="right")
|
|
435
|
+
t.add_column("Shared", justify="right")
|
|
436
|
+
t.add_column("", width=8, no_wrap=True)
|
|
437
|
+
for c in ins.categories:
|
|
438
|
+
share = (c.resident or 0) / total_res
|
|
439
|
+
bar = Text("█" * max(0, round(share * 8)), style=C_USED)
|
|
440
|
+
t.add_row(c.name, fmt_bytes(c.mapped), fmt_bytes(c.resident), fmt_bytes(c.resident_shared), bar)
|
|
441
|
+
if ins.reserved:
|
|
442
|
+
t.add_row(Text("Reserved only", style="dim"), Text(fmt_bytes(ins.reserved), style="dim"), "", "", "")
|
|
443
|
+
parts.append(t)
|
|
444
|
+
if ins.top_files:
|
|
445
|
+
parts.append(Text("\nLargest file-backed pages", style="bold"))
|
|
446
|
+
ft = Table(box=None, padding=(0, 1), show_edge=False, show_header=False)
|
|
447
|
+
ft.add_column(justify="right")
|
|
448
|
+
ft.add_column(no_wrap=True, overflow="ellipsis")
|
|
449
|
+
for f in ins.top_files:
|
|
450
|
+
pp = PureWindowsPath(f.path)
|
|
451
|
+
label = Text(pp.name)
|
|
452
|
+
parent = str(pp.parent)
|
|
453
|
+
label.append(" " + (parent if len(parent) <= 30 else "…" + parent[-29:]), style="dim")
|
|
454
|
+
ft.add_row(fmt_bytes(f.resident if f.resident is not None else f.mapped), label)
|
|
455
|
+
parts.append(ft)
|
|
456
|
+
if ins.evidence:
|
|
457
|
+
parts.append(Text("\nEvidence", style="bold"))
|
|
458
|
+
for level, text in ins.evidence:
|
|
459
|
+
line = Text()
|
|
460
|
+
line.append(f"{level:<10}", style=LEVEL_STYLE.get(level, ""))
|
|
461
|
+
line.append(text)
|
|
462
|
+
parts.append(line)
|
|
463
|
+
if ins.cmdline:
|
|
464
|
+
parts.append(Text("\n" + " ".join(ins.cmdline)[:300], style="dim"))
|
|
465
|
+
parts.append(Text(f"\ninspected in {ins.elapsed * 1000:.0f} ms, refreshes every {INSPECT_EVERY:.0f}s", style="dim"))
|
|
466
|
+
self.query_one("#inspect-body", Static).update(Group(*parts))
|
|
467
|
+
|
|
468
|
+
# ------------------------------------------------------------------ actions
|
|
469
|
+
def _refresh_now(self) -> None:
|
|
470
|
+
if self.snapshot:
|
|
471
|
+
self.render_top()
|
|
472
|
+
self.render_table()
|
|
473
|
+
|
|
474
|
+
def action_sort(self) -> None:
|
|
475
|
+
self.sort_key = self.sort_keys[(self.sort_keys.index(self.sort_key) + 1) % len(self.sort_keys)]
|
|
476
|
+
self.notify(f"sort: {self.sort_key}", timeout=1.5)
|
|
477
|
+
self._refresh_now()
|
|
478
|
+
|
|
479
|
+
def action_group(self) -> None:
|
|
480
|
+
self.grouped = not self.grouped
|
|
481
|
+
self.selected = None
|
|
482
|
+
self._setup_columns()
|
|
483
|
+
self._refresh_now()
|
|
484
|
+
|
|
485
|
+
def action_toggle_all(self) -> None:
|
|
486
|
+
self.show_all = not self.show_all
|
|
487
|
+
self._refresh_now()
|
|
488
|
+
|
|
489
|
+
def action_charts(self) -> None:
|
|
490
|
+
self.chart_mode = self.chart_mode + 1 if self.chart_mode + 1 < len(CHARTS) else -1
|
|
491
|
+
self.query_one("#charts").set_class(self.chart_mode >= 0, "single")
|
|
492
|
+
self.render_charts()
|
|
493
|
+
|
|
494
|
+
def action_mode(self) -> None:
|
|
495
|
+
self.screen.toggle_class("slim")
|
|
496
|
+
|
|
497
|
+
def action_pause(self) -> None:
|
|
498
|
+
self.paused = not self.paused
|
|
499
|
+
self._refresh_now()
|
|
500
|
+
|
|
501
|
+
def action_help(self) -> None:
|
|
502
|
+
self.push_screen(HelpScreen())
|
|
503
|
+
|
|
504
|
+
def on_unmount(self) -> None:
|
|
505
|
+
self.collector.close()
|
memtaskman/cli.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Command-line entry point: `memtaskman` / `mtm`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from memtaskman import __version__
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _snapshot(args, collector=None):
|
|
14
|
+
from memtaskman.collector import Collector, sort_procs
|
|
15
|
+
|
|
16
|
+
c = collector or Collector(system=not args.no_system)
|
|
17
|
+
c.sample()
|
|
18
|
+
time.sleep(args.interval)
|
|
19
|
+
snap = c.sample()
|
|
20
|
+
top = sort_procs(snap.processes, "resident")
|
|
21
|
+
c.resolve_runtimes(top[:40], time_budget=2.0)
|
|
22
|
+
c.refresh_pss(top if args.all else top[: args.top], time_budget=2.0)
|
|
23
|
+
snap = c.sample()
|
|
24
|
+
return c, snap
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cmd_main(args) -> int:
|
|
28
|
+
if args.once or args.json:
|
|
29
|
+
from memtaskman import report
|
|
30
|
+
|
|
31
|
+
c, snap = _snapshot(args)
|
|
32
|
+
c.close()
|
|
33
|
+
if not args.all:
|
|
34
|
+
snap.processes = sorted(snap.processes, key=lambda p: p.resident, reverse=True)[: args.top]
|
|
35
|
+
print(report.to_json(snap) if args.json else report.render_text(snap, top=None))
|
|
36
|
+
return 0
|
|
37
|
+
from memtaskman.app import MemTaskManApp
|
|
38
|
+
|
|
39
|
+
MemTaskManApp(interval=args.interval, system=not args.no_system).run()
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cmd_inspect(args) -> int:
|
|
44
|
+
import psutil
|
|
45
|
+
|
|
46
|
+
from memtaskman import report
|
|
47
|
+
from memtaskman.collector import Collector
|
|
48
|
+
|
|
49
|
+
c = Collector(system=False)
|
|
50
|
+
snap = c.sample()
|
|
51
|
+
p = next((p for p in snap.processes if p.pid == args.pid), None)
|
|
52
|
+
if p is None:
|
|
53
|
+
try:
|
|
54
|
+
psutil.Process(args.pid)
|
|
55
|
+
print(f"pid {args.pid}: access denied", file=sys.stderr)
|
|
56
|
+
except psutil.NoSuchProcess:
|
|
57
|
+
print(f"pid {args.pid}: no such process", file=sys.stderr)
|
|
58
|
+
return 1
|
|
59
|
+
ins = c.inspect(p)
|
|
60
|
+
print(report.to_json(ins) if args.json else report.render_inspection(ins))
|
|
61
|
+
return 0 if not ins.error else 2
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cmd_diff(args) -> int:
|
|
65
|
+
from memtaskman.diff import diff_snapshots
|
|
66
|
+
|
|
67
|
+
with open(args.a, encoding="utf-8") as fa, open(args.b, encoding="utf-8") as fb:
|
|
68
|
+
a, b = json.load(fa), json.load(fb)
|
|
69
|
+
print(diff_snapshots(a, b, top=args.top))
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cmd_record(args) -> int:
|
|
74
|
+
from memtaskman.collector import Collector
|
|
75
|
+
|
|
76
|
+
c = Collector(system=not args.no_system)
|
|
77
|
+
c.sample()
|
|
78
|
+
n = 0
|
|
79
|
+
try:
|
|
80
|
+
with open(args.out, "a", encoding="utf-8") as f:
|
|
81
|
+
while args.count == 0 or n < args.count:
|
|
82
|
+
time.sleep(args.interval)
|
|
83
|
+
snap = c.sample()
|
|
84
|
+
if not args.all:
|
|
85
|
+
snap.processes = sorted(snap.processes, key=lambda p: p.resident, reverse=True)[: args.top]
|
|
86
|
+
f.write(json.dumps(snap.to_dict(), separators=(",", ":")) + "\n")
|
|
87
|
+
f.flush()
|
|
88
|
+
n += 1
|
|
89
|
+
print(f"\rrecorded {n} snapshot(s) to {args.out}", end="", file=sys.stderr)
|
|
90
|
+
except KeyboardInterrupt:
|
|
91
|
+
pass
|
|
92
|
+
finally:
|
|
93
|
+
c.close()
|
|
94
|
+
print(file=sys.stderr)
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
99
|
+
p = argparse.ArgumentParser(prog="memtaskman", description="An htop for what's inside your RAM, not just how full it is.")
|
|
100
|
+
p.add_argument("--version", action="version", version=f"memtaskman {__version__}")
|
|
101
|
+
p.add_argument("-i", "--interval", type=float, default=1.0, help="sample interval in seconds (default 1)")
|
|
102
|
+
p.add_argument("--once", action="store_true", help="print one text report and exit")
|
|
103
|
+
p.add_argument("--json", action="store_true", help="print one JSON snapshot and exit")
|
|
104
|
+
p.add_argument("--top", type=int, default=25, help="processes in --once/--json/record output (default 25)")
|
|
105
|
+
p.add_argument("--all", action="store_true", help="include every process in --once/--json/record output")
|
|
106
|
+
p.add_argument("--no-system", action="store_true", help="skip system-wide counters")
|
|
107
|
+
sub = p.add_subparsers(dest="cmd")
|
|
108
|
+
|
|
109
|
+
ins = sub.add_parser("inspect", help="break down one process's address space")
|
|
110
|
+
ins.add_argument("pid", type=int)
|
|
111
|
+
ins.add_argument("--json", action="store_true")
|
|
112
|
+
ins.set_defaults(func=cmd_inspect)
|
|
113
|
+
|
|
114
|
+
d = sub.add_parser("diff", help="compare two --json snapshots")
|
|
115
|
+
d.add_argument("a")
|
|
116
|
+
d.add_argument("b")
|
|
117
|
+
d.add_argument("--top", type=int, default=20)
|
|
118
|
+
d.set_defaults(func=cmd_diff)
|
|
119
|
+
|
|
120
|
+
r = sub.add_parser("record", help="append snapshots to a JSON Lines file")
|
|
121
|
+
r.add_argument("out")
|
|
122
|
+
r.add_argument("-i", "--interval", type=float, default=5.0)
|
|
123
|
+
r.add_argument("-n", "--count", type=int, default=0, help="stop after N snapshots (0 = until Ctrl+C)")
|
|
124
|
+
r.add_argument("--top", type=int, default=50)
|
|
125
|
+
r.add_argument("--all", action="store_true")
|
|
126
|
+
r.add_argument("--no-system", action="store_true")
|
|
127
|
+
r.set_defaults(func=cmd_record)
|
|
128
|
+
return p
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def main(argv: list[str] | None = None) -> int:
|
|
132
|
+
args = build_parser().parse_args(argv)
|
|
133
|
+
if getattr(args, "func", None):
|
|
134
|
+
return args.func(args)
|
|
135
|
+
return cmd_main(args)
|