
Outil d'observabilité en direct des signaux du noyau utilisant des points de trace eBPF pour diffuser en continu chaque signal levé sur un hôte Linux, montrant en temps réel l'expéditeur, la cible, la disposition, la latence du gestionnaire et les interruptions d'appels système.
sigwire
tail -fpour les signaux. Chaque signal que tout processus sur la machine lève — qui l'a envoyé, qui l'a reçu, quel signal, comment il a été levé (kill(2), le noyau, un timer POSIX), si la cible l'a intercepté et combien de temps son gestionnaire a tourné, s'il a arraché un appel système bloqué avecEINTR— décodé depuis les tracepoints de signaux du noyau et diffusé en direct sur votre terminal. Pas destrace -fsur un seul pid, pas deptrace, aucune coopération des processus concernés.
sigwire transforme les mécanismes de signaux du noyau en un panneau de brassage en direct : chaque ligne est sender ──SIGNAL──▶ target, colorée par sévérité, étiquetée avec comment il a été levé, si la cible l'a intercepté (et combien de temps son gestionnaire a tourné), si elle a interrompu un appel système bloqué (↯ EINTR read), réduit à ×N quand quelque chose spamme, et marqué ☠ quand c'est un coup fatal. Un rail latéral totalise ce qui circule sur le fil ; pausez et sélectionnez une ligne pour inspecter l'image complète — disposition, adresse du gestionnaire, drapeaux sigaction, et les signaux que la cible bloquait à cet instant.
Parce qu'il accroche les tracepoints du noyau, et non un seul processus, une seule exécution surveille tous les signaux sur la machine d'un coup — votre application, un superviseur, le propre mécanisme de défauts du noyau — sans qu'aucun d'eux sache qu'il est tracé.
[!TIP] Deux faces de chaque signal. sigwire surveille à la fois
signal:signal_generate(la vue de l'expéditeur — qui a levé quoi, la ligne du central) etsignal:signal_deliver(la vue de la cible — l'a-t-elle intercepté, avec quel gestionnaire et quels drapeaux, que bloquait-elle, et a-t-elle interrompu un appel système). Deux autres hooks —rt_sigreturn(2)et le tracepoint de sortie d'appel système — chronométrent le gestionnaire et attrapent EINTR. Tout est corrélé en une seule ligne. Cette division est aussi pourquoi le compteur☠ fatalest délibérément conservateur (voir Ce qui compte comme fatal) : la génération a lieu avant la livraison, donc le côté expéditeur ne peut pas connaître le sort d'un signal — seul le côté livraison le peut, et seulement pour les cas qu'il observe.
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)
[Guide d'installation manuelle](https://yeet.cx/docs/manual-installation) | Linux uniquement
Rien à configurer : les signaux sont un trafic de fond constant sur n'importe quelle machine, donc les lignes commencent à apparaître immédiatement en haut. Vous voulez en générer vous-même ? `kill -USR1 <pid>`, `Ctrl-C` une tâche en premier plan, ou démarrez un environnement d'exécution géré et observez son GC/ordonnanceur envoyer des signaux à ses propres threads (`↯ EINTR futx` qui défile).
## Contrôles
Le flux suit par défaut le signal le plus récent ; sélectionnez une ligne ou mettez en pause pour le figer pendant que les données continuent de s'accumuler en dessous.
| touche | action |
| ------ | ------ |
| `p` · `Espace` | mettre en pause / reprendre le flux (le figer pour lire) |
| `↑`/`↓`, `k`/`j` | mettre en pause et inspecter une ligne — ouvre le panneau de détails |
| `/` | filtre flou — correspond au processus, pid, signal, source et disposition ; les caractères correspondants sont mis en évidence en direct |
| `e` | filtrer pour **uniquement les appels système interrompus** (`↯ EINTR` / `↺ redémarré`) |
| `s` | ouvrir le **sélecteur de signaux** — masquer ou afficher n'importe quel signal, en direct |
| `Échap` | revenir d'un niveau — effacer le filtre / fermer le sélecteur / abandonner la sélection, puis quitter |
| `q` | quitter |
## Ce que vous regardez
Chaque ligne représente un signal généré, le plus récent en haut :```
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
Chaque ligne est un bloc : les expéditeur → cible sont comm·pid (l'expéditeur est celui qui a déclenché le signal, current ; la cible est celle à qui il est destiné), le fil au milieu porte le nom du signal coloré par sévérité, ×N regroupe une rafale du signal identique en une seule ligne, et la note à droite donne la source, puis toute interruption d'appel système, puis la disposition.
Chaque ligne est figée au moment où sa distribution se résout et ne mute plus jamais — ainsi une rafale défile comme un journal stable, non pas un agrégat clignotant.
Le fil est coloré par sévérité sur la même palette de 256 couleurs que le reste de l'interface :
| sévérité | signaux | couleur |
|---|---|---|
| tuer | SIGKILL | rouge vif |
| fatal (avec vidage mémoire) | SEGV BUS ABRT ILL FPE TRAP SYS QUIT | rouge |
| terminaison | TERM INT HUP PIPE ALRM … | ambre |
| contrôle de tâches | STOP TSTP TTIN TTOU | jaune |
| continuer | CONT | vert |
| utilisateur | USR1 USR2 | cyan |
| temps réel | SIGRTMIN+n | violet |
| ménage | CHLD URG WINCH … | gris |
La note est la source (kill(2), tgkill, sigqueue, timer, kernel, fault) ; ensuite, si elle a interrompu un appel système bloqué, ↯ EINTR read (ou ↺ restarted read lorsque SA_RESTART l'a automatiquement repris) ; puis la disposition — caught 41µs (un gestionnaire s'est exécuté et combien de temps cela a pris), default (pas de gestionnaire, l'action par défaut a été appliquée), ou ⊘ ignored. Un ☠ marque un coup fatal réel (voir Ce qui compte comme fatal).
[!NOTE]
↯ EINTRest celui à surveiller. Un signal qui arrive alors qu'un thread est bloqué dans un appel système lent (read,poll,accept,futex,nanosleep, …) l'en arrache : l'appel système renvoie-1/EINTRet, à moins que le gestionnaire n'ait définiSA_RESTART, il ne reprend pas — l'application doit réessayer. Oublier cela est un bug classique, exaspérant, dépendant du timing (« pourquoi monread()a-t-il échoué une fois ? »). sigwire le montre en temps réel, et quel appel système a subi le coup. Appuyez surepour masquer tout le reste et ne regarder que les interruptions.
Le rail à droite est la vue agrégée : meilleurs signaux par volume, une répartition par source, et un bilan de distribution — combien de signaux ont été captés vs. ont atteint leur défaut vs. ignorés.
Appuyez sur ↑/↓ (ou p) pour figer le flux et sélectionner une ligne ; le rail se transforme en panneau de détails avec tout ce que le côté distribution sait de ce signal exact :```
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` (a exécuté un gestionnaire en espace utilisateur), `default` (→ action par défaut : terminer / core dump / arrêter / ignorer) ou `ignored`.
- **syscall** — si ce signal a interrompu un appel système bloqué : `EINTR ← read` (l'espace utilisateur a vu `EINTR`) ou `restarted read` (`SA_RESTART` l'a repris de manière transparente).
- **ran** — durée d'exécution du gestionnaire, mesurée de la livraison jusqu'au `rt_sigreturn(2)` qui la termine. (Les durées d'exécution qui ne font que positionner un drapeau dans le gestionnaire C et effectuent le vrai travail plus tard — CPython, Go — affichent un temps très court ici ; c'est normal, pas sigwire.)
- **flags** — les drapeaux `sigaction` sur le gestionnaire (`SA_RESTART`, `SA_SIGINFO`, `SA_NODEFER`, …).
- **TARGET BLOCKS** — les signaux que la cible avait bloqués (son `sigprocmask`) au moment de la livraison, directement depuis son `task_struct`.
`Esc` ferme l'inspecteur ; `p` reprend le flux en direct.
## Ce qui est considéré comme fatal
Le compteur `☠ fatal` et le badge `☠` dans les lignes sont intentionnellement stricts. Parce que `signal_generate` se déclenche à la *génération*, sigwire ne peut pas savoir si la cible a installé un gestionnaire — un `SIGTERM` pourrait être intercepté et transformé en arrêt propre, ou totalement ignoré. Il ne compte donc une mort que lorsqu'elle est sans ambiguïté :
- **`SIGKILL`** délivré — non interceptable, non ignorable, toujours fatal ; **ou**
- un **signal générant un core dump** (`SEGV`/`BUS`/`ABRT`/`ILL`/`FPE`/`TRAP`/`SYS`/`QUIT`) que le **noyau lui-même a déclenché** (une faute synchrone, pas un `kill` utilisateur).
Tout le reste — un `SIGTERM` de `systemd`, un `SIGINT` de votre `Ctrl-C`, un `SIGPWR` d'une exécution vers ses propres threads — est affiché et coloré, mais pas compté comme une mort, car ce n'en était probablement pas une.
## Le sélecteur de signaux (un bouton du noyau en direct)
Trois signaux sont un pur bruit de fond sur toute machine occupée : `SIGCHLD` (chaque récupération de processus fils), `SIGURG` (battement de préemption asynchrone de Go) et `SIGWINCH` (redimensionnements de terminal, diffusés à tous les processus au premier plan). sigwire masque ces trois **dans le noyau** par défaut pour que le flux soit le trafic intéressant — mais quels signaux sont du bruit, c'est votre choix.
Appuyez sur `s` pour ouvrir le **sélecteur de signaux** : une liste modale de chaque signal avec sa couleur de sévérité en direct et combien vous en avez vus, chacun pouvant être basculé entre `shown` (affiché) et `muted` (masqué). Naviguez avec les flèches vers l'un d'eux (ou **tapez son numéro** — `1`, `5` → saute au 15) et appuyez sur `espace`, ce signal bascule instantanément. `a` bascule **tous** en une fois. Le compteur `muted` dans la barre de titre suit combien sont masqués.
C'est la moitié bidirectionnelle de la démo : le masque de mise en sourdine est un `__u64` global dans la section `.data` du programme BPF en cours d'exécution, et basculer une ligne corrige le bit correspondant via `DataSec.patch()` pendant que le programme continue de tourner. Le noyau supprime les signaux masqués avant qu'ils n'atteignent jamais le tampon circulaire, donc les masquer ne vous coûte rien — et les démasquer ramène un signal en plein flux sans rechargement.
## Comment ça fonctionne
Le cœur est [`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) (noyau, liés en un seul objet) et [`src/probes/sigwire.js`](https://github.com/yeet-src/sigwire/blob/master/src/probes/sigwire.js) (espace utilisateur). Tout est corrélé par `(tid cible, signal)`.
### Le côté BPF
Deux fichiers sources sont liés en un seul objet chargeable, `bin/probe.bpf.o`, avec quatre programmes de tracepoint :
| Programme | Attaché à | Ce qu'il capture |
|---|---|---|
| `on_signal_generate` | `signal:signal_generate` | l'expéditeur (`current`) + la cible (`comm`/`pid`), le signal, `si_code`, le drapeau `group`, `result` — supprimé dans le noyau si le bit du signal est positionné dans le `mute_mask` actif |
| `on_signal_deliver` | `signal:signal_deliver` | la disposition de la cible (`sa_handler`), `sa_flags`, et — depuis `task_struct` — son ensemble de signaux `blocked` ; horodate la livraison pour le timing du gestionnaire |
| (rt_sigreturn) | `syscalls:sys_enter_rt_sigreturn` | calcule la différence avec la livraison horodatée pour le temps d'exécution du gestionnaire |
| (sys_exit) | `raw_syscalls:sys_exit` | enregistre le rare retour `-ERESTART*` pour que la prochaine `signal_deliver` le résolve en `EINTR`/`restarted` + le numéro d'appel système interrompu |
Les cartes relient le noyau à l'espace utilisateur :
- `events` — `RINGBUF`, un `signal_event` par génération.
- `dispatch` — `RINGBUF`, un `dispatch_event` par livraison / retour de gestionnaire.
- `mute_mask` — un `__u64` global dans la section `.data` ; le sélecteur corrige des bits individuels pour supprimer les signaux dans le noyau.
- `handler_start` / `restart_pending` — `HASH` indexé par tid, brouillon par thread qui associe une livraison à son `rt_sigreturn`, et la sortie `-ERESTART*` d'un appel système à la livraison qui suit.
### Le côté JS
| fichier | responsabilité |
|---|---|
| [`src/probes/probe.js`](https://github.com/yeet-src/sigwire/blob/master/src/probes/probe.js) | charge `bin/probe.bpf.o` une fois, lie les cartes, démarre les programmes (ils s'attachent automatiquement) |
| [`src/probes/sigwire.js`](https://github.com/yeet-src/sigwire/blob/master/src/probes/sigwire.js) | le seul module de données conscient du BPF : fusionne les deux tampons circulaires en un flux défilant avec des totaux, corrèle la livraison à la génération, possède le bouton de masque muet — expose les signaux `feed`, `visible`, `muteMask` |
| [`src/main.jsx`](https://github.com/yeet-src/sigwire/blob/master/src/main.jsx) | racine de composition : entrée, sélection, disposition responsive (le rail se cache sur les terminaux étroits), `mount` |
| [`src/components/feed.jsx`](https://github.com/yeet-src/sigwire/blob/master/src/components/feed.jsx) | le tableau de distribution : `expéditeur ──SIG──▶ cible`, disposition/latence, badges, teinte, regroupement |
| [`src/components/tally.jsx`](https://github.com/yeet-src/sigwire/blob/master/src/components/tally.jsx) | le rail latéral — signaux principaux, répartition par source, total des livraisons |
| [`src/components/detail.jsx`](https://github.com/yeet-src/sigwire/blob/master/src/components/detail.jsx) | l'inspecteur — disposition par signal, gestionnaire, drapeaux, masque bloqué |
| [`src/components/picker.jsx`](https://github.com/yeet-src/sigwire/blob/master/src/components/picker.jsx) | la modale du sélecteur de signaux — masque/affiche chaque signal via le masque muet du noyau |
| [`src/components/titlebar.jsx`](https://github.com/yeet-src/sigwire/blob/master/src/components/titlebar.jsx) | marque, taux en direct, totaux, le compteur `☠ fatal`, nombre de masqués, en direct/en pause |
| [`src/components/footer.jsx`](https://github.com/yeet-src/sigwire/blob/master/src/components/footer.jsx) | indications des touches et l'invite de filtre en direct |
| [`src/lib/signals.js`](https://github.com/yeet-src/sigwire/blob/master/src/lib/signals.js) | la seule source de vérité : nom, sévérité, couleur, `si_code` → source, disposition, drapeaux, décodage du masque, fatalité |
| [`src/lib/format.js`](https://github.com/yeet-src/sigwire/blob/master/src/lib/format.js) | formateurs purs — remplissage, troncature, `ago()`, durées, comptes compacts |
| [`src/lib/fuzzy.js`](https://github.com/yeet-src/sigwire/blob/master/src/lib/fuzzy.js) | correspondance floue par sous-séquence sur processus + pid + signal + source + disposition |
Le modèle est un **flux défilant de signaux générés**, regroupant les répétitions identiques en lignes `×N`. La ligne de génération d'un signal est figée dès que sa livraison est résolue — donc une ligne déjà à l'écran ne change ni ne saute jamais. Un minuteur de fenêtre de 120 ms publie un instantané par image, donc un tampon circulaire chargé coûte un seul rendu, pas des milliers.
### Pourquoi des tracepoints, pas `strace`/`ptrace`
`strace -f` suit une arborescence de processus et arrête le tracé à chaque événement ; `ptrace` est par cible et intrusif. Les tracepoints de signaux sont la couture où le *noyau* génère et délivre un signal, pour *tous* les processus, sans configuration par application et sans arrêter personne. Apparier génération ↔ livraison ↔ `rt_sigreturn` est ce qui donne la paire expéditeur/cible, la disposition, la latence par gestionnaire et le verdict EINTR qui relient toute la vie d'un signal.
## Tests sur différents noyaux
`make veristat` charge `bin/probe.bpf.o` avec veristat sur **votre** noyau — une vérification rapide que chaque programme passe le vérificateur, plus la complexité par programme (insns/states). Charger du BPF nécessite des privilèges, donc utilisez `sudo`.
Un programme qui se charge sur votre portable peut être rejeté par le vérificateur d'un noyau plus ancien. [`.github/workflows/kernel-matrix.yml`](https://github.com/yeet-src/sigwire/blob/master/.github/workflows/kernel-matrix.yml) protège contre cela : pour chaque noyau de sa matrice, il construit l'objet, démarre ce noyau dans une VM ([little-vm-helper de cilium](https://github.com/cilium/little-vm-helper), images de `quay.io/lvh-images`), et exécute le **veristat** statique fourni contre elle — échouant le job si le vérificateur rejette un programme, et pivotant les résultats par noyau en une grille ✅/❌. Le passage dans la VM est [`build/verify-kernel.sh`](https://github.com/yeet-src/sigwire/blob/master/build/verify-kernel.sh).
Exécutez la même matrice localement (Linux + KVM) avec `make veristat-matrix` — il démarre les images noyau avec `lvh` + QEMU et affiche une grille `ok`/`FAIL`. Choisissez des noyaux avec `make veristat-matrix KERNELS="6.6 bpf-next"`.
## Prérequis
> [!IMPORTANT]
> - **Un noyau Linux avec BTF** (`CONFIG_DEBUG_INFO_BTF`) pour CO-RE — `bpftool` génère `src/bpf/include/vmlinux.h` à partir de cela. Par défaut sur Arch, Fedora, Ubuntu et Debian actuels (tous les noyaux des distributions grand public depuis ~5.4).
> - **Le démon yeet**, qui effectue le chargement BPF privilégié. Les capacités BPF sont déléguées à un processus démonisé, donc `sigwire` lui-même s'exécute sans privilèges. `curl -fsSL https://yeet.cx | sh` l'installe.
>
> Pour construire à partir des sources, vous avez également besoin de `clang` et `bpftool` — mais la chaîne d'outils statique fournie les contient, donc vous n'avez pas besoin d'une chaîne d'outils C/BPF système. Pas de node/npm : esbuild est également fourni et le projet n'a pas de dépendances tierces.
## Avertissements honnêtes
> [!NOTE]
> `sigwire` est de l'observabilité, pas de l'application de règles. Il montre ce qui a été émis ; il ne bloque, ne retarde et ne modifie aucun signal.
- **Une ligne est un signal *émis*.** La ligne du tableau de distribution provient de la génération ; la cible peut l'intercepter, le bloquer ou avoir déjà quitté. Les colonnes disposition/gestionnaire/masque proviennent du côté *livraison* et ne se remplissent qu'une fois que le noyau l'a effectivement livré — un signal bloqué ou encore en attente n'affiche pas de disposition. Voir [Ce qui est considéré comme fatal](#ce-qui-est-considéré-comme-fatal).
- **La corrélation est au mieux.** La génération et la livraison sont des tracepoints séparés sans identifiant partagé, appariés sur `(tid cible, signal)` dans une fenêtre de temps. Sous une rafale du même signal au même thread, l'appariement peut se brouiller ; c'est correct dans la grande majorité des cas.
- **Le timing du gestionnaire mesure le cadre du noyau, pas votre intention.** `ran` est livraison → `rt_sigreturn`. Un gestionnaire qui ne fait que positionner un drapeau (CPython, le runtime de Go) retourne en microsecondes même si le travail « réel » a lieu plus tard dans la boucle d'événements — précis, mais peut-être pas ce à quoi vous vous attendez.
- **La détection EINTR surveille chaque sortie d'appel système.** Intercepter les appels système interrompus signifie s'attacher à `raw_syscalls:sys_exit`, qui se déclenche sur *tous* les retours d'appel système à l'échelle du système (le gestionnaire quitte immédiatement pour tous sauf les rares codes `-ERESTART*`, donc le coût ajouté est de quelques instructions par appel système — mais ce n'est pas nul). Les *noms* d'appels système sont une table x86-64 ; les autres architectures affichent le numéro brut d'appel système.
- **L'expéditeur d'un signal noyau est `current`.** Pour une faute synchrone (`SIGSEGV` d'un mauvais accès), c'est la tâche fautive elle-même — correct et utile. Pour un signal noyau asynchrone, `current` est la tâche qui s'exécutait lorsque le noyau l'a émis, ce qui est une indication, pas une vérité absolue.
- **La numérotation des signaux temps réel est nominale.** `SIGRTMIN+n` est affiché par décalage brut ; les bibliothèques réservent les quelques premiers pour leur propre usage.
- **`comm` fait 16 octets.** Les longs noms de processus sont tronqués par le noyau, pas par sigwire.
## Questions de la communauté
**Est-ce que cela ralentit les processus tracés ?**
Aucun surcoût significatif. Les programmes de tracepoint sont passifs ; le coût est une écriture limitée dans un tampon circulaire par signal (et les quelques instructions par sortie d'appel système pour la détection EINTR), et le tampon circulaire abandonne plutôt que de bloquer si l'espace utilisateur prend du retard.
**Cela montrera-t-il les signaux destinés à un processus qui était déjà en cours lors de mon démarrage ?**
Oui. Les tracepoints se déclenchent pour chaque signal à partir du moment où sigwire s'attache, indépendamment du moment où l'expéditeur ou la cible a démarré — il n'y a pas d'état par processus à avoir manqué.
**Cela fonctionne-t-il pour n'importe quel processus, ou un seul ?**
N'importe quel processus sur l'hôte, tous à la fois — la gouttière expéditeur/cible les distingue. C'est tout le trafic de signaux de la machine, pas un pid.
**Puis-je exporter le flux ?**
Pas intégré. Les callbacks `RingBuf.subscribe` dans `probes/sigwire.js` contiennent chaque enregistrement décodé, donc un récepteur JSON/HTTP/Kafka est une branche là-bas. Pour mettre en place un pipeline géré, [contactez-nous](https://yeet.cx/).
## Construction à partir des sources```sh
make # clang + bpftool → bin/probe.bpf.o ; esbuild → src/index.jsx
make bpf # just the BPF object
make bundle # just the JS bundle
make clean # remove build artifacts
Ensuite, yeet run . exécute la construction locale. make lance deux compilateurs indépendants : clang + bpftool lient src/bpf/*.bpf.c en un objet chargeable bin/probe.bpf.o ; esbuild regroupe src/main.jsx dans src/index.jsx, en résolvant les alias de temps de liaison @/ (racine source) et #/ (racine du projet) via les paths de tsconfig et en laissant les modules intégrés yeet:* externes. Les deux compilateurs proviennent d'une chaîne d'outils statique vendue, donc la construction n'a besoin ni de chaîne d'outils C/BPF système ni de node/npm. Les fichiers générés vmlinux.h, src/index.jsx et bin/*.bpf.o sont des artefacts de construction.
Comme les alias ne sont définis qu'au moment de la liaison, l'exécutable localise l'objet BPF avec import.meta.dirname plutôt qu'un alias. Voir AGENTS.md (alias CLAUDE.md) pour le guide de création de tableaux de bord yeet.
Double BSD/GPL. Le programme BPF déclare char LICENSE[] SEC("license") = "Dual BSD/GPL" dans src/bpf/sigwire.bpf.c, ce que le noyau exige pour les helpers qu'il utilise.