sgrud 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.
- sgrud/__init__.py +45 -0
- sgrud/cli.py +198 -0
- sgrud/errors.py +42 -0
- sgrud/format.py +168 -0
- sgrud/models.py +203 -0
- sgrud/monitor.py +324 -0
- sgrud/procfs.py +160 -0
- sgrud/profile.py +167 -0
- sgrud/remote.py +328 -0
- sgrud/sampler.py +88 -0
- sgrud/tui.py +551 -0
- sgrud-0.1.0.dist-info/METADATA +122 -0
- sgrud-0.1.0.dist-info/RECORD +16 -0
- sgrud-0.1.0.dist-info/WHEEL +4 -0
- sgrud-0.1.0.dist-info/entry_points.txt +2 -0
- sgrud-0.1.0.dist-info/licenses/LICENSE +20 -0
sgrud/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""sgrud: inspect a running CPython process from the outside.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from sgrud import Monitor
|
|
6
|
+
|
|
7
|
+
with Monitor.attach(pid) as m:
|
|
8
|
+
snap = m.snapshot()
|
|
9
|
+
print(snap.process.memory.rss, snap.process.cpu_percent)
|
|
10
|
+
for t in snap.threads:
|
|
11
|
+
print(t.tid, t.name, t.status.describe(), t.frames[:1])
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .errors import AttachError, NotSupported, ProcessExited, SgrudError
|
|
15
|
+
from .models import (
|
|
16
|
+
Awaiter,
|
|
17
|
+
Frame,
|
|
18
|
+
GCCollection,
|
|
19
|
+
GCGeneration,
|
|
20
|
+
Memory,
|
|
21
|
+
Process,
|
|
22
|
+
Snapshot,
|
|
23
|
+
Task,
|
|
24
|
+
Thread,
|
|
25
|
+
ThreadStatus,
|
|
26
|
+
)
|
|
27
|
+
from .monitor import Monitor
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"AttachError",
|
|
31
|
+
"Awaiter",
|
|
32
|
+
"Frame",
|
|
33
|
+
"GCCollection",
|
|
34
|
+
"GCGeneration",
|
|
35
|
+
"Memory",
|
|
36
|
+
"Monitor",
|
|
37
|
+
"NotSupported",
|
|
38
|
+
"Process",
|
|
39
|
+
"ProcessExited",
|
|
40
|
+
"SgrudError",
|
|
41
|
+
"Snapshot",
|
|
42
|
+
"Task",
|
|
43
|
+
"Thread",
|
|
44
|
+
"ThreadStatus",
|
|
45
|
+
]
|
sgrud/cli.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
sgrud PID one text snapshot
|
|
4
|
+
sgrud PID -n 0.5 keep printing snapshots every 0.5 s
|
|
5
|
+
sgrud PID --json JSON lines instead of text
|
|
6
|
+
sgrud PID --profile 5 sample stacks for 5 s and print the hottest functions
|
|
7
|
+
sgrud run -- python app.py spawn the target as a child, then inspect it
|
|
8
|
+
sgrud tui PID interactive Textual interface
|
|
9
|
+
sgrud tui run -- python app.py
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import dataclasses
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
|
|
21
|
+
from . import __name__ as _pkg
|
|
22
|
+
from .errors import SgrudError
|
|
23
|
+
from .format import format_snapshot
|
|
24
|
+
from .monitor import Monitor
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _add_target(parser: argparse.ArgumentParser) -> None:
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"target",
|
|
30
|
+
help="a pid, or `run -- CMD [ARGS...]` to spawn the target as a child",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _add_sections(parser: argparse.ArgumentParser) -> None:
|
|
35
|
+
parser.add_argument("--no-stacks", action="store_true", help="skip stack traces")
|
|
36
|
+
parser.add_argument("--no-tasks", action="store_true", help="skip asyncio tasks")
|
|
37
|
+
parser.add_argument("--no-gc", action="store_true", help="skip GC statistics")
|
|
38
|
+
parser.add_argument("--no-native", action="store_true", help="hide <native> marker frames")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
42
|
+
parser = argparse.ArgumentParser(prog=_pkg, description=__doc__.split("\n\n")[0])
|
|
43
|
+
sub = parser.add_subparsers(dest="command")
|
|
44
|
+
|
|
45
|
+
dump = sub.add_parser("dump", help="print snapshots (default command)")
|
|
46
|
+
_add_target(dump)
|
|
47
|
+
_add_sections(dump)
|
|
48
|
+
dump.add_argument("-n", "--interval", type=float, default=None,
|
|
49
|
+
help="repeat every N seconds until the target exits")
|
|
50
|
+
dump.add_argument("-c", "--count", type=int, default=None, help="stop after N snapshots")
|
|
51
|
+
dump.add_argument("--json", action="store_true", help="emit one JSON object per line")
|
|
52
|
+
dump.add_argument("--max-frames", type=int, default=None, help="frames per thread to show")
|
|
53
|
+
dump.add_argument("--profile", type=float, metavar="SECONDS", default=None,
|
|
54
|
+
help="sample stacks for SECONDS and print a hotspot table instead")
|
|
55
|
+
dump.add_argument("--rate", type=float, default=200.0, help="samples per second for --profile")
|
|
56
|
+
dump.add_argument("--sort", choices=("self", "total"), default="self",
|
|
57
|
+
help="hotspot ordering for --profile")
|
|
58
|
+
dump.add_argument("--mode", choices=("wall", "gil"), default="wall",
|
|
59
|
+
help="count every thread (wall) or only the GIL holder (gil)")
|
|
60
|
+
|
|
61
|
+
tui = sub.add_parser("tui", help="interactive terminal interface")
|
|
62
|
+
_add_target(tui)
|
|
63
|
+
_add_sections(tui)
|
|
64
|
+
tui.add_argument("-n", "--interval", type=float, default=1.0, help="refresh interval")
|
|
65
|
+
tui.add_argument("--rate", type=float, default=100.0,
|
|
66
|
+
help="background stack samples per second for the Hotspots tab, 0 to disable")
|
|
67
|
+
tui.add_argument("--mode", choices=("wall", "gil"), default="wall",
|
|
68
|
+
help="initial hotspot mode, toggle with `m` in the TUI")
|
|
69
|
+
return parser
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def open_monitor(target: str, command: Sequence[str], **options) -> Monitor:
|
|
73
|
+
if target == "run":
|
|
74
|
+
if not command:
|
|
75
|
+
raise SgrudError("`run` needs a command after `--`")
|
|
76
|
+
return Monitor.spawn(list(command), **options)
|
|
77
|
+
if command:
|
|
78
|
+
raise SgrudError("a command after `--` only makes sense with `run`")
|
|
79
|
+
if not target.isdigit():
|
|
80
|
+
raise SgrudError(f"expected a pid or `run -- CMD`, got {target!r}")
|
|
81
|
+
return Monitor.attach(int(target), **options)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _dump(args: argparse.Namespace) -> int:
|
|
85
|
+
sections = dict(stacks=not args.no_stacks, tasks=not args.no_tasks, gc=not args.no_gc)
|
|
86
|
+
try:
|
|
87
|
+
monitor = open_monitor(args.target, args.command_argv, native_frames=not args.no_native)
|
|
88
|
+
except SgrudError as e:
|
|
89
|
+
print(f"sgrud: {e}", file=sys.stderr)
|
|
90
|
+
return 1
|
|
91
|
+
produced = 0
|
|
92
|
+
if monitor.limited is not None:
|
|
93
|
+
print(f"sgrud: limited mode, only /proc data is available. {monitor.limited}",
|
|
94
|
+
file=sys.stderr)
|
|
95
|
+
try:
|
|
96
|
+
with monitor:
|
|
97
|
+
if args.profile is not None:
|
|
98
|
+
if monitor.limited is not None:
|
|
99
|
+
print("sgrud: --profile needs access to the target's memory", file=sys.stderr)
|
|
100
|
+
return 1
|
|
101
|
+
return _profile(monitor, args)
|
|
102
|
+
if args.interval is None:
|
|
103
|
+
# A second sample a moment later gives meaningful CPU percentages.
|
|
104
|
+
monitor.snapshot(stacks=False, tasks=False, gc=False)
|
|
105
|
+
time.sleep(0.1)
|
|
106
|
+
snaps = iter([monitor.snapshot(**sections)])
|
|
107
|
+
else:
|
|
108
|
+
snaps = monitor.stream(args.interval, **sections)
|
|
109
|
+
for snap in snaps:
|
|
110
|
+
if args.json:
|
|
111
|
+
print(json.dumps(snap.to_dict(), default=str), flush=True)
|
|
112
|
+
else:
|
|
113
|
+
if produced:
|
|
114
|
+
print()
|
|
115
|
+
print(
|
|
116
|
+
format_snapshot(
|
|
117
|
+
snap,
|
|
118
|
+
frames=sections["stacks"],
|
|
119
|
+
tasks=sections["tasks"],
|
|
120
|
+
gc=sections["gc"],
|
|
121
|
+
max_frames=args.max_frames,
|
|
122
|
+
),
|
|
123
|
+
flush=True,
|
|
124
|
+
)
|
|
125
|
+
produced += 1
|
|
126
|
+
if args.count is not None and produced >= args.count:
|
|
127
|
+
break
|
|
128
|
+
except SgrudError as e:
|
|
129
|
+
print(f"sgrud: {e}", file=sys.stderr)
|
|
130
|
+
return 1 if not produced else 0
|
|
131
|
+
except KeyboardInterrupt:
|
|
132
|
+
pass
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _profile(monitor: Monitor, args: argparse.Namespace) -> int:
|
|
137
|
+
from .format import format_hotspots
|
|
138
|
+
from .sampler import Sampler
|
|
139
|
+
|
|
140
|
+
sampler = Sampler(monitor, rate=args.rate, mode=args.mode)
|
|
141
|
+
with sampler:
|
|
142
|
+
deadline = time.monotonic() + args.profile
|
|
143
|
+
while time.monotonic() < deadline and sampler.exited is None:
|
|
144
|
+
time.sleep(0.05)
|
|
145
|
+
hot = sampler.hotspots
|
|
146
|
+
if args.json:
|
|
147
|
+
rows = [dataclasses.asdict(r) for r in hot.rows(sort=args.sort)]
|
|
148
|
+
print(json.dumps({"samples": hot.samples, "rate": hot.rate(), "mode": hot.mode,
|
|
149
|
+
"errors": sampler.errors, "rows": rows}))
|
|
150
|
+
else:
|
|
151
|
+
print(format_hotspots(hot.rows(sort=args.sort), samples=hot.samples, rate=hot.rate(),
|
|
152
|
+
mode=hot.mode))
|
|
153
|
+
if sampler.errors:
|
|
154
|
+
print(f"({sampler.errors} samples failed, last: {sampler.last_error})")
|
|
155
|
+
if sampler.exited is not None:
|
|
156
|
+
print(f"sgrud: {sampler.exited}", file=sys.stderr)
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _tui(args: argparse.Namespace) -> int:
|
|
161
|
+
from .tui import run_tui
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
monitor = open_monitor(args.target, args.command_argv, native_frames=not args.no_native)
|
|
165
|
+
except SgrudError as e:
|
|
166
|
+
print(f"sgrud: {e}", file=sys.stderr)
|
|
167
|
+
return 1
|
|
168
|
+
return run_tui(
|
|
169
|
+
monitor,
|
|
170
|
+
interval=args.interval,
|
|
171
|
+
stacks=not args.no_stacks,
|
|
172
|
+
tasks=not args.no_tasks,
|
|
173
|
+
gc=not args.no_gc,
|
|
174
|
+
sample_rate=args.rate,
|
|
175
|
+
sample_mode=args.mode,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
180
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
181
|
+
command_argv: list[str] = []
|
|
182
|
+
if "--" in argv:
|
|
183
|
+
cut = argv.index("--")
|
|
184
|
+
argv, command_argv = argv[:cut], argv[cut + 1 :]
|
|
185
|
+
if argv and argv[0] not in {"dump", "tui", "-h", "--help"}:
|
|
186
|
+
argv.insert(0, "dump")
|
|
187
|
+
args = build_parser().parse_args(argv)
|
|
188
|
+
args.command_argv = command_argv
|
|
189
|
+
if args.command == "tui":
|
|
190
|
+
return _tui(args)
|
|
191
|
+
if args.command == "dump":
|
|
192
|
+
return _dump(args)
|
|
193
|
+
build_parser().print_help()
|
|
194
|
+
return 2
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == "__main__":
|
|
198
|
+
sys.exit(main())
|
sgrud/errors.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Exceptions raised by sgrud."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SgrudError(Exception):
|
|
7
|
+
"""Base class for all sgrud errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProcessExited(SgrudError):
|
|
11
|
+
"""The target process is gone."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, pid: int, returncode: int | None = None):
|
|
14
|
+
self.pid = pid
|
|
15
|
+
self.returncode = returncode
|
|
16
|
+
msg = f"process {pid} has exited"
|
|
17
|
+
if returncode is not None:
|
|
18
|
+
msg += f" with code {returncode}"
|
|
19
|
+
super().__init__(msg)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AttachError(SgrudError):
|
|
23
|
+
"""Could not attach to the target process.
|
|
24
|
+
|
|
25
|
+
``hint`` carries a human readable suggestion on how to fix it.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self, pid: int, message: str, hint: str | None = None, *, transient: bool = False
|
|
30
|
+
):
|
|
31
|
+
self.pid = pid
|
|
32
|
+
self.hint = hint
|
|
33
|
+
#: True when the target may simply still be starting up.
|
|
34
|
+
self.transient = transient
|
|
35
|
+
text = f"cannot attach to process {pid}: {message}"
|
|
36
|
+
if hint:
|
|
37
|
+
text += f"\n{hint}"
|
|
38
|
+
super().__init__(text)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NotSupported(SgrudError):
|
|
42
|
+
"""The platform or interpreter lacks a required feature."""
|
sgrud/format.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Plain text rendering of snapshots, shared by the CLI and usable in tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
|
|
9
|
+
from .models import Frame, Snapshot, Task, Thread
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def human_bytes(n: int) -> str:
|
|
13
|
+
value = float(n)
|
|
14
|
+
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
15
|
+
if abs(value) < 1024 or unit == "TiB":
|
|
16
|
+
return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
|
|
17
|
+
value /= 1024
|
|
18
|
+
return f"{value:.1f} TiB"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def human_duration(seconds: float) -> str:
|
|
22
|
+
if math.isnan(seconds):
|
|
23
|
+
return "?"
|
|
24
|
+
if seconds < 1e-3:
|
|
25
|
+
return f"{seconds * 1e6:.0f}µs"
|
|
26
|
+
if seconds < 1:
|
|
27
|
+
return f"{seconds * 1e3:.1f}ms"
|
|
28
|
+
if seconds < 60:
|
|
29
|
+
return f"{seconds:.1f}s"
|
|
30
|
+
minutes, sec = divmod(int(seconds), 60)
|
|
31
|
+
hours, minutes = divmod(minutes, 60)
|
|
32
|
+
if hours:
|
|
33
|
+
return f"{hours}h{minutes:02d}m"
|
|
34
|
+
return f"{minutes}m{sec:02d}s"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def short_path(path: str, keep: int = 2) -> str:
|
|
38
|
+
if not path or path.startswith("<"):
|
|
39
|
+
return path
|
|
40
|
+
parts = path.replace("\\", "/").split("/")
|
|
41
|
+
return "/".join(parts[-keep:]) if len(parts) > keep else path
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def percent(value: float | None) -> str:
|
|
45
|
+
return " - " if value is None else f"{value:5.1f}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def format_frames(frames: Iterable[Frame], indent: str = " ") -> list[str]:
|
|
49
|
+
lines = []
|
|
50
|
+
for f in frames:
|
|
51
|
+
if f.synthetic:
|
|
52
|
+
lines.append(f"{indent}{f.funcname}")
|
|
53
|
+
else:
|
|
54
|
+
lines.append(f"{indent}{f.funcname} {short_path(f.filename)}:{f.lineno}")
|
|
55
|
+
return lines
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def format_thread(t: Thread, *, frames: bool = True, max_frames: int | None = None) -> list[str]:
|
|
59
|
+
head = (
|
|
60
|
+
f"[{t.tid}] {t.name:<16} {t.status.describe():<12} state={t.state} "
|
|
61
|
+
f"cpu={percent(t.cpu_percent)}% utime={t.user_time:.2f}s stime={t.system_time:.2f}s"
|
|
62
|
+
)
|
|
63
|
+
lines = [head]
|
|
64
|
+
if frames:
|
|
65
|
+
shown = t.frames if max_frames is None else t.frames[:max_frames]
|
|
66
|
+
lines.extend(format_frames(shown))
|
|
67
|
+
if max_frames is not None and len(t.frames) > max_frames:
|
|
68
|
+
lines.append(f" ... {len(t.frames) - max_frames} more")
|
|
69
|
+
return lines
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def format_task_tree(snap: Snapshot) -> list[str]:
|
|
73
|
+
children = snap.task_children()
|
|
74
|
+
by_id = {t.id: t for t in snap.tasks}
|
|
75
|
+
lines: list[str] = []
|
|
76
|
+
|
|
77
|
+
def label(t: Task) -> str:
|
|
78
|
+
top = t.frames[0].funcname if t.frames else "?"
|
|
79
|
+
chain = " <- ".join(f.funcname for f in t.frames[:4])
|
|
80
|
+
return f"{t.name} (0x{t.id:x}) [tid {t.thread_id}] {chain or top}"
|
|
81
|
+
|
|
82
|
+
def walk(t: Task, prefix: str, last: bool, seen: set[int]) -> None:
|
|
83
|
+
branch = "└─ " if last else "├─ "
|
|
84
|
+
lines.append(f"{prefix}{branch}{label(t)}")
|
|
85
|
+
if t.id in seen:
|
|
86
|
+
lines.append(f"{prefix}{' ' if last else '│ '}(cycle)")
|
|
87
|
+
return
|
|
88
|
+
seen = seen | {t.id}
|
|
89
|
+
kids = children.get(t.id, [])
|
|
90
|
+
for i, k in enumerate(kids):
|
|
91
|
+
walk(k, prefix + (" " if last else "│ "), i == len(kids) - 1, seen)
|
|
92
|
+
|
|
93
|
+
roots = children.get(None, [])
|
|
94
|
+
for i, root in enumerate(roots):
|
|
95
|
+
walk(root, "", i == len(roots) - 1, set())
|
|
96
|
+
if not roots and by_id:
|
|
97
|
+
lines.append("(all tasks are in await cycles)")
|
|
98
|
+
return lines
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def format_gc(snap: Snapshot) -> list[str]:
|
|
102
|
+
lines = []
|
|
103
|
+
for g in snap.gc:
|
|
104
|
+
lines.append(
|
|
105
|
+
f"gen{g.generation}: {g.collections} collections, {g.collected} collected, "
|
|
106
|
+
f"{g.uncollectable} uncollectable, total {human_duration(g.total_duration)}, "
|
|
107
|
+
f"heap {g.heap_size}"
|
|
108
|
+
)
|
|
109
|
+
for c in g.history[:3]:
|
|
110
|
+
lines.append(
|
|
111
|
+
f" last: {human_duration(c.duration)} collected={c.collected} "
|
|
112
|
+
f"candidates={c.candidates} heap={c.heap_size}"
|
|
113
|
+
)
|
|
114
|
+
return lines
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def format_snapshot(
|
|
118
|
+
snap: Snapshot,
|
|
119
|
+
*,
|
|
120
|
+
threads: bool = True,
|
|
121
|
+
frames: bool = True,
|
|
122
|
+
tasks: bool = True,
|
|
123
|
+
gc: bool = True,
|
|
124
|
+
max_frames: int | None = None,
|
|
125
|
+
) -> str:
|
|
126
|
+
p = snap.process
|
|
127
|
+
m = p.memory
|
|
128
|
+
lines = [
|
|
129
|
+
f"pid {p.pid} {os.path.basename(p.exe) or '?'} {' '.join(p.cmdline)[:80]}",
|
|
130
|
+
f"state={p.state} threads={p.num_threads} uptime={human_duration(p.uptime)} "
|
|
131
|
+
f"cpu={percent(p.cpu_percent)}% utime={p.user_time:.2f}s stime={p.system_time:.2f}s",
|
|
132
|
+
f"rss={human_bytes(m.rss)} vms={human_bytes(m.vms)} hwm={human_bytes(m.hwm)} "
|
|
133
|
+
f"swap={human_bytes(m.swap)} data={human_bytes(m.data)} shared={human_bytes(m.shared)}",
|
|
134
|
+
]
|
|
135
|
+
if threads:
|
|
136
|
+
lines.append("")
|
|
137
|
+
lines.append(f"threads ({len(snap.threads)}):")
|
|
138
|
+
for t in snap.threads:
|
|
139
|
+
lines.extend(format_thread(t, frames=frames, max_frames=max_frames))
|
|
140
|
+
if tasks:
|
|
141
|
+
lines.append("")
|
|
142
|
+
lines.append(f"asyncio tasks ({len(snap.tasks)}):")
|
|
143
|
+
lines.extend(format_task_tree(snap))
|
|
144
|
+
if gc:
|
|
145
|
+
lines.append("")
|
|
146
|
+
lines.append("gc:")
|
|
147
|
+
lines.extend(format_gc(snap))
|
|
148
|
+
for section, err in snap.errors.items():
|
|
149
|
+
lines.append(f"! {section}: {err}")
|
|
150
|
+
return "\n".join(lines)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def format_hotspots(
|
|
154
|
+
rows, *, samples: int, rate: float | None = None, mode: str = "wall", limit: int = 25
|
|
155
|
+
) -> str:
|
|
156
|
+
"""Render hotspot rows (see :meth:`sgrud.profile.Hotspots.rows`) as a table."""
|
|
157
|
+
head = f"hotspots ({mode}): {samples} samples"
|
|
158
|
+
if rate:
|
|
159
|
+
head += f" at {rate:.0f}/s"
|
|
160
|
+
lines = [head, f"{'self%':>6} {'total%':>7} {'self':>7} {'total':>7} function file"]
|
|
161
|
+
for r in rows[:limit]:
|
|
162
|
+
lines.append(
|
|
163
|
+
f"{r.self_percent:6.1f} {r.total_percent:7.1f} {r.self_samples:7d} "
|
|
164
|
+
f"{r.total_samples:7d} {r.funcname} {short_path(r.filename)}"
|
|
165
|
+
)
|
|
166
|
+
if not rows:
|
|
167
|
+
lines.append("(no samples)")
|
|
168
|
+
return "\n".join(lines)
|
sgrud/models.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Plain data model for a snapshot of a remote Python process.
|
|
2
|
+
|
|
3
|
+
Everything here is a frozen dataclass so snapshots can be compared, cached,
|
|
4
|
+
serialized with :func:`dataclasses.asdict` and consumed by any front end
|
|
5
|
+
(CLI, TUI, tests) without touching ``_remote_debugging`` or ``/proc``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import enum
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ThreadStatus(enum.IntFlag):
|
|
17
|
+
"""Bit flags reported by ``_remote_debugging`` for each thread."""
|
|
18
|
+
|
|
19
|
+
NONE = 0
|
|
20
|
+
HAS_GIL = 1 << 0
|
|
21
|
+
ON_CPU = 1 << 1
|
|
22
|
+
UNKNOWN = 1 << 2
|
|
23
|
+
GIL_REQUESTED = 1 << 3
|
|
24
|
+
HAS_EXCEPTION = 1 << 4
|
|
25
|
+
MAIN_THREAD = 1 << 5
|
|
26
|
+
|
|
27
|
+
def describe(self) -> str:
|
|
28
|
+
"""Short human readable label such as ``"gil,cpu"``."""
|
|
29
|
+
parts = []
|
|
30
|
+
if self & ThreadStatus.MAIN_THREAD:
|
|
31
|
+
parts.append("main")
|
|
32
|
+
if self & ThreadStatus.HAS_GIL:
|
|
33
|
+
parts.append("gil")
|
|
34
|
+
elif self & ThreadStatus.GIL_REQUESTED:
|
|
35
|
+
parts.append("wait-gil")
|
|
36
|
+
if self & ThreadStatus.ON_CPU:
|
|
37
|
+
parts.append("cpu")
|
|
38
|
+
if self & ThreadStatus.HAS_EXCEPTION:
|
|
39
|
+
parts.append("exc")
|
|
40
|
+
if self & ThreadStatus.UNKNOWN:
|
|
41
|
+
parts.append("?")
|
|
42
|
+
return ",".join(parts) or "idle"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Frame:
|
|
47
|
+
"""One Python stack frame, or a synthetic ``<native>`` / ``<GC>`` marker."""
|
|
48
|
+
|
|
49
|
+
funcname: str
|
|
50
|
+
filename: str
|
|
51
|
+
lineno: int | None = None
|
|
52
|
+
end_lineno: int | None = None
|
|
53
|
+
col_offset: int | None = None
|
|
54
|
+
end_col_offset: int | None = None
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def synthetic(self) -> bool:
|
|
58
|
+
return self.lineno is None
|
|
59
|
+
|
|
60
|
+
def format(self) -> str:
|
|
61
|
+
if self.synthetic:
|
|
62
|
+
return self.funcname
|
|
63
|
+
return f"{self.funcname} ({self.filename}:{self.lineno})"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class Thread:
|
|
68
|
+
"""An OS thread that belongs to the target interpreter."""
|
|
69
|
+
|
|
70
|
+
tid: int
|
|
71
|
+
name: str
|
|
72
|
+
interpreter_id: int
|
|
73
|
+
status: ThreadStatus
|
|
74
|
+
#: Kernel scheduler state letter from ``/proc`` (R, S, D, ...), or "".
|
|
75
|
+
state: str
|
|
76
|
+
user_time: float
|
|
77
|
+
system_time: float
|
|
78
|
+
#: CPU usage since the previous snapshot in percent of one core.
|
|
79
|
+
#: ``None`` for the first snapshot of a monitor.
|
|
80
|
+
cpu_percent: float | None
|
|
81
|
+
#: Leaf frame first.
|
|
82
|
+
frames: tuple[Frame, ...] = ()
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def is_main(self) -> bool:
|
|
86
|
+
return bool(self.status & ThreadStatus.MAIN_THREAD)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True, slots=True)
|
|
90
|
+
class Awaiter:
|
|
91
|
+
"""A task waiting on another task, with the frames of the awaiting coroutine."""
|
|
92
|
+
|
|
93
|
+
task_id: int
|
|
94
|
+
frames: tuple[Frame, ...] = ()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class Task:
|
|
99
|
+
"""An asyncio task discovered in the target process."""
|
|
100
|
+
|
|
101
|
+
id: int
|
|
102
|
+
name: str
|
|
103
|
+
thread_id: int
|
|
104
|
+
#: Coroutine call stack, leaf first.
|
|
105
|
+
frames: tuple[Frame, ...] = ()
|
|
106
|
+
awaited_by: tuple[Awaiter, ...] = ()
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def parent_ids(self) -> tuple[int, ...]:
|
|
110
|
+
return tuple(a.task_id for a in self.awaited_by)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, slots=True)
|
|
114
|
+
class GCCollection:
|
|
115
|
+
"""One garbage collection recorded in the target's GC history ring."""
|
|
116
|
+
|
|
117
|
+
generation: int
|
|
118
|
+
interpreter_id: int
|
|
119
|
+
started_at: int
|
|
120
|
+
stopped_at: int
|
|
121
|
+
duration: float
|
|
122
|
+
collected: int
|
|
123
|
+
uncollectable: int
|
|
124
|
+
candidates: int
|
|
125
|
+
heap_size: int
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True, slots=True)
|
|
129
|
+
class GCGeneration:
|
|
130
|
+
generation: int
|
|
131
|
+
collections: int
|
|
132
|
+
collected: int
|
|
133
|
+
uncollectable: int
|
|
134
|
+
total_duration: float
|
|
135
|
+
heap_size: int
|
|
136
|
+
#: Most recent first.
|
|
137
|
+
history: tuple[GCCollection, ...] = ()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True, slots=True)
|
|
141
|
+
class Memory:
|
|
142
|
+
"""Process memory figures from ``/proc/<pid>/status``, in bytes."""
|
|
143
|
+
|
|
144
|
+
rss: int
|
|
145
|
+
vms: int
|
|
146
|
+
hwm: int
|
|
147
|
+
swap: int
|
|
148
|
+
data: int
|
|
149
|
+
shared: int
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True, slots=True)
|
|
153
|
+
class Process:
|
|
154
|
+
pid: int
|
|
155
|
+
exe: str
|
|
156
|
+
cmdline: tuple[str, ...]
|
|
157
|
+
state: str
|
|
158
|
+
num_threads: int
|
|
159
|
+
memory: Memory
|
|
160
|
+
user_time: float
|
|
161
|
+
system_time: float
|
|
162
|
+
#: Seconds since the process started.
|
|
163
|
+
uptime: float
|
|
164
|
+
#: CPU usage since the previous snapshot in percent of one core.
|
|
165
|
+
cpu_percent: float | None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True, slots=True)
|
|
169
|
+
class Snapshot:
|
|
170
|
+
"""A consistent-as-practical picture of the target at one instant."""
|
|
171
|
+
|
|
172
|
+
timestamp: float
|
|
173
|
+
process: Process
|
|
174
|
+
threads: tuple[Thread, ...] = ()
|
|
175
|
+
tasks: tuple[Task, ...] = ()
|
|
176
|
+
gc: tuple[GCGeneration, ...] = ()
|
|
177
|
+
#: Sections that could not be collected, mapped to the error text.
|
|
178
|
+
errors: dict[str, str] = field(default_factory=dict)
|
|
179
|
+
|
|
180
|
+
def thread(self, tid: int) -> Thread | None:
|
|
181
|
+
for t in self.threads:
|
|
182
|
+
if t.tid == tid:
|
|
183
|
+
return t
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
def task(self, task_id: int) -> Task | None:
|
|
187
|
+
for t in self.tasks:
|
|
188
|
+
if t.id == task_id:
|
|
189
|
+
return t
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
def task_children(self) -> dict[int | None, list[Task]]:
|
|
193
|
+
"""Map parent task id (``None`` for roots) to its child tasks."""
|
|
194
|
+
known = {t.id for t in self.tasks}
|
|
195
|
+
children: dict[int | None, list[Task]] = {}
|
|
196
|
+
for t in self.tasks:
|
|
197
|
+
parents = [p for p in t.parent_ids if p in known] or [None]
|
|
198
|
+
for p in parents:
|
|
199
|
+
children.setdefault(p, []).append(t)
|
|
200
|
+
return children
|
|
201
|
+
|
|
202
|
+
def to_dict(self) -> dict[str, Any]:
|
|
203
|
+
return dataclasses.asdict(self)
|