sigwire
tail -f用于信号。 系统上每个进程引发的每个信号——谁发送的、谁接收的、哪个信号、如何引发的(kill(2)、内核、POSIX 定时器)、目标是否捕获了它以及其处理程序运行了多长时间、它是否用EINTR撕开了阻塞的系统调用——从内核的信号跟踪点解码并实时流式传输到您的终端。无需对单一 pid 使用strace -f,无需ptrace,无需被涉及进程的配合。
sigwire 将内核的信号机制转变为实时配线架:每一行是 sender ──SIGNAL──▶ target,按严重性着色,标记引发方式,目标是否捕获了它(以及其处理程序运行了多长时间),是否中断了阻塞的系统调用**(↯ EINTR read),当某个信号大量出现时折叠为 ×N,并且当它是真正的致命一击时标记 ☠。** 侧边栏统计着传输中的信号;暂停并选择一行以查看完整画面——信号处置、处理程序地址、sigaction 标志以及目标当时正在阻塞的信号。
因为它挂钩内核的跟踪点,而不是任何单一进程,一次运行即可同时监控主机上的每个信号——您的应用程序、监督程序、内核自身的故障机制——而它们都不会意识到自己正在被跟踪。
[!TIP] 每个信号的两个方面。 sigwire 同时监控
signal:signal_generate(发送者的视角——谁引发了什么,配线架线路)和signal:signal_deliver(目标的视角——是否捕获了它,使用哪个处理程序和标志,当时阻塞了什么,以及是否中断了系统调用)。另外两个钩子——rt_sigreturn(2)和系统调用退出跟踪点——对处理程序进行计时并捕获 EINTR。所有这些都被关联回同一行。这种分离也是为什么☠ fatal计数故意保守的原因(参见什么算作致命):信号的产生发生在传递之前,因此发送方无法知道信号的命运——只有传递方可以,而且仅限于它观察到的情况。
curl -fsSL https://yeet.cx | sh # install the yeet daemon (one time) yeet run github:yeet-src/sigwire # run the dashboard (the daemon does the privileged BPF load)
[手动安装指南](https://yeet.cx/docs/manual-installation) | 仅限 Linux
无需配置 — 信号在任何机器上都是持续的背景流量,因此行会立即从顶部开始出现。想自己生成一些?`kill -USR1 <pid>`、对前台作业按 `Ctrl-C`,或者启动任何托管运行时并观察其 GC/调度器 ping 自己的线程(`↯ EINTR futex` 滚动而过)。
## 控制
信号流默认跟随最新信号;选择一行或暂停后,它会保持静止,同时数据在下方继续流动。
| 键 | 操作 |
| --- | ------ |
| `p` · `Space` | 暂停/恢复信号流(冻结以阅读) |
| `↑`/`↓`, `k`/`j` | 暂停并检查一行 — 打开详情面板 |
| `/` | 模糊过滤 — 匹配进程、PID、信号、来源和处置方式;匹配字符实时高亮 |
| `e` | 过滤至**仅中断的系统调用**(`↯ EINTR` / `↺ restarted`) |
| `s` | 打开**信号选择器** — 实时静音或显示任何信号 |
| `Esc` | 退出一层 — 清除过滤/关闭选择器/取消选择,然后退出 |
| `q` | 退出 |
## 您正在查看的内容
每一行是一个生成的信号,最新的在最顶部:```
WHEN SENDER SIGNAL TARGET NOTE
now bash·4402──SIGINT───▶ node·8813 kill(2) ↯ EINTR read caught 41µs
1.2s systemd·1──────SIGTERM──▶ nginx·1291 kill(2) caught 1.2ms
3.4s kernel·8813──SIGSEGV──▶ chrome·8813 fault default ☠
4.1s postgres·507──SIGUSR1───▶ postgres·509 ×6 kill(2) caught 9µs
每一行都是一个块:发送方 → 目标 为 comm·pid(发送方是发起信号的一方,current;目标是信号指向的进程),中间的导线承载着根据严重性着色的信号名称,×N 将相同信号的一次突发合并为一行,右侧的注释给出来源,然后是任何系统调用中断,最后是处置方式。
每一行在交付解析完成时冻结,此后永远不会再改变——因此一次突发会作为一条稳定的日志滚动显示,而不是闪烁的聚合。
导线根据严重性着色,采用与 UI 其余部分相同的 256 色调色板:
| 严重性 | 信号 | 颜色 |
|---|---|---|
| 致命 | SIGKILL | 热红 |
| 致命(生成 core 文件) | SEGV BUS ABRT ILL FPE TRAP SYS QUIT | 红色 |
| 终止 | TERM INT HUP PIPE ALRM … | 琥珀色 |
| 作业控制 | STOP TSTP TTIN TTOU | 黄色 |
| 继续 | CONT | 绿色 |
| 用户 | USR1 USR2 | 青色 |
| 实时 | SIGRTMIN+n | 紫色 |
| 内务 | CHLD URG WINCH … | 灰色 |
注释 是来源(kill(2)、tgkill、sigqueue、timer、kernel、fault);然后,如果它中断了阻塞的系统调用,会显示 ↯ EINTR read(或 ↺ restarted read,当 SA_RESTART 自动恢复它时);最后是处置方式——caught 41µs(处理程序运行了,以及耗时多久)、default(没有处理程序,采用了默认行为)或 ⊘ ignored。☠ 标记真正的致命一击(参见什么算致命)。
[!NOTE]
↯ EINTR是需要关注的重点。 当一个信号到达时,线程正阻塞在慢速系统调用(read、poll、accept、futex、nanosleep……)中,该线程会被强制唤醒:系统调用返回-1/EINTR,并且除非处理程序设置了SA_RESTART,否则它不会恢复——应用程序必须重试。忘记这一点是一个经典的、令人抓狂的、与时机相关的 bug(“为什么我的read()失败了一次?”)。sigwire 实时显示这种情况的发生,以及是哪个系统调用受到了影响。按e键隐藏所有其他内容,只观察这些中断。
右侧的轨道是聚合视图:按数量排列的顶部信号、按来源的分类,以及交付统计——捕获了多少信号、默认处理了多少、忽略了多少。
按 ↑/↓(或 p)冻结流并选择一行;轨道变为一个详细信息面板,显示关于该确切信号的交付侧所知的一切:```
SIGNAL
SIGUSR1 (10) user
from ctarget·3980913
to ctarget·3980913
RAISED
via tgkill
code SI_TKILL
scope thread
result delivered
DELIVERY
handled caught
syscall EINTR ← read
handler 0x55f0a1c3
ran 3.0ms
flags SA_SIGINFO
TARGET BLOCKS
SIGINT SIGQUIT SIGTERM
- **handled** — `caught`(运行了用户空间处理程序)、`default`(→ 默认动作:终止/核心转储/停止/忽略)或 `ignored`。
- **syscall** — 如果该信号中断了一个阻塞的系统调用:`EINTR ← read`(用户空间看到 `EINTR`)或 `restarted read`(`SA_RESTART` 透明地恢复它)。
- **ran** — 处理程序执行的时间,从递送到结束它的 `rt_sigreturn(2)` 计算。(只会在 C 处理程序中设置标志并在以后执行实际工作的运行时——CPython、Go——在这里显示一个很小的时间;那是它们的问题,不是 sigwire 的。)
- **flags** — 处理程序上的 `sigaction` 标志(`SA_RESTART`、`SA_SIGINFO`、`SA_NODEFER`……)。
- **TARGET BLOCKS** — 目标在递送时刻已阻塞的信号(其 `sigprocmask`),直接来自其 `task_struct`。
`Esc` 关闭检查器;`p` 恢复实时显示。
## 什么算作致命
`☠ fatal` 计数器和 `☠` 行徽章故意严格。因为 `signal_generate` 在*产生*时触发,sigwire 无法看到目标是否安装了处理程序——`SIGTERM` 可能被捕获并转换为干净关闭,或者完全被忽略。因此,只有当死亡是明确无误时,它才会计数:
- **`SIGKILL` 递送**——不可捕获、不可忽略、始终致命;**或**
- **内核自身引发的**(同步故障,而非用户空间的 `kill`)一个**核心转储信号**(`SEGV`/`BUS`/`ABRT`/`ILL`/`FPE`/`TRAP`/`SYS`/`QUIT`)。
其他所有信号——来自 `systemd` 的 `SIGTERM`、来自你的 `Ctrl-C` 的 `SIGINT`、运行时发给自己线程的 `SIGPWR`——都会被显示和着色,但不计为死亡,因为很可能不是。
## 信号选择器(一个实时的内核旋钮)
在任何繁忙的机器上,三个信号都是纯背景噪音:`SIGCHLD`(每次子进程回收)、`SIGURG`(Go 的异步抢占心跳)和 `SIGWINCH`(终端大小变化,广播给所有前台进程)。sigwire 默认在**内核内**静音这三个信号,以便显示感兴趣流量——但哪些信号是噪音取决于你。
按下 `s` 打开**信号选择器**:一个模态列表,包含每个信号及其实时严重性颜色和你已经看到的数量,每个信号可在 `shown`(显示)和 `muted`(静音)之间切换。用箭头移动到某个信号(或**输入其数字**——`1`、`5` → 跳到 15),然后按 `space`,该信号立即切换。`a` 一次性切换**所有**信号。标题栏中的 `muted` 计数跟踪当前隐藏的数量。
这是演示的双向部分:静音掩码是正在运行的 BPF 程序的 `.data` 部分中的一个 `__u64` 全局变量,切换一行会通过 `DataSec.patch()` 修补匹配的位,而程序持续运行。内核在静音信号到达环形缓冲区之前就丢弃它们,因此静音不会消耗任何成本——而且取消静音会在流式过程中恢复信号,无需重新加载。
## 工作原理
核心是 [`src/bpf/sigwire.bpf.c`](https://github.com/yeet-src/sigwire/blob/master/src/bpf/sigwire.bpf.c) + [`src/bpf/deliver.bpf.c`](https://github.com/yeet-src/sigwire/blob/master/src/bpf/deliver.bpf.c)(内核,链接成一个对象)和 [`src/probes/sigwire.js`](https://github.com/yeet-src/sigwire/blob/master/src/probes/sigwire.js)(用户空间)。所有内容通过 `(target tid, signal)` 关联。
### BPF 侧
两个源文件链接成一个可加载对象 `bin/probe.bpf.o`,包含四个 tracepoint 程序:
| 程序 | 附加到 | 捕获内容 |
|---|---|---|
| `on_signal_generate` | `signal:signal_generate` | 发送者(`current`)+ 目标(`comm`/`pid`)、信号、`si_code`、`group` 标志、`result`——如果该信号的位在实时 `mute_mask` 中设置,则在内核中丢弃 |
| `on_signal_deliver` | `signal:signal_deliver` | 目标的处置(`sa_handler`)、`sa_flags`,以及来自 `task_struct` 的 `blocked` 信号集;为处理程序计时标记递送 |
| (rt_sigreturn) | `syscalls:sys_enter_rt_sigreturn` | 与标记的递送进行差异比较,得到处理程序的运行时间 |
| (sys_exit) | `raw_syscalls:sys_exit` | 记录罕见的 `-ERESTART*` 返回值,以便下一个 `signal_deliver` 将其解析为 `EINTR`/`restarted` 以及被中断的系统调用号 |
映射连接内核和用户空间:
- `events` — `RINGBUF`,每次产生一个 `signal_event`。
- `dispatch` — `RINGBUF`,每次递送/处理程序返回一个 `dispatch_event`。
- `mute_mask` — `.data` 部分中的一个 `__u64` 全局变量;选择器通过修补单个位在内核中丢弃信号。
- `handler_start` / `restart_pending` — `HASH`,以 tid 为键,每个线程的临时空间,用于将一次递送与其 `rt_sigreturn` 配对,以及将系统调用的 `-ERESTART*` 退出与后续递送配对。
### JS 侧