L'hypothèse était que chaque qCInfo bloquait le thread de décision sur une écriture SQLite sous verrou. Mesurée sur la cible, elle ne tient pas — et deux de ses prémisses étaient fausses. CE QUI ÉTAIT FAUX. (1) `qCInfo` ne passe PAS par le LogEngine : il va au gestionnaire de messages, qui écrit sur stdout. Le chemin `Logger::log()` → `logEvent()` est celui des états et actions journalisés, déclenché par des changements d'état. Deux chemins, deux coûts. (2) Le LogEngine de la cible est LogEngineInfluxDB : `logEvent()` empile dans m_writeQueue et poste en asynchrone. Rien n'y bloque le thread appelant. Le .sqlite du rapport était celui du harnais de test, pas de la box. MESURÉ sur Pi Zero 2 W, banc croisé arm64 lié à libnymea, gestionnaire de nymea installé, stdout sur la socket journald — les conditions de nymead : · catégorie active, vers journald : 69,4 µs / ligne · catégorie active, vers /dev/null : 61,9 µs / ligne · catégorie éteinte : 0,0045 µs / ligne L'I/O n'est pas le coût : 8 µs sur 70. Le reste est du formatage. Et le cycle d'arbitrage tourne à 60 s exactement pour ~30 lignes — soit 2,1 ms par cycle, 0,0035 %. Il faudrait 143 lignes par seconde soutenues pour atteindre 1 %. Donc : ce n'était pas un argument pour le rapport, et l'y mettre sans mesurer l'aurait affaibli. Une première estimation en Python donnait 11 µs/ligne — six fois trop bas, parce qu'elle ne mesurait que l'écriture et pas le formatage. Le banc en Qt, lié à la vraie bibliothèque, était le seul moyen d'avoir le chiffre. MAIS UN DÉFAUT RÉEL EN SORT, petit et chiffré. nymeaLogMessageHandler construit `messageString` — dont un QDateTime::toString — POUR CHAQUE MESSAGE, alors qu'elle ne sert que si un fichier journal est ouvert. Mesuré isolément : 28,0 µs, soit 40 % du coût d'une ligne. Et nymead tourne sans fichier journal dans le service livré (`ExecStart=/usr/bin/nymead -n`, aucun descripteur .log ouvert) : ces 28 µs sont jetés à chaque message, sur toute installation en configuration par défaut. Correctif d'une accolade : construire messageString sous `if (s_logFile.isOpen())`. Ajouté en troisième demande du rapport, explicitement indépendante du plantage.
10 KiB
SIGSEGV dans Logger::log — déréférencement d'un journaliseur ABSENT
À remonter à nymea. Rédigé le 2026-08-31, symbolisé et conclu le 2026-09-05. Le vidage est conservé (voir « Pièces »).
L'hypothèse initiale — durée de vie du
Loggervis-à-vis des événements postés — est TOMBÉE. Ce n'est pas un usage après libération. C'est un pointeur nul déréférencé, et la garde qui devait l'empêcher interroge un ensemble différent de celui qu'elle protège. Le correctif est d'une ligne.
Conclusion, en une phrase
ThingManagerImplementation::executeAction() connecte une lambda à ThingActionInfo::finished
qui écrit un journal d'action via m_actionLoggers.value(clé)->log(...). QHash::value() rend
nullptr sur une clé absente, et le résultat est déréférencé sans contrôle. La garde utilisée —
thing->loggedActionTypeIds().contains(actionType.id()) — porte sur la liste du Thing, pas sur
la table des journaliseurs ; les deux divergent au retrait d'un Thing, et c'est là que ça casse.
Ce qui est ÉTABLI
Symbolisé avec libnymea1-dbgsym et libnymea-core-dbgsym en 1.15.2+202606191336~trixie1 — la
version exacte du plantage, identifiants de build vérifiés (libnymea.so.1 :
3696e684…, libnymea-core.so.1 : b50a1ae5…).
| trame | résolution |
|---|---|
#0 libnymea.so.1 + 0x14ea24 |
Logger::log(QStringList const&, QVariantMap const&) — libnymea/logging/logger.cpp:55 |
#1 libnymea-core.so.1 + 0x1edae6 |
ThingManagerImplementation::executeAction(Action const&)::{lambda()#1}::operator()() const |
Et le registre tranche la nature du défaut : rdi = 0x0 au moment du signal. Dans l'ABI SysV
AMD64, rdi porte le premier argument, donc this pour une méthode non statique.
Logger::log a été appelée sur un pointeur nul. Un usage après libération donnerait une
adresse quelconque, pas zéro.
Le corps de Logger::log tient en une instruction — m_engine->logEvent(this, tags, values) —
donc la lecture de m_engine à l'offset d'un this nul produit le SIGSEGV.
Le mécanisme
// thingmanagerimplementation.cpp — executeAction()
connect(info, &ThingActionInfo::finished, this, [=](){
if (thing->loggedActionTypeIds().contains(actionType.id())) { // ← garde
...
m_actionLoggers.value(thing->id().toString() + "-" + actionType.name())->log({}, {...});
} // ↑ rend nullptr si la clé est absente
});
void ThingManagerImplementation::unregisterActionLogger(Thing *thing, const ActionTypeId &id) {
...
m_actionLoggers.remove(name); // la TABLE est vidée
}
Au retrait d'un Thing, la boucle foreach (actionTypeId : thing->loggedActionTypeIds()) unregisterActionLogger(...) vide la table. La liste loggedActionTypeIds() du Thing, elle,
n'est pas touchée : c'est une propriété du Thing, pas de la table.
La connexion est établie avec this (le ThingManager) pour contexte, pas avec le Thing ni
avec l'info. Un finished déjà posté survit donc au retrait du Thing. Quand la lambda
s'exécute :
- la garde consulte
loggedActionTypeIds()→ vraie, la liste est intacte ; m_actionLoggers.value(clé)→nullptr, l'entrée a été retirée ;nullptr->log(...)→ SIGSEGV.
La garde et la table ne parlent pas du même objet. C'est ce qui rend le défaut invisible à la relecture : le code a une garde, elle a l'air de protéger l'accès qui suit, et elle n'a aucun rapport avec lui.
Pourquoi la reproductibilité est FAIBLE, et pourquoi ce n'est pas rassurant
Observé une fois sur une exécution de suite de tests en tâche de fond ; non reproduit en trois séquences identiques ni en exécution isolée (48/48, code 0). La fenêtre exige qu'une action soit encore en vol au moment où son Thing est retiré — ce qu'un démontage de suite de tests produit naturellement, et qu'une exécution tranquille ne produit presque jamais.
Ce n'est pas limité aux tests. Toute suppression de Thing pendant qu'une action est en cours ouvre la même fenêtre, sur une installation de production. Une action longue — une borne de recharge qui négocie, un appareil Modbus lent — l'élargit.
Correctif proposé
Le minimum, une ligne, sans changement de conception :
Logger *logger = m_actionLoggers.value(thing->id().toString() + "-" + actionType.name());
if (logger)
logger->log({}, {...});
Mieux, et c'est la vraie leçon : faire porter la garde sur la table qu'on va indexer
(m_actionLoggers.contains(clé)) plutôt que sur une liste qui lui ressemble. Les mêmes deux
lignes existent pour m_stateLoggers et m_eventLoggers — à vérifier de la même façon.
Le
Loggerlui-même n'est pas en cause : sa durée de vie est correcte. C'est son ABSENCE de la table qui n'est pas testée.
Environnement
libnymea1 / libnymea-core |
1.15.2+202606191336~trixie1 |
| Qt | 6.8.2+dfsg-9+deb13u2 |
| Système | Debian 13 (trixie), x86_64 |
| Binaire | suite de tests charging du plugin énergie ETM |
Réserve de lecture
La symbolisation vient des symboles 1.15.2, donc les noms et les lignes sont ceux de la
version qui a planté. En revanche, le code cité ci-dessus est lu dans l'arbre 1.16.0 (seule
source disponible localement). Les deux symboles résolus s'y retrouvent à l'identique, et
Logger::log y fait toujours trois lignes — mais la formulation exacte du correctif est à
vérifier contre 1.15.2 par quelqu'un qui a la branche.
Pièces
- Vidage :
coredumpctldu 2026-08-31 07:06:01 CEST, PID 115071, 2,7 Mio compressés (77 Mio décompressés), conservé. - Symbolisation reproductible sans installer quoi que ce soit, et sans privilèges :
V=1.15.2+202606191336~trixie1
apt-get download libnymea1-dbgsym=$V libnymea-core-dbgsym=$V
for d in *.deb; do dpkg-deb -x "$d" root/; done
addr2line -f -C -e root/usr/lib/debug/.build-id/36/96e684….debug 0x14ea24 # → Logger::log
addr2line -f -C -e root/usr/lib/debug/.build-id/b5/0a1ae5….debug 0x1edae6 # → la lambda
gdb -batch -q --core=<core> -ex "info all-registers rdi" # → rdi = 0x0
gdbne peut PAS produire de pile complète ici : le binaire de test et le plugin ont été reconstruits depuis le plantage, donc leurs identifiants de build ne correspondent plus au vidage. La résolution par offset dans les bibliothèques, elle, reste valide — leurs identifiants correspondent, et c'est tout ce dont la conclusion a besoin.
Second point, MESURÉ : le coût d'un qCInfo, et une chaîne construite pour rien
Ouvert sur l'hypothèse que la journalisation volerait du temps à la boucle de décision. La mesure ne la soutient pas — et deux prémisses étaient fausses. On le consigne quand même, parce qu'il en reste un défaut réel, petit et chiffré.
Ce qui était faux dans l'hypothèse
qCInfone passe PAS par leLogEngine. Il va au gestionnaire de messages (nymeaLogMessageHandler), qui écrit surstdoutavecfflushpar ligne. Le cheminLogger::log()→LogEngine::logEvent()est celui des états et actions journalisés, déclenché par des changements d'état, pas par les traces de code. Deux chemins, deux coûts.- Le
LogEnginede la cible ne fait pas d'I/O synchrone. C'estLogEngineInfluxDB:logEvent()empile dansm_writeQueueetQNetworkAccessManager::post()part en asynchrone. Rien n'y bloque le thread appelant. (Le.sqlitecité plus haut est celui du harnais de test.)
Ce qui est mesuré, sur la cible
Banc croisé arm64, lié à libnymea, gestionnaire de nymea installé, stdout sur la socket
journald — les conditions de nymead. Raspberry Pi Zero 2 W, 5 000 lignes par essai, trois essais.
| condition | coût par ligne |
|---|---|
catégorie ACTIVE, stdout → journald |
69,4 µs (68,6 / 68,9 / 76,5) |
catégorie ACTIVE, stdout → /dev/null |
61,9 µs (61,7 / 62,3 / 61,7) |
| catégorie ÉTEINTE | 0,0045 µs |
L'I/O n'est pas le coût : 8 µs sur 70. Le reste est du formatage, dans le thread appelant. Et l'extinction de catégorie court-circuite avant tout formatage — c'est gratuit, Qt fait ce qu'il faut.
Rapporté au cycle : la boucle d'arbitrage tourne à 60 s exactement, pour ~30 lignes par cycle (915 lignes sur 30 min relevées au journal). Soit 2,1 ms par cycle de 60 s — 0,0035 %. Pour atteindre 1 % du cycle, il faudrait ~8 600 lignes par cycle, soit 143 lignes par seconde soutenues.
Conclusion : la journalisation ne vole pas de temps à la boucle de décision sur cette cible. Ce n'était pas un argument pour ce rapport, et l'y mettre sans mesurer l'aurait affaibli.
Le défaut qui reste, lui, est réel
nymeaLogMessageHandler construit messageString — dont
QDateTime::currentDateTime().toString("yyyy.MM.dd hh:mm:ss.zzz") — pour chaque message,
alors que cette chaîne ne sert que si un fichier journal est ouvert :
QMutexLocker locker(&s_loggerMutex);
if (s_logFile.isOpen()) { // ← seul usage de messageString
QTextStream textStream(&s_logFile);
textStream << messageString << '\n';
}
Mesuré isolément sur la même cible : 28,0 µs par appel (31,9 / 27,9 / 27,9), soit 40 % du
coût d'une ligne. Et nymead tourne sans fichier journal dans le service livré
(ExecStart=/usr/bin/nymead -n, aucun descripteur .log ouvert) : ces 28 µs sont jetés à
chaque message, sur toutes les installations en configuration par défaut.
Correctif : ne construire messageString que sous if (s_logFile.isOpen()). Le switch sert
déjà à deux fins — écrire sur stdout et fabriquer la chaîne ; seule la seconde est
conditionnelle.
Ce n'est pas une urgence : 28 µs × 30 lignes = 0,8 ms par cycle de 60 s. C'est signalé parce que c'est gratuit à corriger et que le coût est payé par tout le monde, tout le temps.
Ce qui est demandé
- Confirmer le correctif contre la branche 1.15.x, et vérifier les deux tables jumelles
(
m_stateLoggers,m_eventLoggers). - Décider si la connexion doit prendre
thingouinfopour contexte plutôt quethis— ce qui fermerait la fenêtre au lieu de la rendre inoffensive. - Indépendant du plantage : conditionner la construction de
messageStringàs_logFile.isOpen()dansnymeaLogMessageHandler— 28 µs par message rendus à toute installation sans fichier journal, soit la configuration livrée.