-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmemgroups.py
More file actions
executable file
·2788 lines (2520 loc) · 119 KB
/
Copy pathmemgroups.py
File metadata and controls
executable file
·2788 lines (2520 loc) · 119 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""memgroups - group RAM consumption by application.
Reads /proc, folds processes into logical groups (Chrome, Devin Desktop, MCP
servers, ...) and prints a table. Read-only, works without root.
One file, stdlib only, no runtime dependencies. That is a constraint, not a
stage: the whole tool is meant to be readable end to end by one person, and to
run on a machine where installing anything is the last thing you want to do.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import time
try:
import tomllib # Python 3.11+
except ModuleNotFoundError: # 3.10, which is what Ubuntu 22.04 ships
# Deliberately NOT falling back to the `tomli` PyPI package: stdlib-only is a
# design constraint, and the tool's whole job is answerable without a config.
# Degrade to defaults and say so only if a config actually exists.
tomllib = None
from dataclasses import dataclass, field
from pathlib import Path
def _detect_version() -> str:
"""The installed package's version, or a marker when there is no package.
The number lives in exactly one place - the git tag - and setuptools-scm
writes it into the wheel's metadata at build time. Reading it back here
means the tag, the wheel, the Arch package and `--version` can never
disagree, which is the failure a hand-edited literal invites.
importlib.metadata is stdlib, so this adds no dependency. Running straight
out of a checkout there is no metadata to read, and saying so is more honest
than printing a number that was true at some point in the past.
"""
try:
from importlib.metadata import PackageNotFoundError, version
except ImportError: # pragma: no cover - Python < 3.8
return "unknown"
try:
return version("memgroups")
except PackageNotFoundError:
return "unknown (running from a source checkout)"
__version__ = _detect_version()
DOCKER_TIMEOUT = 3.0
DEFAULT_TOP = 15
DEFAULT_CGROUPFS = "/sys/fs/cgroup"
ZSWAP_SOURCE = "cgroup2 memory.stat"
TMPFS_SOURCE = "statvfs of tmpfs mounts"
NAME_WIDTH_MAX = 56 # enough for "Docker: ghcr.io/modelcontextprotocol/inspector:latest"
CMDLINE_MAX = 4096 # cap argv: bound the per-process regex work against a hostile process
FILE_HINT_MIN_KB = 1024 * 1024 # 1 GiB held by ONE process - see file_hint()
FILE_HINT_RATIO = 2 # ... and the group is 2x more FILE than SIZE
FILE_HINT_NAME_MAX = 32 # keeps the hint line inside 80 columns
METRICS = ("data", "anon", "rss", "pss")
# The default. "anon" cannot see a hypervisor's guest RAM, which VirtualBox and
# a file-backed QEMU map as FILE pages: seven running VMs read as 0.78 GiB
# instead of 20.45 GiB on the machine this was measured on.
DEFAULT_METRIC = "data"
# Metrics whose SIZE can be checked against /proc/meminfo, so "ram" scope means
# something: anon claims AnonPages, data claims AnonPages plus part of Mapped.
# "rss" cannot - it counts a shared page once per process holding it.
RECONCILABLE = ("data", "anon")
SCOPES = ("ram", "processes")
SORTS = ("size", "swap", "zswap", "name")
UNCLASSIFIED = "Unclassified"
REST_FMT = "Other groups ({n})"
DOCKER_PREFIX = "Docker: "
COMPOSE_PREFIX = "Docker compose: "
COMPOSE_LABEL = "com.docker.compose.project"
ROW_TMPFS = "tmpfs / shm"
ROW_KERNEL = "Kernel (slab, page tables, stacks)"
ROW_ZSWAP = "zswap (compressed pool)" # exactly 23 chars: fits the 80-column name field
# xterm-256 palette, picked to stay readable on both dark and light terminals.
PALETTE = (39, 214, 76, 203, 141, 45, 220, 170, 111, 208, 84, 167)
CONTAINER_RE = re.compile(r"docker[-/]([0-9a-f]{12,64})")
# Recognises the collapsed row by the same format string that builds it, so the
# two can never drift apart. Used to keep the headline off a row that is not one
# application.
REST_RE = re.compile(re.escape(REST_FMT).replace(re.escape("{n}"), r"\d+") + r"$")
# One row per virtual machine, the same way one row per container works. The
# name is free text the VM's owner chose, so it is read to the next option and
# not to the next space: "FS: Ub_Mate" is a real machine name on the host this
# was written against. Shared host daemons - VBoxSVC, libvirtd, its dnsmasq -
# carry no machine name and stay in the plain Virtualization row, exactly as
# "Docker engine" stays separate from the containers.
# Checked first, and anchored to argv[0]: "-name" is an ordinary option, and
# without this gate "find /home/u/obs -name *.mkv" became a virtual machine
# called *.mkv. The name patterns are only ever applied to a hypervisor.
VM_HOST_RE = re.compile(r"^(?:\S*/)?(?:qemu-system-[a-z0-9_]+|VirtualBoxVM|VBoxHeadless)(?:\s|$)")
VM_NAME_RE = (
re.compile(r"--comment\s+(.+?)\s+--startvm\b"), # VirtualBox, VBoxHeadless
re.compile(r"-name\s+(?:guest=)?([^,\s]+)"), # QEMU, plain and libvirt forms
)
# The name comes from the process's own command line, so it is not ours to
# trust with the table's width. Control characters are already stripped in
# collect_procs; this is the length half of the same argument.
VM_NAME_MAX = 48
DOCKER_CLIENT_RE = re.compile(r"(?:^|/)docker\s+(?:run|exec|start)\b")
# Control characters, incl. ESC/CR/LF. A process controls its own argv and comm,
# so strip these before printing - otherwise a hostile process can forge table
# rows or inject terminal escape sequences into our output. Same idea as ps.
CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
# Processes whose job is to LAUNCH other processes. Their children are unrelated
# applications, so inheriting a group across one is always wrong. Without this the
# ancestor walk hands every unmatched desktop app to "System services" - a modern
# desktop starts apps from `systemd --user` and from D-Bus activation - which is
# worse than saying nothing, because a wrong answer arrives with the same
# confidence as a right one, and it makes the "how much is Unclassified on a
# foreign machine" question unanswerable.
#
# The list has four kinds of launcher on it. Service managers and D-Bus come
# first. Then the remote and console entry points: a per-connection `sshd:
# user@pts/0` matches the System services rule, so without the barrier every
# single thing run over SSH inherits System services - measured on a real host,
# where this tool classified *itself* that way. `login` does the same for a text
# console, since agetty above it is a system service too. Then display managers,
# which own a graphical session but are not what runs in it. Last, Wayland
# compositors: they keep the clients they spawn as direct children, so the
# moment a compositor is classified it starts absorbing every unmatched
# application on the desktop.
SUPERVISOR_RE = re.compile(
r"(?:^|/)(?:systemd|init|dbus-daemon|dbus-broker(?:-launch)?|"
r"xdg-desktop-portal(?:-[a-z]+)?)\b"
r"|(?:^|/)sshd(?:-session)?\b"
r"|(?:^|/)login(?:$|\s)"
# Not \b: GDM and SDDM run an Xorg of their own whose arguments carry
# "/run/user/120/gdm/Xauthority" and "/run/sddm/xauth_HTgLMj". That Xorg is
# a real X server and belongs in Desktop / X, so the name must be followed
# by the end of the word, not by another path component.
r"|(?:^|/)(?:lightdm|gdm[0-9]?|sddm(?:-helper)?|greetd|plasmalogin(?:-helper)?)"
r"(?:$|[- ])"
r"|(?:^|/bin/)(?:sway|[Hh]yprland|river|niri|wayfire|labwc|weston|dwl|cage)(?![\w./-])"
# Job schedulers and the session's key agent. cron/crond/anacron run
# arbitrary user jobs, and ssh-agent is the classic ~/.xinitrc wrapper that
# execs the whole session - all of them are classified below, so without a
# barrier every backup script under cron and every window of an
# ssh-agent-wrapped session would inherit "System services".
#
# This one alternative is looser than the rest: the name is matched after
# any slash, so "vim /home/me/cron" is a barrier too. That is deliberate,
# not an oversight. A barrier costs nothing unless a process has children
# that would otherwise inherit through it, and an editor does not. Tighten
# this to argv[0] and the real case is lost: "/bin/sh -c /home/me/job.sh"
# under cron is reached through the PARENT's cmdline, which is where the
# scheduler's name sits.
r"|(?:^|/)(?:(?:ana)?crond?|ssh-agent)(?:$|\s)"
)
# /proc/mounts escapes exactly these four characters in the path fields
# (fs/proc_namespace.c mangles " \t\n\\"). Decode in ONE pass: chained
# str.replace() would turn the escaped backslash "\134040" into a space.
MOUNT_ESCAPE_RE = re.compile(r"\\(040|011|012|134)")
MOUNT_ESCAPES = {"040": " ", "011": "\t", "012": "\n", "134": "\\"}
# name, cmdline patterns, comm patterns, container patterns.
# Order matters: the first matching rule wins. Anything running inside a
# container is labelled "Docker compose: <project>" / "Docker: <image>" before
# these rules are consulted, so they only ever see host processes.
DEFAULT_RULES: tuple[tuple[str, list[str], list[str], list[str]], ...] = (
# Backups come FIRST, and every pattern below is anchored to argv[0]. Both
# halves of that are load-bearing, and together they close a defect that ran
# in both directions.
#
# A backup names what it copies. With the application rules above these, 83
# patterns could steal a running backup: "rsync -a /etc/nginx /backup" was a
# web server, "borg create ::snap /etc/systemd" a system service, and
# "restic backup /home/u/.config/kwin" a desktop. Anchoring 83 patterns one
# by one is 83 chances to break something; moving five rules is one change,
# and it is also the correct statement - a running backup is a backup no
# matter whose directory is in its arguments.
#
# The anchor is what makes the move safe in the other direction. Unanchored,
# "\brsync\b" first would take "vim /etc/rsync.conf" and "code ~/src/rsync"
# (it takes them today, from further down the list). Anchored, only the
# program itself matches. borg and duplicity get a second pattern because
# they are Python programs: the kernel puts the interpreter in argv[0], so
# the name sits in the second word.
#
# Container membership is decided before any of these rules run, so a restic
# inside a container is still "Docker: <image>".
("rsync", [r"^(?:\S*/)?rsync(?:\s|$)"], [r"^rsync$"], []),
("restic", [r"^(?:\S*/)?restic(?:\s|$)"], [r"^restic$"], []),
(
"borg",
[
r"^(?:\S*/)?borg(backup)?(?:\s|$)",
r"^(?:\S*/)?python[0-9.]*(?: -\w+)* /\S+/borg(backup)?(?:\s|$)",
],
[r"^borg$"],
[],
),
(
"duplicity",
[
r"^(?:\S*/)?duplicity(?:\s|$)",
r"^(?:\S*/)?python[0-9.]*(?: -\w+)* /\S+/duplicity(?:\s|$)",
],
[r"^duplicity$"],
[],
),
("rclone", [r"^(?:\S*/)?rclone(?:\s|$)"], [r"^rclone$"], []),
("Claude Code CLI", [r"/claude-code/bin/claude\b"], [r"^claude$"], []),
# Browsers.
(
"Google Chrome",
[r"/opt/google/chrome", r"/chromium(-browser)?\b", r"ms-playwright/chromium"],
[],
[],
),
("Mozilla Firefox", [r"/firefox\b", r"/librewolf\b", r"/waterfox\b", r"/floorp\b"], [], []),
# IDE / AI editors.
(
"JetBrains IDE",
[
r"/(webstorm|idea|pycharm|goland|phpstorm|clion|rider|rubymine)\b",
r"JetBrains",
r"/jbr/bin/java",
r"jcef",
r"cef_server",
],
[],
[],
),
("Windsurf", [r"/usr/share/windsurf", r"/electron39/"], [], []),
("Devin Desktop", [r"/devin-desktop", r"/electron42/", r"\.config/Devin"], [], []),
("VS Code / Cursor", [r"/usr/share/code\b", r"/cursor\b", r"vscode-server"], [], []),
# Messengers.
(
"Messengers",
[
# Anchored to the binary, not the directory: a bare "/Telegram\b"
# also matched "rsync -a ~/Downloads/Telegram/ /backup/" and sent it here.
r"/Telegram/Telegram\b",
# What every distro package, snap and flatpak actually installs.
r"/telegram-desktop\b",
r"/usr/lib/slack",
r"[Dd]iscord",
r"signal-desktop",
r"/viber\b",
r"/bin/element-desktop\b",
r"im\.riot\.Riot",
r"/opt/zoom/",
r"/bin/zoom\b",
r"teams-for-linux",
r"com\.microsoft\.Teams",
],
[r"^(element-desktop|teams-for-linux|telegram-desktop)", r"^zoom$"],
[],
),
# Host infrastructure.
(
"Docker engine",
[r"/usr/bin/dockerd", r"\bcontainerd\b", r"docker-proxy", r"containerd-shim"],
[r"^(dockerd|containerd|containerd-shim.*|docker-proxy|docker-init)$"],
[],
),
(
"Virtualization (KVM/VirtualBox)",
# The VirtualBox directory catches the rest of the host-side family -
# VBoxSVC, VBoxXPCOMIPCD, VBoxNetDHCP. Guest additions install to
# /usr/bin and /usr/sbin instead, which is what keeps them out of this
# row and in "Guest agents (VM/cloud)".
[
# All three anchored to argv[0]: the bare words used to file
# "grep -rn VirtualBox /etc" - and this tool's own command line -
# as a hypervisor.
r"^(?:\S*/)?qemu-system-[a-z0-9_]+(?:\s|$)",
r"^(?:\S*/)?(?:VBoxHeadless|VirtualBox(?:VM)?)(?:\s|$)",
# The host installation directory, both spellings: Debian's
# /usr/lib/virtualbox and Oracle's /opt/VirtualBox. Requiring a
# file component after it keeps "rsync -a /usr/lib/virtualbox/ /b"
# in the rsync row.
r"^(?:\S*/)?[Vv]irtual[Bb]ox/[A-Za-z]",
# Deliberately NOT argv[0]-anchored: libvirt's dnsmasq is named
# /usr/bin/dnsmasq and only its --conf-file argument says libvirt.
r"/libvirt",
],
# qemu-system-x86_64-spice cannot match the cmdline pattern (the hyphen
# ends [a-z0-9_]+), so the comm half is what catches the wrappers.
[r"^qemu-system-", r"^(?:VirtualBox(?:VM)?|VBoxHeadless)$"],
[],
),
# Services, both on the host and inside containers.
(
"Databases",
[r"\bpostgres\b", r"\bmysqld\b", r"\bmariadbd\b", r"redis-server", r"\bmongod\b"],
[],
[],
),
(
"Web servers and apps",
[r"\bnginx\b", r"\bapache2\b", r"\bhttpd\b", r"php-fpm", r"\bgunicorn\b", r"\buwsgi\b"],
[],
[],
),
# Desktop session.
(
"Desktop / X",
[
r"/Xorg\b",
# Every Wayland session runs one, and it holds the pixmaps of every
# X11 client on the desktop, so it is routinely the largest process
# on the machine. /Xorg above cannot reach it. Anchored to /bin/ or
# to the start of argv, or "rsync -a ~/src/xwayland /backup" would
# be filed as a desktop.
r"(?:^|/bin/)[Xx]wayland(?:-satellite)?(?![\w./-])",
r"\bxfce",
r"\bmate-",
r"\bmarco\b",
r"\bcaja\b",
r"plasmashell",
r"\bkwin",
r"gnome-shell",
# GNOME's settings daemons: about fifteen of them on a stock
# session. Anchored to argv[0] and to the directory they install
# into (/usr/libexec on Arch and Fedora, a gnome-settings-daemon
# subdirectory on Debian), because "gsd-" alone is short enough to
# appear inside a backup path.
r"^\S*/(?:libexec|gnome-settings-daemon)/gsd-[a-z0-9-]+(?:\s|$)",
# The KDE daemon zoo. ^\S* so the directory has to be part of
# argv[0]. The backup rules at the top of the list already protect
# "rsync -a /usr/libexec/kded6 /backup", but the anchor is what
# keeps a plain "vim /usr/libexec/kded6" out. Names are enumerated,
# never a k<word>d shape - /usr/sbin/knotd and /usr/bin/kadmind are
# not KDE. /kf[0-9]* covers the KF6 helper directory.
r"^\S*/(bin|lib(64|exec)?)(/kf[0-9]*)?/k(ded|globalacceld|walletd"
r"|activitymanagerd|access|runner|systemstats"
r"|screen_backend_launcher|screenlocker_greet|deconnectd|secretd"
r"|nighttimed|iod|smserver|unifiedpush-[a-z-]+|cminit|deinit"
r"|launcher)[0-9]*(?:\s|$)",
# Session helpers that are not k-prefixed: two Plasma protocol
# bridges, the crash handler family, and the XSETTINGS bridge (the
# X-side twin of gsd-xsettings, which is already in this row).
r"^(?:\S*/)?(?:xembedsniproxy|gmenudbusmenuproxy"
r"|drkonqi(?:-[a-z-]+)?|xsettingsd)(?:\s|$)",
# The control centres. They belong here rather than in a row of
# their own: "settings" is not something this tool can detect - it
# sees a named binary, and systemsettings is a component of the KDE
# shell exactly as plasmashell is. A generic Settings row would have
# to claim that a chess program's preferences window belongs in it,
# which nothing in /proc supports.
r"^(?:\S*/)?(?:systemsettings|kcmshell|gnome-control-center)"
r"[0-9]*(?:\s|$)",
# Cinnamon: the shell, its session binary and its helpers. Anchored
# to the END of argv[0], so "/home/me/src/cinnamon/build.sh" is not
# a desktop.
r"^(?:\S*/)?cinnamon(?:-[a-z-]+)?(?:\s|$)",
# The Cinnamon Settings Daemon, the csd-* fork of gsd-*. Both
# directories are needed: csd-* sit in /usr/bin, except csd-printer,
# which does not.
r"^\S*/(?:bin|libexec)/csd-[a-z0-9-]+(?:\s|$)",
# XApp, the component library Cinnamon, MATE and Xfce share. Its own
# directory is part of the anchor, which makes this tighter than the
# csd line above - worth keeping the two apart for that alone.
r"^\S*/xapps?/xapp-[a-z0-9-]+(?:\s|$)",
r"^(?:\S*/)?touchegg(?:\s|$)",
# Evolution Data Server and GNOME Online Accounts. Background
# infrastructure of a session rather than an application the user
# opened, which is why they go here and not in a row of their own:
# "personal data" is a category, and a category is not something
# /proc can show us.
r"^\S*/(?:libexec|lib)/(?:evolution-data-server/)?evolution-[a-z-]+(?:\s|$)",
r"^\S*/(?:libexec|lib)/goa-[a-z-]+(?:\s|$)",
# The GNOME session manager and its two helpers.
r"^\S*/libexec/gnome-session-[a-z-]+(?:\s|$)",
# The snap-to-desktop bridge, which ships as a snap of its own, and
# the launcher that snap uses. The launcher name is generic enough
# that it is pinned to a /snap path.
r"^(?:\S*/)?snapd-desktop-integration(?:\s|$)",
r"^/snap/\S+/user-session-helper(?:\s|$)",
# Display managers, replacing a bare "lightdm" that used to swallow
# "rsync -a /etc/lightdm /backup". Second directory alternative is
# their private libexec (/usr/lib/*/sddm/sddm-helper,
# /usr/lib/gdm3/gdm-x-session): those are not in bin or libexec.
r"^\S*/(?:bin|s?bin|lib(?:64|exec)?|sddm|lightdm|gdm[0-9]?)"
r"/(?:sddm|gdm|lightdm|greetd|plasmalogin)(?:-[a-z][a-z-]*)?[0-9]*(?:\s|$)",
# plasma-discover is a software centre, not session furniture, and
# it has a row of its own further down.
r"/(bin|lib(64|exec)?)/(start)?plasma[-_](?!discover)",
r"/org_kde_[A-Za-z]",
r"/polkit-[a-z]+-authentication-agent",
r"^(?:\S*/)?(?:ibus|fcitx5?)(?:-[a-z0-9-]+)?(?:\s|$)",
r"\bpicom\b",
r"pipewire",
r"pulseaudio",
r"wireplumber",
r"xdg-desktop-portal",
r"\bblueman",
r"[-_]applet\b",
r"applet\.py",
r"[-_]tray\b",
r"\bxclip\b",
r"\bclipit\b",
r"\bcopyq\b",
r"\bflameshot\b",
r"\bspectacle\b",
r"gnome-screenshot\b",
],
[
# comm carries the whole Wayland half of the desktop. These programs
# are started from a session file or a D-Bus service by bare name,
# so argv[0] has no directory for a cmdline pattern to anchor to,
# and comm - the executable's basename, truncated by the kernel to
# fifteen characters - is the only thing that identifies them. The
# patterns are written to survive that truncation.
r"^k(ded|globalacceld|walletd|activitymanage|access|runner"
r"|systemstats|screen_backend|screenlocker_g)",
# The daemons added below. Closed with $ rather than left open like
# the line above, because none of these names reaches the kernel's
# 15-character limit (kunifiedpush-di is the truncation of
# kunifiedpush-distributor and is exactly 15): an open end would
# file ~/bin/ksecretd-backup.sh as a desktop.
r"^k(deconnectd|secretd|nighttimed|iod|smserver|unifiedpush-di"
r"|cminit|deinit|launcher)[0-9]*$",
# Display managers, whose cmdline patterns all require a directory.
# gdmap, lightdmx and sddmconfig are excluded by the terminator.
r"^(sddm|gdm[0-9]?|lightdm|greetd)(-|$)",
# gnome-control-center is 20 characters, so the kernel truncates
# its comm to "gnome-control-c" - shorter than the name the cmdline
# pattern needs. This is the only control centre that needs a comm
# pattern; systemsettings and kcmshell are short enough that the
# cmdline pattern's own ^ branch reaches them.
r"^gnome-control-c",
r"^(start)?plasma[-_](?!discover)",
# The compositors themselves. They are also in SUPERVISOR_RE, and
# the two must ship together: a classified compositor absorbs every
# unmatched application on the desktop through the ancestor walk,
# because a Wayland client is a direct child of its compositor.
r"^(?:sway|[Hh]yprland|river|niri|wayfire|labwc|weston|dwl|cage)$",
# Bars and wallpaper daemons. The bars are a few megabytes and ride
# along; the wallpaper daemons are the reason - they hold a decoded
# full-resolution buffer per output, which is hundreds of megabytes
# on a multi-monitor 4K desktop.
r"^(?:waybar|eww|yambar|ironbar|sfwbar|polybar|mako|dunst|swaybg"
r"|swww-daemon|hyprpaper|wpaperd|mpvpaper)$",
# Spelled out rather than a bare ^cosmic-, which would swallow
# cosmic-term and cosmic-edit from Terminals and Editors.
r"^cosmic-(?:comp|panel|bg|osd|idle|greeter|launcher|notifica"
r"|workspa|settings|applet|session|dock)",
],
[],
),
(
"System services",
[
r"^/sbin/init",
r"systemd",
r"dbus-daemon",
r"dbus-broker",
r"NetworkManager",
r"polkitd",
r"udisksd",
r"upowerd",
r"accounts-daemon",
r"rtkit-daemon",
r"\bgeoclue",
r"bluetoothd\b",
r"\bvirt[a-z]*d\b",
r"\bcupsd\b",
r"\bsmbd\b",
r"\bsshd\b",
r"\bcrond\b",
r"\bavahi",
r"wpa_supplicant",
r"\b(chronyd|ntpd)\b",
r"irqbalance",
r"thermald",
r"\bacpid\b",
r"\bagetty\b",
r"spacenavd",
r"rsyslogd",
r"journald",
# D-Bus and systemd --user activate these, so before the supervisor
# boundary existed they reached this group by inheritance. Named
# explicitly now: matched on purpose beats matched by luck, and they
# are session plumbing on every GTK/GNOME/XFCE/MATE desktop, not just
# on the machine this was written on.
r"/gvfsd?(-[a-z0-9]+)*\b",
r"gvfs-[a-z-]+-volume-monitor",
r"gnome-keyring-daemon",
r"/at-spi(2)?-[a-z-]+",
r"/dconf-service\b",
r"xdg-permission-store",
r"xdg-document-portal",
r"/obexd\b",
r"\(sd-pam\)",
r"/wsdd\b",
# Ubuntu/Fedora daemons that were left Unclassified. argv[0]-only,
# so that an ordinary "vim /usr/libexec/colord" is not a service.
# Backups are protected by rule order, not by this anchor.
r"^(?:\S*/)?(?:cups-browsed|colord(?:-sane)?|ModemManager"
r"|power-profiles-daemon|switcheroo-control|mpris-proxy"
r"|(?:ana)?cron|ssh-agent)(?:\s|$)",
# Shebang scripts: the kernel rewrites argv[0] to the interpreter,
# so the program name is the SECOND word and no argv[0] anchor can
# reach it. Requiring a python interpreter in front, an absolute
# path, and a closed list of names is what keeps
# "vim /usr/bin/networkd-dispatcher" out.
r"^(?:\S*/)?python[0-9.]*(?: -\w+)* /\S+/"
r"(?:networkd-dispatcher|unattended-upgrade-shutdown)(?:\s|$)",
# Seen on two different distributions. The path has the name twice,
# /usr/libexec/fwupd/fwupd, which the trailing anchor handles.
r"^\S*/fwupd(?:\s|$)",
# GNOME's own ssh agent, beside gnome-keyring-daemon above.
r"^\S*/gcr-ssh-agent(?:\s|$)",
r"^(?:\S*/)?kerneloops(?:\s|$)",
],
# Only the two names the kernel truncates: for every other name above,
# an empty cmdline is replaced by comm and the argv[0] patterns match
# the bare basename on their own.
[r"^(?:power-profiles-|switcheroo-cont)$"],
[],
),
(
"Terminals",
[
r"mate-terminal",
r"gnome-terminal",
r"\bkonsole\b",
r"\balacritty\b",
r"\bkitty\b",
r"xfce4-terminal",
# The default terminal on Ubuntu 26.04 and Fedora 40+, where it
# replaced gnome-terminal. The agent is the process that owns the
# shell, so the shell reaches this row through the ancestor walk.
r"^(?:\S*/)?ptyxis(?:-agent)?(?:\s|$)",
r"\btmux\b",
],
[r"^(tmux.*|screen)$"],
[],
),
# Desktop applications, the software a machine that is not a developer's
# runs. Deliberately placed after everything above: a first-match-wins list
# is only safe to extend at the end, so nothing here can outrank a system or
# container process. A backup of ~/.local/share/Steam stays "rsync" because
# the backup rules sit at the very top of the list. Only the main process of
# an app needs a rule; helpers, Proton and Wine children and Electron
# zygotes reach their group through the ancestor walk.
# Two matchers, two jobs. A cmdline pattern ties a bare application
# name to a directory that really holds executables ("/bin/<name>" covers
# /usr/bin, /usr/local/bin, ~/.local/bin, Flatpak /app/bin, Snap
# /snap/x/current/usr/bin and an AppImage's /tmp/.mount_xxx/usr/bin in one
# go), never to a bare word - "/home/me/steamdeck" and "~/dev/brave-new-world"
# are not applications. comm carries the other half: .desktop files launch
# apps by bare name (Exec=gimp-3.2 %U, Exec=thunderbird %u), so argv[0] has
# no directory at all and only comm identifies the process. comm is the
# executable's basename truncated to 15 characters, so these patterns are
# truncation-safe.
# Steam, Lutris and Heroic launch games and nothing else, so "Games" here is
# a fact about the software rather than a guess about the user. Their own
# bundled Wine builds are listed here too, so that a game does not leak into
# the Wine row: this rule is checked first and first match wins.
(
"Games (Steam/Lutris/Heroic)",
[
r"/Steam/[^ ]*steam", # ~/.local/share/Steam/ubuntu12_64/steam
r"/(bin|games)/steam\b",
r"/usr/lib/steam/",
r"steamapps/common/",
r"compatibilitytools\.d/", # Proton and GE-Proton builds
r"SteamLinuxRuntime|pressure-vessel",
r"/bin/lutris\b",
r"lutris/runners/", # ~/.local/share/lutris/runners/wine/lutris-GE/bin/wine
r"/(bin|opt)/[Hh]eroic\b",
r"[Hh]eroic[^ /]*\.[Aa]ppImage",
r"heroic/tools/", # Heroic's own Wine and Proton builds
r"com\.heroicgameslauncher",
],
[r"^(steam|steamwebhelper|lutris|heroic)$"],
[],
),
# Wine is a Windows compatibility layer, not a game. Office suites, CAD,
# trading terminals and banking clients all run under it, so the row names
# what the process is instead of guessing what it is for. Games launched
# through Steam, Lutris or Heroic never reach this rule - the rule above
# claims them, bundled Wine builds included. Bottles belongs here for the
# same reason: it is a general-purpose Wine prefix manager.
(
"Wine (Windows apps)",
[
r"/bin/wine(server|boot|cfg|console|dbg|tricks)?\b",
r"/bin/bottles\b",
r"com\.usebottles\.bottles",
],
[
r"^wine(server|boot|cfg|console|dbg|tricks|64|32|-preloader)?$",
r"^bottles$",
],
[],
),
# Its own row, not part of "Creative apps": a scene or a render can be the
# largest thing on the machine, and then the row has to name the app.
("Blender", [r"/bin/blender\b", r"/blender[^ /]*/blender\b"], [r"^blender$"], []),
(
"Creative apps",
[
r"/bin/gimp(-console)?(-[0-9.]+)?\b", # gimp-3.2, not gimptool
r"/bin/(inkscape|krita|darktable|kdenlive|audacity|obs)\b",
r"/(opt|usr/lib)/obs-studio/",
],
[
r"^gimp(-console)?(-[0-9.]*)?$",
r"^(inkscape|krita|darktable|kdenlive|audacity|obs)$",
],
[],
),
(
"Media players",
[
r"/bin/(vlc|mpv|spotify)\b",
r"/(usr/share|opt|app)/spotify/",
r"spotify-launcher",
],
[r"^(vlc|mpv|spotify)$"],
[],
),
(
"Office and documents",
[
r"/soffice(\.bin)?\b", # the real LibreOffice process
r"/(bin|lib)/libreoffice\b",
r"/libreoffice/program/",
r"/opt/onlyoffice/",
r"/[Dd]esktop[Ee]ditors\b", # OnlyOffice ships as "desktopeditors"
r"/bin/(okular|evince|papers|calibre|ebook-(viewer|edit))\b",
],
[r"^(soffice|soffice\.bin|libreoffice|okular|evince|papers|calibre)$"],
[],
),
(
"Mozilla Thunderbird",
[r"/bin/thunderbird\b", r"/thunderbird/thunderbird\b"],
[r"^thunderbird$"],
[],
),
(
"Editors and notes",
[
r"/sublime_text\b",
# Never a bare "zed": /usr/bin/zed is the ZFS event daemon on any
# box with ZFS. The editor is "zeditor" when packaged, and lives
# under its own directory when installed by the official script.
r"/bin/zeditor\b",
# Official installer: $HOME/.local/zed.app/libexec/zed-editor
r"/zed\.app/",
r"/zed-editor\b",
r"/zed/bin/zed\b",
r"dev\.zed\.Zed",
r"/bin/emacs(-[0-9.]+)?\b",
r"/(bin|opt/[Oo]bsidian|opt/[Ll]ogseq)/(obsidian|logseq)\b",
r"[Oo]bsidian[^ /]*\.[Aa]ppImage",
],
[
# comm only for these two: "kate" is also a common first name, so a
# cmdline pattern would match every path under /home/kate.
r"^(kate|gedit|gnome-text-edit)",
r"^(sublime_text|plugin_host|zeditor|obsidian|logseq)",
r"^emacs(-[0-9.]*)?$",
],
[],
),
(
"Browsers (Brave/Vivaldi/Edge)",
[
r"brave\.com/brave",
r"/bin/brave(-browser)?\b",
r"/vivaldi(-snapshot)?/vivaldi",
r"/bin/vivaldi\b",
r"/opera(-beta|-developer)?/opera\b", # never a bare "opera": ~/Music/opera
r"/bin/opera\b",
r"/msedge\b",
r"/bin/microsoft-edge",
r"zen[-_]browser",
r"/zen/zen\b",
],
[r"^(brave|vivaldi|msedge|microsoft-edge)", r"^opera(-|$)"],
[],
),
(
"Password managers",
[
r"/bin/keepassxc(-proxy)?\b",
r"/opt/Bitwarden/",
r"/bin/bitwarden(-desktop)?\b",
r"/opt/1Password/",
r"/bin/1password\b",
],
[r"^(keepassxc|bitwarden|1password)"],
[],
),
(
"Sync and torrent clients",
[
r"/bin/(syncthing|nextcloud)\b",
r"/bin/qbittorrent(-nox)?\b",
r"/bin/deluge(-gtk|-console|d)?\b",
r"/bin/transmission-(gtk|qt|daemon|cli|remote)\b",
],
[r"^(syncthing|nextcloud|qbittorrent)", r"^(transmission|deluge)-?"],
[],
),
# These names are ordinary enough to turn up inside a path being copied.
# The backup rules at the top of the list are what keeps
# "borg create ::snap ~/baloo_files" a backup rather than an indexer; the
# argv[0] anchors below are what keeps "vim ~/baloo_files" out of the row.
(
# Often the largest surprise on a GNOME or KDE desktop, and always a
# surprise, because nobody starts it deliberately.
"File indexing (Tracker/Baloo)",
[
r"(?:^|/)(?:localsearch(?:-[a-z]+)?-3"
r"|tracker-(?:miner|extract|writeback|store)[a-z0-9-]*)\b",
r"/(bin|lib(64|exec)?)/baloo(_file|runner|ctl)[a-z_]*",
],
[r"^(baloo_file|baloorunner|tracker-|localsearch)"],
[],
),
(
"File managers",
[
# dolphin-emu is a GameCube emulator, not the KDE file manager.
r"/(bin|lib(64|exec)?)/(dolphin(?!-emu)|nautilus|[Tt]hunar"
r"|nemo(-desktop)?|pcmanfm(-qt)?|krusader)\b",
],
[r"^(nautilus|nemo(-desktop)?|[Tt]hunar|dolphin|pcmanfm(-qt)?|krusader)$"],
[],
),
(
"Software center and updates",
[
# Both call setproctitle(), which is why argv[0] carries no
# directory at all and why the capitalisation differs between them:
# mintUpdate.py sets "mintUpdate", mintinstall.py sets "mintinstall".
r"^(?:\S*/)?(?:gnome-software|snap-store"
r"|plasma-discover(?:-[a-z-]+)?|DiscoverNotifier"
r"|packagekitd|snapd|mintUpdate|mintinstall|update-notifier)(?:\s|$)",
# The package updater itself, behind its interpreter. Spelled
# separately from unattended-upgrade-shutdown in System services on
# purpose: they are two different programs and one pattern for both
# would put the updater in the wrong row.
r"^(?:\S*/)?python[0-9.]*(?: -\w+)* /\S+/"
r"(?:unattended-upgrade|check-new-release-gtk)(?:\s|$)",
],
# plasma-discover is exactly 15 characters; the shipped "plasma-discove"
# was 14 with a $ after it and could therefore never match.
[
r"^(gnome-software|snap-store|plasma-discover|DiscoverNotifie"
r"|packagekitd|snapd)$"
],
[],
),
(
# Deliberately not merged into "Virtualization (KVM/VirtualBox)": that
# row means this machine RUNS virtual machines and is measured in
# gigabytes, while these agents mean this machine IS one and never grow
# past a few dozen megabytes. Merging them would answer the question
# backwards on a guest.
"Guest agents (VM/cloud)",
[
r"/(bin|s?bin|lib(64|exec)?)/VBox(?:Client|DRMClient|Service|Control)"
r"(?:-all)?(?:\s|$)",
r"^(?:\S*/)?(?:vmtoolsd|vmware-user(?:-suid-wrapper)?|VGAuthService)(?:\s|$)",
r"^(?:\S*/)?(?:qemu-ga|spice-vdagentd?)(?:\s|$)",
],
# Named one by one, not a bare ^VBox: the host side has VBoxSVC,
# VBoxManage and VBoxXPCOMIPCD, and calling those guest agents would
# announce "this machine is a VM" on the machine running the VMs.
[
r"^VBox(Client|DRMClient|Service|Control)",
r"^(vmtoolsd|VGAuthService|qemu-ga|spice-vdagent)",
],
[],
),
# Last on purpose: this says how a tool was installed, not what it is, so it
# must not preempt any of the rules above. Whatever it does not catch falls
# through to the ancestor walk and lands on whichever app spawned it.
(
"Tools: npx / uv / pipx",
[r"/_npx/", r"\.cache/uv/archive", r"/pipx/venvs/"],
[],
[],
),
)
EXAMPLE_CONFIG = """\
# memgroups - user grouping rules.
# Goes to ~/.config/memgroups.toml (or $XDG_CONFIG_HOME/memgroups.toml).
# Create with: memgroups --init-config
[options]
# data - anonymous memory plus resident file pages that are not program text
# (default). The extra part is what a hypervisor's guest RAM, an mmap'd
# database and a memfd live in; without it seven running VMs read as
# 0.78 GiB instead of 20.45 GiB
# anon - anonymous memory only, which is what data was before the file half
# rss - full resident set (overstates: a shared page counts once per process)
# pss - proportional set size (fairer for fork families, but without root
# less than half of the processes are readable)
metric = "data"
# ram - processes plus tmpfs and kernel memory, percentages of total RAM.
# Disk cache is not listed: the kernel drops it under pressure,
# so it is free memory, not consumption
# processes - processes only, 100% = their sum
scope = "ram"
# How many groups to show, the rest is collapsed into one row. 0 shows all.
top = 15
# Display order of the table: size (default), swap, zswap, name.
# Order only - it changes no figure, and it does not change which groups survive
# `top` (that cut is by SIZE + SWAP, so a swapped-out group cannot drop off).
sort = "size"
# Containers of one docker-compose project are grouped together as
# "Docker compose: <project>". A container started by plain `docker run` has no
# compose label and becomes "Docker: <image>".
# Your own groups. Checked FIRST - before the built-in rules and before the
# automatic Docker grouping. So a rule of your own is the only way to pull a
# container out of its Docker group and name it yourself.
# The first match wins.
#
# match - regexes against the full command line
# match_comm - regexes against /proc/PID/comm (short name)
# match_container - regexes against the image name, the container name and the
# compose project (any of the three matching is enough)
# after_defaults - true if the rule should come AFTER the built-in ones
# [[group]]
# name = "MCP stack"
# match_container = ["^mcp$"]
# [[group]]
# name = "Heavy JVMs"
# match_comm = ["^java$"]
# after_defaults = true
"""
# --------------------------------------------------------------------------- #
# Data model
# --------------------------------------------------------------------------- #
def docker_group(image: str) -> str:
return f"{DOCKER_PREFIX}{image}"
def compose_group(project: str) -> str:
return f"{COMPOSE_PREFIX}{project}"
@dataclass(slots=True, frozen=True)
class Container:
"""A docker container as seen by `docker ps`."""
name: str = ""
image: str = ""
project: str = "" # com.docker.compose.project label, empty for plain `docker run`
@property
def label(self) -> str:
"""Containers of one compose project are one thing - group them as one.
Otherwise 31 containers of the same stack become 31 rows of 40 MiB each
and their real 2 GiB never shows up anywhere.
"""
if self.project:
return compose_group(self.project)
return docker_group(self.image or self.name)
def fields(self) -> tuple[str, ...]:
return tuple(f for f in (self.image, self.name, self.project) if f)
@dataclass(slots=True)
class Proc:
pid: int
ppid: int
rss: int # KiB
anon: int
# RssFile: file-backed resident memory. Read by aggregate() for the FILE column
# (--file), by the hint that points at it, and by data_file() for the default
# metric, which counts the part of it that is not program text. RssShmem is
# collected for completeness and is not read by the current grouping.
file: int
shmem: int
pss: int
comm: str
cmdline: str
container: Container | None = None
swap: int = 0 # KiB shown in the SWAP column: SwapPss in pss mode, else VmSwap
cgroup: str = "" # cgroup v2 path from /proc/PID/cgroup, "" under cgroup v1
# Always plain VmSwap, whatever the metric. The zswap split weights use this so
# every process in a cgroup is weighed on one scale: in pss mode `swap` is
# SwapPss only where smaps_rollup was readable, which would mix two models.
# Left at 0 it mirrors `swap`, which is exactly right outside pss mode.
swap_vm: int = 0
text_vm: int = 0 # VmExe + VmLib: a virtual CEILING on text, never a size
def __post_init__(self) -> None:
if not self.swap_vm:
self.swap_vm = self.swap
@property
def data_file(self) -> int:
"""Resident file pages that are not program text: guest RAM, mmap'd
databases, memfd.
VmExe and VmLib are VIRTUAL sizes, so subtracting them from a resident
figure always takes away at least as much as the resident text really
is. That makes this a lower bound and never an overstatement, which is
the only direction the tool is allowed to be wrong in. It also needs
nothing but /proc/PID/status - smaps is unreadable for exactly the
processes this exists for, because VirtualBoxVM is not dumpable.
"""
return max(0, self.file - self.text_vm)
def metric(self, name: str) -> int:
if name == "data":
return self.anon + self.data_file
if name == "anon":
return self.anon
if name == "rss":
return self.rss
return self.pss
@dataclass(slots=True)
class Rule:
name: str
match: list[re.Pattern[str]] = field(default_factory=list)
match_comm: list[re.Pattern[str]] = field(default_factory=list)
match_container: list[re.Pattern[str]] = field(default_factory=list)
# User rules are checked before the "Docker: <image>" fallback so that own
# containers can still be grouped by hand. Defaults are checked after it.
pre_container: bool = False
def test(self, p: Proc) -> bool:
if any(rx.search(p.cmdline) for rx in self.match):
return True
if any(rx.search(p.comm) for rx in self.match_comm):
return True
if p.container and self.match_container:
fields = p.container.fields()
if any(rx.search(f) for rx in self.match_container for f in fields):
return True
return False
@dataclass(slots=True)
class Row:
name: str
size: int # KiB of RAM, may be negative for the delta row
procs: int # -1 for synthetic rows
swap: int = 0 # KiB swapped out (sum of VmSwap). Never folded into size.
# Share of the zswap pool charged to this row. A breakdown of the zswap row's
# size, never extra RAM, so it is never folded into size either.
zswap: int = 0
zswap_estimated: bool = False # True if any part of zswap came from a split
# LAST on purpose, even though it reads better next to `swap`: aggregate() and
# apply_top() build Rows positionally through zswap_estimated, and many tests
# construct Rows positionally too. A field inserted in the middle would silently
# reinterpret every one of those. The same applies to `file` and `file_max`
# below: they are appended after swap_estimated, never spliced in next to swap.
swap_estimated: bool = False # True when swap contains the tmpfs estimate
file: int = 0 # summed RssFile of the group's processes; never part of size
# Largest single RssFile in the group. Never displayed. It is the only file
# figure with no cross-process double counting, which is exactly what
# file_hint() needs and what the hint line quotes.
file_max: int = 0
# --------------------------------------------------------------------------- #
# /proc parsing
# --------------------------------------------------------------------------- #
def _read(path: Path) -> str | None:
"""Read a /proc file, tolerating processes that vanish mid-scan."""
try:
return path.read_text(encoding="utf-8", errors="replace")