sgrud 0.1.0__tar.gz
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-0.1.0/.gitignore +10 -0
- sgrud-0.1.0/.python-version +1 -0
- sgrud-0.1.0/LICENSE +20 -0
- sgrud-0.1.0/PKG-INFO +122 -0
- sgrud-0.1.0/README.md +109 -0
- sgrud-0.1.0/docs/decisions.md +164 -0
- sgrud-0.1.0/pyproject.toml +35 -0
- sgrud-0.1.0/sgrud/__init__.py +45 -0
- sgrud-0.1.0/sgrud/cli.py +198 -0
- sgrud-0.1.0/sgrud/errors.py +42 -0
- sgrud-0.1.0/sgrud/format.py +168 -0
- sgrud-0.1.0/sgrud/models.py +203 -0
- sgrud-0.1.0/sgrud/monitor.py +324 -0
- sgrud-0.1.0/sgrud/procfs.py +160 -0
- sgrud-0.1.0/sgrud/profile.py +167 -0
- sgrud-0.1.0/sgrud/remote.py +328 -0
- sgrud-0.1.0/sgrud/sampler.py +88 -0
- sgrud-0.1.0/sgrud/tui.py +551 -0
- sgrud-0.1.0/tests/conftest.py +43 -0
- sgrud-0.1.0/tests/target_app.py +48 -0
- sgrud-0.1.0/tests/test_monitor.py +210 -0
- sgrud-0.1.0/tests/test_profile.py +102 -0
- sgrud-0.1.0/tests/test_tui.py +132 -0
- sgrud-0.1.0/tests/test_units.py +117 -0
- sgrud-0.1.0/uv.lock +194 -0
sgrud-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.15b1
|
sgrud-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 An Long
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
7
|
+
the Software without restriction, including without limitation the rights to
|
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
10
|
+
subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
sgrud-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sgrud
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Attach to a running CPython process and inspect memory, CPU, threads, asyncio tasks, stacks and GC with near-zero overhead
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
10
|
+
Requires-Python: >=3.15
|
|
11
|
+
Requires-Dist: textual>=8.0
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# sgrud
|
|
15
|
+
|
|
16
|
+
Attach to a running CPython process and watch its memory, CPU, threads,
|
|
17
|
+
asyncio tasks, stacks and garbage collector without slowing it down.
|
|
18
|
+
|
|
19
|
+
sgrud never stops or instruments the target. It reads the interpreter's
|
|
20
|
+
state straight out of process memory through CPython 3.15's
|
|
21
|
+
`_remote_debugging` module (the same machinery behind the Tachyon sampling
|
|
22
|
+
profiler and `python -m asyncio ps`) and pairs it with `/proc` for memory
|
|
23
|
+
and CPU accounting. A full snapshot of stacks for every thread costs tens
|
|
24
|
+
of microseconds on the sgrud side and nothing on the target side.
|
|
25
|
+
|
|
26
|
+
Requires Linux and CPython 3.15 or newer. The target must run the same
|
|
27
|
+
major.minor version as sgrud itself.
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
sgrud PID one text snapshot
|
|
33
|
+
sgrud PID -n 0.5 keep printing every 0.5 s until the target exits
|
|
34
|
+
sgrud PID --json one JSON object per line, easy to pipe elsewhere
|
|
35
|
+
sgrud run -- python app.py start the target as a child and inspect it
|
|
36
|
+
sgrud tui PID interactive terminal interface
|
|
37
|
+
sgrud tui run -- python app.py
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`--no-stacks`, `--no-tasks` and `--no-gc` drop sections you do not need.
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
sgrud PID --profile 5 sample stacks for 5 s, print the hottest functions
|
|
44
|
+
sgrud PID --profile 5 --mode gil count only the thread holding the GIL
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Keys inside the TUI: `1`-`5` switch tabs, `space` pauses, `r` refreshes,
|
|
48
|
+
`+` and `-` change the refresh interval, `q` quits. On the Hotspots tab
|
|
49
|
+
`s` toggles self/total ordering, `m` toggles wall/gil mode and `c` clears
|
|
50
|
+
the samples. The tab is fed by a background sampler (`--rate`, default
|
|
51
|
+
100 Hz) that keeps running while you look at the other tabs.
|
|
52
|
+
|
|
53
|
+
In wall mode every thread with a Python stack counts, so a sleeping thread
|
|
54
|
+
weighs as much as a busy one. In gil mode only the GIL holder counts, which
|
|
55
|
+
answers "where does the CPU time go" for CPython code.
|
|
56
|
+
|
|
57
|
+
## Library
|
|
58
|
+
|
|
59
|
+
The TUI is only a front end. Everything comes from `Monitor`, which
|
|
60
|
+
returns plain frozen dataclasses:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from sgrud import Monitor
|
|
64
|
+
|
|
65
|
+
with Monitor.attach(pid) as m: # or Monitor.spawn(["python", "app.py"])
|
|
66
|
+
snap = m.snapshot() # snapshot(stacks=..., tasks=..., gc=...)
|
|
67
|
+
print(snap.process.memory.rss, snap.process.cpu_percent)
|
|
68
|
+
for t in snap.threads:
|
|
69
|
+
print(t.tid, t.name, t.status.describe(), t.cpu_percent, t.frames[:1])
|
|
70
|
+
for task in snap.tasks:
|
|
71
|
+
print(task.name, task.parent_ids, [f.funcname for f in task.frames])
|
|
72
|
+
print(snap.gc[0].collections, snap.gc[0].history[:1])
|
|
73
|
+
print(snap.to_dict()) # JSON friendly
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`Monitor.stream(interval)` yields snapshots until the target exits, at which
|
|
77
|
+
point it raises `ProcessExited`. CPU percentages need two snapshots, so the
|
|
78
|
+
first one reports `None`.
|
|
79
|
+
|
|
80
|
+
For profiling, `Sampler` runs `Monitor.sample_stacks()` in a background
|
|
81
|
+
thread and feeds a `Hotspots` aggregator:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from sgrud.sampler import Sampler
|
|
85
|
+
|
|
86
|
+
with Sampler(monitor, rate=500, mode="gil") as sampler:
|
|
87
|
+
time.sleep(5)
|
|
88
|
+
for row in sampler.hotspots.rows(sort="self", limit=10):
|
|
89
|
+
print(row.self_percent, row.funcname, row.filename)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Permissions
|
|
93
|
+
|
|
94
|
+
Memory, CPU, thread names and per-thread CPU come from `/proc` and work
|
|
95
|
+
for any process you own. Stacks, GIL state, asyncio tasks, GC statistics
|
|
96
|
+
and hotspots need to read the target's memory, which needs ptrace rights.
|
|
97
|
+
With the default `kernel.yama.ptrace_scope=1` that is only granted for
|
|
98
|
+
child processes.
|
|
99
|
+
|
|
100
|
+
Without those rights sgrud still attaches in limited mode and shows what it
|
|
101
|
+
can, with a banner explaining what is missing. To get everything, either
|
|
102
|
+
start the target through `sgrud run -- ...`, run sgrud with `sudo`, grant
|
|
103
|
+
`CAP_SYS_PTRACE`, or relax Yama for the session:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Pass `require_full=True` to `Monitor.attach` to fail instead of degrading.
|
|
110
|
+
|
|
111
|
+
A target started with `-X disable-remote-debug` can still be inspected.
|
|
112
|
+
That flag only disables code injection, which sgrud does not use.
|
|
113
|
+
|
|
114
|
+
## Development
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
uv sync
|
|
118
|
+
uv run pytest
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The tests spawn `tests/target_app.py` and inspect it, so they exercise the
|
|
122
|
+
real attach path. The Textual app is tested headlessly through its pilot.
|
sgrud-0.1.0/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# sgrud
|
|
2
|
+
|
|
3
|
+
Attach to a running CPython process and watch its memory, CPU, threads,
|
|
4
|
+
asyncio tasks, stacks and garbage collector without slowing it down.
|
|
5
|
+
|
|
6
|
+
sgrud never stops or instruments the target. It reads the interpreter's
|
|
7
|
+
state straight out of process memory through CPython 3.15's
|
|
8
|
+
`_remote_debugging` module (the same machinery behind the Tachyon sampling
|
|
9
|
+
profiler and `python -m asyncio ps`) and pairs it with `/proc` for memory
|
|
10
|
+
and CPU accounting. A full snapshot of stacks for every thread costs tens
|
|
11
|
+
of microseconds on the sgrud side and nothing on the target side.
|
|
12
|
+
|
|
13
|
+
Requires Linux and CPython 3.15 or newer. The target must run the same
|
|
14
|
+
major.minor version as sgrud itself.
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
sgrud PID one text snapshot
|
|
20
|
+
sgrud PID -n 0.5 keep printing every 0.5 s until the target exits
|
|
21
|
+
sgrud PID --json one JSON object per line, easy to pipe elsewhere
|
|
22
|
+
sgrud run -- python app.py start the target as a child and inspect it
|
|
23
|
+
sgrud tui PID interactive terminal interface
|
|
24
|
+
sgrud tui run -- python app.py
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`--no-stacks`, `--no-tasks` and `--no-gc` drop sections you do not need.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
sgrud PID --profile 5 sample stacks for 5 s, print the hottest functions
|
|
31
|
+
sgrud PID --profile 5 --mode gil count only the thread holding the GIL
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Keys inside the TUI: `1`-`5` switch tabs, `space` pauses, `r` refreshes,
|
|
35
|
+
`+` and `-` change the refresh interval, `q` quits. On the Hotspots tab
|
|
36
|
+
`s` toggles self/total ordering, `m` toggles wall/gil mode and `c` clears
|
|
37
|
+
the samples. The tab is fed by a background sampler (`--rate`, default
|
|
38
|
+
100 Hz) that keeps running while you look at the other tabs.
|
|
39
|
+
|
|
40
|
+
In wall mode every thread with a Python stack counts, so a sleeping thread
|
|
41
|
+
weighs as much as a busy one. In gil mode only the GIL holder counts, which
|
|
42
|
+
answers "where does the CPU time go" for CPython code.
|
|
43
|
+
|
|
44
|
+
## Library
|
|
45
|
+
|
|
46
|
+
The TUI is only a front end. Everything comes from `Monitor`, which
|
|
47
|
+
returns plain frozen dataclasses:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from sgrud import Monitor
|
|
51
|
+
|
|
52
|
+
with Monitor.attach(pid) as m: # or Monitor.spawn(["python", "app.py"])
|
|
53
|
+
snap = m.snapshot() # snapshot(stacks=..., tasks=..., gc=...)
|
|
54
|
+
print(snap.process.memory.rss, snap.process.cpu_percent)
|
|
55
|
+
for t in snap.threads:
|
|
56
|
+
print(t.tid, t.name, t.status.describe(), t.cpu_percent, t.frames[:1])
|
|
57
|
+
for task in snap.tasks:
|
|
58
|
+
print(task.name, task.parent_ids, [f.funcname for f in task.frames])
|
|
59
|
+
print(snap.gc[0].collections, snap.gc[0].history[:1])
|
|
60
|
+
print(snap.to_dict()) # JSON friendly
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`Monitor.stream(interval)` yields snapshots until the target exits, at which
|
|
64
|
+
point it raises `ProcessExited`. CPU percentages need two snapshots, so the
|
|
65
|
+
first one reports `None`.
|
|
66
|
+
|
|
67
|
+
For profiling, `Sampler` runs `Monitor.sample_stacks()` in a background
|
|
68
|
+
thread and feeds a `Hotspots` aggregator:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from sgrud.sampler import Sampler
|
|
72
|
+
|
|
73
|
+
with Sampler(monitor, rate=500, mode="gil") as sampler:
|
|
74
|
+
time.sleep(5)
|
|
75
|
+
for row in sampler.hotspots.rows(sort="self", limit=10):
|
|
76
|
+
print(row.self_percent, row.funcname, row.filename)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Permissions
|
|
80
|
+
|
|
81
|
+
Memory, CPU, thread names and per-thread CPU come from `/proc` and work
|
|
82
|
+
for any process you own. Stacks, GIL state, asyncio tasks, GC statistics
|
|
83
|
+
and hotspots need to read the target's memory, which needs ptrace rights.
|
|
84
|
+
With the default `kernel.yama.ptrace_scope=1` that is only granted for
|
|
85
|
+
child processes.
|
|
86
|
+
|
|
87
|
+
Without those rights sgrud still attaches in limited mode and shows what it
|
|
88
|
+
can, with a banner explaining what is missing. To get everything, either
|
|
89
|
+
start the target through `sgrud run -- ...`, run sgrud with `sudo`, grant
|
|
90
|
+
`CAP_SYS_PTRACE`, or relax Yama for the session:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Pass `require_full=True` to `Monitor.attach` to fail instead of degrading.
|
|
97
|
+
|
|
98
|
+
A target started with `-X disable-remote-debug` can still be inspected.
|
|
99
|
+
That flag only disables code injection, which sgrud does not use.
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
uv sync
|
|
105
|
+
uv run pytest
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The tests spawn `tests/target_app.py` and inspect it, so they exercise the
|
|
109
|
+
real attach path. The Textual app is tested headlessly through its pilot.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# sgrud 设计决策与踩坑记录
|
|
2
|
+
|
|
3
|
+
给后续会话看的。记录 2026-09-06 从空项目到当前状态期间做出的决策、原因、被放弃的方案,以及实际踩过的坑。本文件故意不提交(已加入 `.git/info/exclude`),改动直接编辑即可。
|
|
4
|
+
|
|
5
|
+
## 0. 踩坑速查
|
|
6
|
+
|
|
7
|
+
动手前先看这一节,每条都是实际撞过的。
|
|
8
|
+
|
|
9
|
+
1. **`../cpython` 的 main 分支是 3.16a0,不是 3.15。** 查 3.15 的实现要用 `git show v3.15.0b1:<path>`,比如 `Modules/_remote_debugging/threads.c`。3.14 的对应文件是单文件 `Modules/_remote_debugging_module.c`。
|
|
10
|
+
2. **`_remote_debugging.is_python_process` 没权限时静默返回 False。** 它内部要读目标内存。先用 `procfs.can_read_memory` 判断权限,再调它,否则会把好好的解释器报成"不是 Python"。
|
|
11
|
+
3. **本机 WSL2 的 `kernel.yama.ptrace_scope=1`。** 只能读子进程的内存,非子进程要 sudo。C 层报的错误是误导性的 "Failed to find the PyRuntime section",真实原因是 `/proc/<pid>/mem` 读取被拒。`/proc` 下的 status、stat、comm、exe、maps 不受此限制。
|
|
12
|
+
4. **预发布版附加要求版本完全一致。** 3.15.0b1 的 sgrud 附加 3.14 进程直接报 "Can't attach from a pre-release Python interpreter"。
|
|
13
|
+
5. **WALL 模式下线程状态一律带 UNKNOWN 位。** 这不是错误,是 unwinder 跳过了 CPU 状态检查。`monitor.py` 已用 `/proc` 的调度状态补上 ON_CPU。
|
|
14
|
+
6. **`get_gc_stats` 返回的是环形缓冲,无序,含空槽。** 累计字段按 `collections` 排序恢复顺序,`collections == 0` 是空槽,`heap_size` 是瞬时值不能做差。
|
|
15
|
+
7. **`TaskInfo.awaited_by[i].task_name` 是父任务地址(int),不是名字。** 建树时要按这个 id 去 `task_id` 里找。
|
|
16
|
+
8. **`get_all_awaited_by` 高负载下会撕裂读抛错。** 已在 `remote.py` 重试 3 次。看到 tasks 突然为空先怀疑这个。
|
|
17
|
+
9. **目标刚启动时附加会报 "No interpreter state found"。** 瞬时错误,`attach` 已重试 1 秒,测试里也要给目标留启动时间。
|
|
18
|
+
10. **pytest 里用双重 fork 造孤儿进程时不能通过管道传 pid。** 孙进程继承的管道端会让 `communicate()` 永远等不到 EOF,整个测试挂死。孙进程三个标准流全部 DEVNULL,pid 走临时文件。
|
|
19
|
+
11. **`pkill -f` 的模式会匹配到当前 shell 自己。** 命令行里含 heredoc 或同样的字符串时会把自己杀掉(退出码 144)。用 `[t]arget_app` 这种写法或先 `pgrep` 再 `kill`。
|
|
20
|
+
12. **Textual 8 的 `Static` 没有 `renderable` 属性。** 测试断言不要碰 Textual 内部属性,`StackPanel` 为此暴露了 `last_title` / `last_frames` / `stale`。
|
|
21
|
+
13. **`DataTable.update_cell` 不会重算列宽。** 要传 `update_width=True`,否则内容变长后被截断。
|
|
22
|
+
14. **argparse 处理不了 `nargs="+"` 位置参数后面的 `--`。** `cli.main()` 手动在 `--` 处切分。
|
|
23
|
+
15. **`uv sync` 在包目录创建前跑过的话,editable 安装是坏的。** 报 `No module named 'sgrud'` 时 `uv sync --reinstall-package sgrud`。
|
|
24
|
+
16. **高 CPU 的孤儿进程和测试同时跑会让测试随机失败。** 验证完记得杀掉,用 `pgrep -f "[t]arget_app"` 检查残留。
|
|
25
|
+
17. **没有 SVG 转 PNG 的工具。** 看 TUI 效果用 `app.export_screenshot()` 导出 SVG,再用 scratchpad 里的小脚本把 `<text>` 元素按坐标拼回文本网格。
|
|
26
|
+
|
|
27
|
+
## 1. 版本与底层机制
|
|
28
|
+
|
|
29
|
+
**只支持 CPython 3.15 及以上,放弃 3.14。**
|
|
30
|
+
3.14 的 `_remote_debugging.RemoteUnwinder` 只有 `get_stack_trace`、`get_all_awaited_by`、`get_async_stack_trace` 三个方法,没有线程状态位、GC 统计、`<native>` / `<GC>` 合成帧、帧缓存和 `pause_threads`。用 3.14 只能做"看栈"和"看协程",线程是否持有 GIL、GC 情况都拿不到。用户明确允许依赖 3.15 特性后,把 `requires-python` 定为 `>=3.15`,用 uv 装了 3.15.0b1。
|
|
31
|
+
|
|
32
|
+
**主路径是被动读内存,不是代码注入。**
|
|
33
|
+
PEP 768 给了两条路:`RemoteUnwinder` 通过 `process_vm_readv` 读目标内存,目标完全不感知;`sys.remote_exec` 往目标主线程注入脚本,会占用目标执行时间且没有返回通道。工具的核心要求是"附加后没有明显性能损耗",所以所有功能都基于前者。实测全线程栈快照在 sgrud 侧只需几十微秒,目标侧为零。`sys.remote_exec` 留作将来可选的"重操作"(tracemalloc 之类),必须手动触发。
|
|
34
|
+
|
|
35
|
+
**`_remote_debugging` 是私有模块,只在一个文件里 import。**
|
|
36
|
+
`sgrud/remote.py` 是唯一接触它的地方,所有返回值都转成 `sgrud/models.py` 里的冻结 dataclass。模块加载时校验每个 structseq 类型的 `__match_args__` 包含我们依赖的字段,字段变动时抛 `NotSupported` 而不是运行到一半崩溃。3.15 还在 beta,这层隔离是为了将来跟随 API 变化时只改一处。
|
|
37
|
+
|
|
38
|
+
**`-X disable-remote-debug` 的目标仍然可以查看。**
|
|
39
|
+
实测该标志只禁用 `remote_exec` 注入,内存读取不受影响。文档里明确写了这一点,避免用户误以为需要重启目标。
|
|
40
|
+
|
|
41
|
+
## 2. 数据来源
|
|
42
|
+
|
|
43
|
+
**内存和 CPU 来自 `/proc`,不来自 `_remote_debugging`。**
|
|
44
|
+
后者不提供这些信息。进程内存读 `/proc/<pid>/status`,进程和每线程 CPU 时间读 `/proc/<pid>/task/<tid>/stat`,两次快照之间做差得到百分比。这也是 3.15 Tachyon 判断线程是否在 CPU 上的方式。
|
|
45
|
+
|
|
46
|
+
**线程名从 `/proc/<pid>/task/<tid>/comm` 读。**
|
|
47
|
+
3.14 起 `threading.Thread` 启动时会把名字写进 pthread name,所以 `comm` 里能直接看到 `busy`、`cpu-worker` 这类用户起的名字。`_remote_debugging` 本身不提供线程名。
|
|
48
|
+
|
|
49
|
+
**WALL 模式下自己推导 ON_CPU,清掉 UNKNOWN 位。**
|
|
50
|
+
读 C 源码确认:`RemoteUnwinder` 只在 CPU 或 ALL 模式才去读 `/proc` 判断线程是否在运行,WALL 模式一律标 UNKNOWN,因为那次读取"很贵"。而我们本来就为每线程 CPU 读了 stat,顺手用调度状态 `R` 补上 ON_CPU,把 UNKNOWN 去掉,界面上不再满屏问号。
|
|
51
|
+
|
|
52
|
+
**GC 环形缓冲按累计计数排序恢复时间顺序。**
|
|
53
|
+
`get_gc_stats` 返回的是每代一个环形缓冲(非 free-threaded 构建 gen0 有 11 槽,gen1/2 各 3 槽),但缓冲的 `index` 没有暴露。观察到 `collections`、`collected`、`candidates`、`duration` 都是累计值,`heap_size` 是瞬时值。所以:`collections == 0` 的是空槽;按 `collections` 排序得到时间顺序;相邻条目做差得到单次收集的增量;环已经绕过一圈导致前驱丢失时增量标为 -1 或 NaN,而不是猜。
|
|
54
|
+
|
|
55
|
+
**协程树用自己的模型,不复用 `asyncio.tools`。**
|
|
56
|
+
`asyncio.tools` 是 3.14 加的命令行辅助,函数带下划线、面向打印。`TaskInfo.awaited_by` 里每个 `CoroInfo` 的 `task_name` 字段实际上是父任务的地址,`call_stack` 是父协程当时的帧。据此建 `Task.awaited_by: tuple[Awaiter]`,`Snapshot.task_children()` 负责把父子关系转成树,父任务不在快照中的当作根,循环 await 单独标出。
|
|
57
|
+
|
|
58
|
+
**读任务图时重试 3 次。**
|
|
59
|
+
遍历任务图需要多次内存读取,目标在这期间还在跑,撕裂读会让整次调用抛错。验证降级模式时孤儿进程和测试目标同时高负载跑,就出现了一次任务列表为空导致三个测试联动失败。CPython 自带的 `python -m asyncio ps` 同样重试 3 次,这里对齐。
|
|
60
|
+
|
|
61
|
+
## 3. 附加与权限
|
|
62
|
+
|
|
63
|
+
**先查 `/proc/<pid>/mem` 能否读,再调 `is_python_process`。**
|
|
64
|
+
用户用非子进程 pid 试了一次,报"不是 CPython 解释器",明显误判。读 C 实现发现 `is_python_process` 内部也要读目标内存,权限不够时静默返回 False。所以顺序改为:先 `can_read_memory`,权限问题优先报权限提示。
|
|
65
|
+
|
|
66
|
+
**没权限时降级而不是失败。**
|
|
67
|
+
用户问"必须 sudo 吗"。分析后发现 Yama `ptrace_scope=1` 只挡 `PTRACE_MODE_ATTACH`(`/proc/<pid>/mem`、`process_vm_readv`),不挡 `/proc/<pid>/status`、`stat`、`comm`、`exe`、`maps` 这些 `PTRACE_MODE_READ` 级别的读取。于是 `Monitor.attach` 在读不了内存时进入 `limited` 模式:内存、CPU、线程名、每线程 CPU 照常,栈、GIL 状态、协程、GC、热点缺失,原因放在 `monitor.limited` 和 `snapshot.errors["attach"]`。TUI 显示横幅并不启动采样器,CLI 在 stderr 提示一行,`--profile` 直接拒绝。严格失败用 `require_full=True`。
|
|
68
|
+
|
|
69
|
+
**降级模式下用启发式判断是否 Python。**
|
|
70
|
+
`is_python_process` 不可用时,看 `exe` 的文件名是否以 `python` 开头,或 `maps` 里是否映射了 `libpython`。这样 `sleep` 之类的进程仍会被拒绝,嵌入式解释器也能识别。
|
|
71
|
+
|
|
72
|
+
**`Monitor.spawn` 作为绕过 ptrace 限制的正规途径。**
|
|
73
|
+
子进程总是可以被父进程读取,`sgrud run -- python app.py` 就是给不想 sudo 的场景准备的。
|
|
74
|
+
|
|
75
|
+
**附加时对瞬时错误重试 1 秒。**
|
|
76
|
+
目标刚启动时解释器状态未就绪会报 "No interpreter state found"。把错误分成 transient 和非 transient(权限、版本不匹配),前者在 `attach` 里重试最多 1 秒。
|
|
77
|
+
|
|
78
|
+
**错误信息带修复建议。**
|
|
79
|
+
`AttachError.hint` 区分三种情况:版本不匹配(预发布版必须完全一致)、权限(附带当前 `ptrace_scope` 值和三种解法)、目标不是 Python。用户看到的是可操作的提示,而不是 C 层的 "Failed to find the PyRuntime section"。
|
|
80
|
+
|
|
81
|
+
## 4. 架构分层
|
|
82
|
+
|
|
83
|
+
**库层完全不依赖 TUI,先做库再做界面。**
|
|
84
|
+
用户要求可测试、可直接读取、将来能接 TUI。分层为 `models`(冻结 dataclass)、`procfs`、`remote`、`monitor`(组装快照)、`format`(纯文本渲染)、`cli`、`tui`。`Snapshot.to_dict()` 直接可 JSON 化。TUI 只消费 `Snapshot`,所以测试可以用伪造的快照驱动界面。
|
|
85
|
+
|
|
86
|
+
**快照的三个部分可单独关闭。**
|
|
87
|
+
`snapshot(stacks=, tasks=, gc=)` 让调用方控制成本。协程和 GC 比栈贵一个数量级,高频采样时只要栈。
|
|
88
|
+
|
|
89
|
+
**`Monitor` 内部加锁,`sample_stacks()` 作为快速路径。**
|
|
90
|
+
`RemoteUnwinder` 带帧缓存,不是线程安全的。后台采样线程和 UI 刷新共用一个 `Monitor` 时,用一把锁串行化对 unwinder 的访问。快照本身只要几百微秒,争用可以忽略。
|
|
91
|
+
|
|
92
|
+
**每个 CPU 百分比在第一次快照时是 `None`。**
|
|
93
|
+
需要两次读数做差。CLI 单次 dump 会先取一次不带栈的快照,等 100 毫秒再取正式的一次,这样输出里的 CPU 是有意义的。
|
|
94
|
+
|
|
95
|
+
## 5. TUI 选型与行为
|
|
96
|
+
|
|
97
|
+
**选 Textual,不选 blessed、urwid、curses、prompt_toolkit。**
|
|
98
|
+
Textual 有 DataTable、Tree、TabbedContent、Sparkline、Select 这些现成控件,正好对应线程表、协程树、页签、曲线和线程过滤;原生跑在 asyncio 上,刷新用 `set_interval` 即可;有无头 pilot 可测。blessed 和 curses 要自己写所有控件,urwid 老旧且 asyncio 集成弱,prompt_toolkit 偏输入场景。Tachyon 的 live 模式用 curses 写,只是热点表,可作参考但不是我们要的形态。Textual 8.2.8 在 3.15.0b1 上实测可用。
|
|
99
|
+
|
|
100
|
+
**目标退出后冻结在最后一帧,不清空。**
|
|
101
|
+
用户指出"没有检测进程退出"。处理是:停掉刷新定时器和采样器,摘要显示红色 EXITED(子进程模式带退出码),栈面板标为 stale 并保留最后看到的栈,空格暂停失效。目的是让用户还能看到崩溃前的状态。
|
|
102
|
+
|
|
103
|
+
**选中对象消失时标 stale,不静默清空。**
|
|
104
|
+
线程结束或任务完成后,右侧面板变红并注明 "thread gone" / "task finished",仍显示最后一次的栈。
|
|
105
|
+
|
|
106
|
+
**栈面板改成两列不折行。**
|
|
107
|
+
第一版用单行 "函数 路径:行号" 文本,长路径折行后很难读。改成 Rich `Table.grid` 两列,超宽省略号截断,函数列有最小宽度。
|
|
108
|
+
|
|
109
|
+
**`DataTable.update_cell` 必须传 `update_width=True`。**
|
|
110
|
+
否则列宽固定为首次插入时的内容宽度,线程状态从 `idle` 变成 `gil,cpu` 后会被截断。
|
|
111
|
+
|
|
112
|
+
**线程表按 tid 做行键原地更新,任务树整体重建。**
|
|
113
|
+
线程数量少、变化慢,原地更新能保住光标。任务树用 Textual 的 `Tree.clear()` 重建后恢复 `cursor_line`,代价小于维护增量 diff。
|
|
114
|
+
|
|
115
|
+
## 6. 热点分析
|
|
116
|
+
|
|
117
|
+
**采样器是普通线程,不依赖 Textual。**
|
|
118
|
+
`Sampler` 用 `threading.Thread` 按固定周期调 `Monitor.sample_stacks()` 喂 `Hotspots`,这样 CLI `--profile` 和库用户都能用,TUI 只是其中一个消费者。落后时不追赶(不做突发补采)。
|
|
119
|
+
|
|
120
|
+
**累积计数加手动清空,不做滑动窗口。**
|
|
121
|
+
滑动窗口需要保存每个样本或做衰减,复杂且内存随频率增长。先做"累积 + `c` 清空",够用。
|
|
122
|
+
|
|
123
|
+
**递归函数每次采样 total 只计一次。**
|
|
124
|
+
否则 total 百分比会超过 100%。用 per-sample 的 `seen` 集合去重。
|
|
125
|
+
|
|
126
|
+
**wall 和 gil 两种模式,默认 wall。**
|
|
127
|
+
wall 统计所有有 Python 栈的线程,睡眠线程和忙线程权重相同,看"线程在等什么"合适;gil 只统计持有 GIL 的线程,回答"CPU 时间花在哪",对 CPython 代码最有用。状态位随采样结果一起返回,过滤零成本。没做 CPU 模式,因为它需要每次采样都读 `/proc/<pid>/task/*/stat`,会显著拖慢采样频率。默认 wall 与 Tachyon 一致,切换模式时清空样本,因为两种模式的数据不可比。
|
|
128
|
+
|
|
129
|
+
**`<native>` 作为独立条目保留。**
|
|
130
|
+
它在 total 里会显示 100%(线程最底层就是 native 启动帧),看起来奇怪但是正确的。没有把 native 时间归到调用它的 Python 函数上,那会改变语义。
|
|
131
|
+
|
|
132
|
+
## 7. 测试
|
|
133
|
+
|
|
134
|
+
**测试针对真实进程,不 mock `_remote_debugging`。**
|
|
135
|
+
`tests/target_app.py` 带两个线程和一棵 asyncio 任务树,打印 READY 后再附加。模块级 fixture 共享一个目标,减少启动开销。`--exit-after` 参数用于退出检测测试。
|
|
136
|
+
|
|
137
|
+
**TUI 用 Textual 的 headless pilot 测。**
|
|
138
|
+
不启动真实终端,通过 `run_test` 按键、等待、查询控件状态。`StackPanel` 暴露 `last_title`、`last_frames`、`stale` 属性给测试用,避免断言依赖 Textual 内部属性(`renderable` 在 Textual 8 里已经没有了)。
|
|
139
|
+
|
|
140
|
+
**非子进程的权限测试用双重 fork 加 pid 文件。**
|
|
141
|
+
Yama 的判断依据是"是否是祖先进程",所以启动器 fork 一个孙进程后自己退出。第一版让启动器通过 stdout 管道传 pid,在 pytest 下会挂住(孙进程继承的管道端没关,`communicate()` 等不到 EOF)。改成孙进程 stdin/stdout/stderr 全部指向 DEVNULL,pid 通过临时文件传递。
|
|
142
|
+
|
|
143
|
+
**性能有回归测试。**
|
|
144
|
+
`test_stack_sampling_is_fast` 断言 200 次带栈快照的平均耗时低于 5 毫秒,防止后续改动把主路径拖慢。
|
|
145
|
+
|
|
146
|
+
## 8. 工程约定
|
|
147
|
+
|
|
148
|
+
**CLI 手动切分 `--`。**
|
|
149
|
+
argparse 处理 `nargs="+"` 位置参数加 `--` 时会把后面的东西当成多余参数。`main()` 先在 `--` 处切开,后半段作为要执行的命令原样传给 `Monitor.spawn`。
|
|
150
|
+
|
|
151
|
+
**pytest-asyncio 作为 dev 依赖,textual 暂时是硬依赖。**
|
|
152
|
+
Textual 的 pilot 测试是 async 的。textual 将来可以改成 extra,让只用库层的人不用装它,目前没做。
|
|
153
|
+
|
|
154
|
+
**分支叫 main,提交用 gitmoji 加短标题,不写正文。**
|
|
155
|
+
`uv init` 生成的默认分支是 master,第一次提交前改名。提交格式是用户明确要求的,三个历史提交重写过一次。
|
|
156
|
+
|
|
157
|
+
## 9. 已知取舍与待办
|
|
158
|
+
|
|
159
|
+
- 降级模式在附加时判断一次,运行中放开 `ptrace_scope` 不会自动升级为完整模式。
|
|
160
|
+
- `looks_like_python` 是启发式,可能漏掉改过名字的嵌入式解释器。
|
|
161
|
+
- 热点表没有按行号区分,同一函数内的不同热点行合并在一起。
|
|
162
|
+
- 只支持 Linux,`/proc` 相关代码在其他平台直接抛 `NotSupported`。
|
|
163
|
+
- `_remote_debugging` 在 3.15 正式版前仍可能变化,字段校验只能提前报错,不能自动适配。
|
|
164
|
+
- 未做:子进程选择器、`sys.remote_exec` 重操作、JSONL 导出、textual 改为可选依赖。
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sgrud"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Attach to a running CPython process and inspect memory, CPU, threads, asyncio tasks, stacks and GC with near-zero overhead"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.15"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"License :: OSI Approved :: MIT License",
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"Programming Language :: Python :: 3.15",
|
|
13
|
+
]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"textual>=8.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
sgrud = "sgrud.cli:main"
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest>=8",
|
|
24
|
+
"pytest-asyncio>=1.4.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["hatchling"]
|
|
29
|
+
build-backend = "hatchling.build"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["sgrud"]
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
testpaths = ["tests"]
|
|
@@ -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
|
+
]
|