Skip to content

Latest commit

 

History

History
689 lines (478 loc) · 24.8 KB

File metadata and controls

689 lines (478 loc) · 24.8 KB

🔝 Retour au Sommaire

29.4.2 — UndefinedBehaviorSanitizer (-fsanitize=undefined)

Section 29.4 : Sanitizers · Chapitre 29


Introduction

Le standard C++ définit un ensemble d'opérations dont le résultat est explicitement "indéfini" — le compilateur est libre de produire n'importe quel comportement, y compris un comportement qui semble correct. C'est ce qu'on appelle le undefined behavior (UB), et c'est le concept le plus traître du langage.

Pourquoi traître ? Parce que le UB ne plante pas. Pas tout de suite, en tout cas. Un entier signé qui overflow produit un résultat qui a l'air correct — jusqu'au jour où le compilateur, à un niveau d'optimisation différent, exploite l'hypothèse que les entiers signés ne débordent jamais et supprime votre vérification de limites. Un décalage de bits invalide retourne une valeur qui, par chance, est celle que vous attendiez — jusqu'à un changement de compilateur ou d'architecture. Un accès via un pointeur null lit une valeur quelconque de la mémoire — jusqu'à ce que le système d'exploitation décide que cette adresse est protégée.

Le UB est un bug dormant. Il fonctionne par accident, pas par design. Et quand il cesse de fonctionner, le symptôme peut se manifester à des milliers de lignes de distance de sa cause.

UndefinedBehaviorSanitizer — UBSan — détecte ces situations à l'exécution. Chaque opération susceptible de provoquer un comportement indéfini est instrumentée par une vérification. Si l'opération est invalide, UBSan signale l'erreur avec la ligne exacte, l'opération en cause, et les valeurs impliquées.


Activation

g++ -fsanitize=undefined -g -O1 -fno-omit-frame-pointer -o prog main.cpp

Comme pour ASan, le flag -fsanitize=undefined doit être passé au compilateur et au linker. La combinaison avec ASan est recommandée et n'entraîne qu'un surcoût marginal :

# La combinaison recommandée pour le développement quotidien
g++ -fsanitize=address,undefined -g -O1 -fno-omit-frame-pointer -o prog main.cpp

Surcoût de performance

C'est l'atout majeur d'UBSan : son impact sur les performances est quasi négligeable.

Métrique Surcoût typique
Temps d'exécution ~5-20 % plus lent
Mémoire Négligeable
Taille du binaire ~10-15 % plus gros

Ce surcoût minimal s'explique par la nature des vérifications : UBSan ajoute quelques instructions de test avant les opérations sensibles (overflow, shift, division), mais ne maintient pas de shadow memory ni de quarantaine comme ASan. Le programme instrumenté reste proche du programme normal en termes de comportement et de performance.

Ce faible coût rend UBSan utilisable dans des contextes où ASan serait trop coûteux — y compris, dans certains cas, sur des builds proches de la production.


Ce que -fsanitize=undefined active

Le flag undefined est un raccourci qui active un ensemble de vérifications. Comprendre lesquelles vous permet de choisir un sous-ensemble plus ciblé si nécessaire.

Checks inclus dans -fsanitize=undefined

Check Détecte
signed-integer-overflow Débordement d'entier signé (INT_MAX + 1)
unsigned-integer-overflow ⚠️ Non inclus par défaut (voir note ci-dessous)
shift Décalage de bits invalide (négatif ou ≥ largeur du type)
divide-by-zero Division entière par zéro
null Déréférencement de pointeur null
alignment Accès mémoire mal aligné
bool Chargement d'un bool avec une valeur autre que 0 ou 1
enum Chargement d'un enum avec une valeur hors de sa plage
float-cast-overflow Conversion flottant → entier hors limites
float-divide-by-zero Division flottante par zéro
return Atteindre la fin d'une fonction non-void sans return
unreachable Exécution de __builtin_unreachable()
vla-bound Tableau à taille variable avec taille ≤ 0
object-size Accès via un pointeur dont la taille de l'objet est connue
pointer-overflow Arithmétique de pointeur qui overflow
vptr Appel virtuel ou cast via un objet du mauvais type dynamique
nonnull-attribute Passage de nullptr à un paramètre marqué __attribute__((nonnull))
returns-nonnull-attribute Retour de nullptr d'une fonction marquée nonnull
builtin Argument invalide passé à un builtin (__builtin_clz(0))

⚠️ L'overflow d'entier non signé n'est PAS un comportement indéfini en C++. Il est défini comme un modulo 2^N. UBSan ne l'inclut pas dans -fsanitize=undefined. Si vous voulez le détecter (parce qu'un overflow non signé est souvent un bug logique même s'il n'est pas un UB), activez-le explicitement : -fsanitize=unsigned-integer-overflow.

Activer des checks individuels

Vous pouvez activer un check spécifique au lieu du groupe complet :

# Seulement les overflows d'entiers signés
g++ -fsanitize=signed-integer-overflow -g -O1 -o prog main.cpp

# Overflows signés + non signés
g++ -fsanitize=signed-integer-overflow,unsigned-integer-overflow -g -O1 -o prog main.cpp

# Tout undefined + unsigned overflow (la couverture maximale)
g++ -fsanitize=undefined,unsigned-integer-overflow -g -O1 -o prog main.cpp

Exclure un check spécifique

Pour activer le groupe undefined mais exclure un check qui produit trop de bruit :

# Tout sauf l'alignement (courant avec du code legacy ou du SIMD)
g++ -fsanitize=undefined -fno-sanitize=alignment -g -O1 -o prog main.cpp

Catégorie 1 : Signed integer overflow

Le comportement indéfini le plus courant et le plus sous-estimé. En C++, l'overflow d'un entier signé est indéfini. Cela signifie que le compilateur peut supposer qu'il n'arrive jamais — et exploiter cette hypothèse pour des optimisations agressives.

Exemple

// signed_overflow.cpp
#include <cstdio>
#include <climits>

int main() {
    int x = INT_MAX;
    int y = x + 1;    // UB : overflow d'entier signé
    
    std::printf("INT_MAX + 1 = %d\n", y);
    
    return 0;
}

Sans UBSan, ce programme affiche probablement INT_MAX + 1 = -2147483648 (le comportement de wrap-around en complément à deux). Ça a l'air de "fonctionner". Mais c'est un mirage — le compilateur a le droit de faire autre chose, et certaines optimisations exploitent exactement cette liberté.

Compilation et exécution

g++ -fsanitize=undefined -g -O1 -fno-omit-frame-pointer -o signed_overflow signed_overflow.cpp
./signed_overflow

Rapport UBSan

signed_overflow.cpp:6:19: runtime error: signed integer overflow: 
2147483647 + 1 cannot be represented in type 'int'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior signed_overflow.cpp:6:19

Le rapport est concis et précis : le fichier, la ligne, la colonne, l'opération exacte (2147483647 + 1), et la raison (cannot be represented in type 'int').

Pourquoi c'est dangereux en pratique

Considérez cette vérification de limites :

bool is_safe(int offset, int length) {
    return offset + length > 0;    // Vérifie que la somme est positive
}

Si offset et length sont tous deux positifs, le compilateur peut raisonner ainsi : "La somme de deux entiers signés positifs ne peut pas être négative (car l'overflow est UB et n'arrive donc jamais). Donc cette fonction retourne toujours true." Et il élimine la vérification. Votre garde de sécurité disparaît du binaire compilé.

C'est le mécanisme exact par lequel des vulnérabilités de sécurité naissent de UB exploité par le compilateur. UBSan les détecte avant qu'elles ne causent des problèmes.


Catégorie 2 : Shift invalide

Un décalage de bits est indéfini quand le nombre de positions est négatif ou supérieur ou égal à la largeur du type, ou quand un shift gauche sur un entier signé produit un overflow.

Exemple

// shift_ub.cpp
#include <cstdio>
#include <cstdint>

int main() {
    int32_t a = 1;
    int32_t b = a << 32;    // UB : shift >= largeur du type (32 bits)
    std::printf("1 << 32 = %d\n", b);
    
    int32_t c = -1;
    int32_t d = c << 2;     // UB avant C++20 : shift gauche d'un négatif
    std::printf("-1 << 2 = %d\n", d);
    
    int shift_amount = -3;
    int32_t e = a << shift_amount;   // UB : shift par valeur négative
    std::printf("1 << -3 = %d\n", e);
    
    return 0;
}

Rapport UBSan

shift_ub.cpp:6:22: runtime error: shift exponent 32 is too large for 32-bit type 'int'  
shift_ub.cpp:9:22: runtime error: left shift of negative value -1  
shift_ub.cpp:12:22: runtime error: shift exponent -3 is negative  

Trois erreurs, trois lignes, trois explications distinctes. Chaque rapport identifie le problème exact.

📝 Note C++20 : depuis C++20, le shift gauche d'une valeur négative est défini (résultat en complément à deux). UBSan ajuste ses vérifications selon le standard utilisé (-std=c++20 vs -std=c++17). Le shift par une valeur ≥ largeur du type reste indéfini dans tous les standards.


Catégorie 3 : Division par zéro

La division entière par zéro est un comportement indéfini. Sur la plupart des architectures, elle provoque un signal SIGFPE, mais ce n'est pas garanti par le standard.

Exemple

// div_zero.cpp
#include <cstdio>

int average(int total, int count) {
    return total / count;    // UB si count == 0
}

int main() {
    std::printf("Moyenne : %d\n", average(100, 0));
    return 0;
}

Rapport UBSan

div_zero.cpp:4:18: runtime error: division by zero

Simple, direct. Sans UBSan, ce programme crashe probablement avec un SIGFPE, mais le signal ne vous donne pas la ligne exacte ni le contexte — UBSan oui.


Catégorie 4 : Déréférencement de pointeur null

Accéder à la mémoire via un pointeur null est un comportement indéfini. Le résultat le plus courant est un SIGSEGV, mais le compilateur peut aussi optimiser en supposant que le pointeur n'est jamais null.

Exemple

// null_deref.cpp
#include <cstdio>

struct Config {
    int port;
    const char* host;
};

void print_config(Config* cfg) {
    std::printf("Host: %s, Port: %d\n", cfg->host, cfg->port);
}

int main() {
    Config* cfg = nullptr;
    print_config(cfg);    // UB : déréférencement de nullptr
    return 0;
}

Rapport UBSan

null_deref.cpp:10:48: runtime error: member access within null pointer of type 'Config'

UBSan identifie précisément l'opération : un accès membre (cfg->host) via un pointeur null de type Config. Le rapport apparaît avant le crash — UBSan intercepte le UB, pas le signal résultant.

Subtilité : le compilateur exploite le UB

Considérez cette variante :

void print_config(Config* cfg) {
    if (cfg == nullptr) {
        std::printf("Configuration vide\n");
        return;
    }
    std::printf("Host: %s\n", cfg->host);
}

int main() {
    Config* cfg = nullptr;
    cfg->port = 8080;       // UB : écriture via nullptr
    print_config(cfg);       // Le compilateur peut éliminer le check null
}

Après la ligne cfg->port = 8080, le compilateur est en droit de supposer que cfg != nullptr (sinon, il y aurait un UB, et le programme n'a pas le droit de provoquer un UB). En conséquence, il peut éliminer le if (cfg == nullptr) dans print_config comme étant "mort" — il "sait" que cfg n'est pas null.

UBSan détecte le problème dès la ligne cfg->port = 8080, bien avant que les optimisations n'aient effacé vos gardes de sécurité.


Catégorie 5 : Float-cast overflow

La conversion d'un nombre flottant vers un type entier est indéfinie si la valeur flottante est hors de la plage représentable par l'entier.

Exemple

// float_cast.cpp
#include <cstdio>
#include <cmath>

int main() {
    double big = 1e18;
    int truncated = static_cast<int>(big);    // UB : 1e18 > INT_MAX
    std::printf("Tronqué : %d\n", truncated);
    
    double nan = std::nan("");
    int from_nan = static_cast<int>(nan);     // UB : NaN → int
    std::printf("NaN → int : %d\n", from_nan);
    
    return 0;
}

Rapport UBSan

float_cast.cpp:6:21: runtime error: 1e+18 is outside the range of representable values of type 'int'  
float_cast.cpp:9:23: runtime error: value nan is outside the range of representable values of type 'int'  

Ce type de bug survient fréquemment dans le traitement de données externes — un fichier JSON contient un nombre flottant que vous convertissez en entier sans vérification de limites.


Catégorie 6 : Fonction non-void sans return

Atteindre la fin d'une fonction déclarée comme retournant une valeur sans exécuter de return est un comportement indéfini. Le compilateur peut générer n'importe quelle valeur de retour — ou ne rien retourner du tout.

Exemple

// missing_return.cpp
#include <cstdio>

int classify(int value) {
    if (value > 0) return 1;
    if (value < 0) return -1;
    // Bug : pas de return quand value == 0
}

int main() {
    std::printf("Classification de 0 : %d\n", classify(0));
    return 0;
}

Rapport UBSan

missing_return.cpp:6:1: runtime error: execution reached the end of a value-returning  
function without returning a value  

Le compilateur émet généralement un warning (-Wreturn-type) pour ce cas. Mais le warning n'apparaît qu'à la compilation — si vous n'avez pas -Werror, le bug passe en production. UBSan le détecte à l'exécution, quand le chemin problématique est réellement emprunté.


Catégorie 7 : Accès mal aligné

Certaines architectures exigent que les accès mémoire soient alignés : un int (4 octets) doit être lu à une adresse multiple de 4. Un accès mal aligné est un comportement indéfini — il peut fonctionner (x86 le tolère avec une pénalité de performance), crasher (ARM strict), ou produire des données corrompues.

Exemple

// alignment.cpp
#include <cstdio>
#include <cstdint>

int main() {
    char buffer[16] = {};
    
    // Forcer un pointeur int* mal aligné
    int* misaligned = reinterpret_cast<int*>(buffer + 1);
    *misaligned = 42;    // UB : adresse non alignée sur 4 octets
    
    std::printf("Valeur : %d\n", *misaligned);
    return 0;
}

Rapport UBSan

alignment.cpp:9:6: runtime error: store to misaligned address 0x7ffc12345679 for type 
'int', which requires 4 byte alignment
0x7ffc12345679: note: pointer points here
 00 00 00 00  00 00 00 00  00 00 00 00  00 00 00 00

Sur x86_64, ce code "fonctionne" — le processeur tolère les accès mal alignés. Mais c'est du UB, et UBSan le signale à raison. Sur ARM ou dans du code SIMD, ce même accès crasherait ou produirait un résultat incorrect.

Quand désactiver ce check

Le check alignment est le plus susceptible de produire du bruit dans du code legacy ou du code qui fait du networking/sérialisation (casting de buffers en structures). Si les faux positifs sont trop nombreux :

g++ -fsanitize=undefined -fno-sanitize=alignment -g -O1 -o prog main.cpp

Catégorie 8 : Appel virtuel via un mauvais type (vptr)

Le check vptr détecte les appels de fonctions virtuelles via un objet dont le type dynamique est incorrect — typiquement, un objet qui a été détruit mais dont la mémoire est réutilisée.

Exemple

// bad_vptr.cpp
#include <cstdio>
#include <cstdlib>

struct Base {
    virtual void process() { std::printf("Base::process\n"); }
    virtual ~Base() = default;
};

struct Derived : Base {
    void process() override { std::printf("Derived::process\n"); }
};

int main() {
    Base* obj = new Derived();
    obj->~Base();           // Destruction manuelle
    obj->process();         // UB : vtable détruite
    std::free(obj);         // Libération sans passer par delete
    return 0;
}

Rapport UBSan

bad_vptr.cpp:14:9: runtime error: member call on address 0x602000000010 which does  
not point to an object of type 'Base'  
0x602000000010: note: object has invalid vptr

Ce check nécessite des informations RTTI. Il est activé par défaut avec -fsanitize=undefined mais peut être désactivé si vous compilez avec -fno-rtti :

# Si votre projet utilise -fno-rtti, désactivez vptr
g++ -fsanitize=undefined -fno-sanitize=vptr -fno-rtti -g -O1 -o prog main.cpp

Comportement à la détection d'erreur

Par défaut, UBSan a un comportement différent d'ASan :

  • ASan : arrête le programme à la première erreur (abort)
  • UBSan : affiche le rapport et continue l'exécution

Ce comportement par défaut est voulu : un programme peut contenir de nombreux UB bénins (du point de vue fonctionnel), et arrêter à la première occurrence masquerait les autres. Vous voyez tous les problèmes en une seule exécution.

Changer le comportement

Pour arrêter le programme à la première erreur UBSan :

# Option 1 : via variable d'environnement
UBSAN_OPTIONS="halt_on_error=1" ./prog

# Option 2 : via flag de compilation (piège le UB comme un signal)
g++ -fsanitize=undefined -fno-sanitize-recover=all -g -O1 -o prog main.cpp

Le flag -fno-sanitize-recover=all transforme chaque UB détecté en arrêt fatal. C'est recommandé en CI — un build avec UBSan doit échouer si un UB est détecté.

Cibler la récupération par check

Vous pouvez choisir quels checks sont fatals et lesquels ne font qu'avertir :

# Overflow signé = fatal, tout le reste = warning
g++ -fsanitize=undefined \
    -fno-sanitize-recover=signed-integer-overflow \
    -g -O1 -o prog main.cpp

Obtenir des backtraces complètes

Par défaut, UBSan affiche uniquement la ligne de l'erreur, sans pile d'appels. Pour obtenir un backtrace :

UBSAN_OPTIONS="print_stacktrace=1" ./prog

Le rapport devient :

signed_overflow.cpp:6:19: runtime error: signed integer overflow: 
2147483647 + 1 cannot be represented in type 'int'
    #0 0x4f4b26 in compute(int, int) signed_overflow.cpp:6
    #1 0x4f4c10 in process_batch(std::vector<int> const&) batch.cpp:42
    #2 0x4f5012 in main main.cpp:15

La pile d'appels montre le chemin complet qui a mené à l'opération invalide. C'est indispensable pour diagnostiquer un UB dans du code imbriqué.

L'option print_stacktrace=1 nécessite que le programme soit compilé avec -fno-omit-frame-pointer pour des backtraces fiables. C'est une raison de plus d'inclure ce flag systématiquement.


UBSan en mode minimal : -fsanitize-trap

Pour les environnements très contraints (embarqué, production), UBSan peut être compilé en mode trap : au lieu d'afficher un rapport, il exécute une instruction trap qui provoque un arrêt immédiat du programme, sans aucune dépendance à la bibliothèque runtime d'UBSan.

g++ -fsanitize=undefined -fsanitize-trap=all -g -O1 -o prog main.cpp

Avantages :

  • Aucune bibliothèque runtime nécessaire (pas de libubsan)
  • Aucune sortie texte (pas de fuite d'information en production)
  • Surcoût de taille quasi nul
  • Le core dump résultant permet d'identifier la ligne via GDB

Inconvénient : pas de message d'erreur. Vous voyez uniquement un crash (SIGILL ou SIGTRAP) et devez charger le core dump dans GDB pour comprendre quel check a déclenché le trap.

C'est un compromis intéressant pour la production : le programme est protégé contre les UB les plus graves (il s'arrête proprement au lieu de continuer dans un état corrompu), sans le surcoût du runtime de reporting.


UBSan et GDB

Comme pour ASan, UBSan se combine bien avec GDB. Quand UBSan est configuré pour être fatal (-fno-sanitize-recover=all ou halt_on_error=1), GDB intercepte l'erreur :

UBSAN_OPTIONS="halt_on_error=1" gdb ./prog
(gdb) run
# UBSan détecte un UB et appelle abort()
(gdb) backtrace
(gdb) info locals
(gdb) print x

Vous pouvez aussi poser un breakpoint sur le handler UBSan :

(gdb) break __ubsan_handle_add_overflow
(gdb) run

Les noms des handlers suivent le pattern __ubsan_handle_<check_name>. Les plus courants :

Handler Check
__ubsan_handle_add_overflow Overflow sur addition
__ubsan_handle_sub_overflow Overflow sur soustraction
__ubsan_handle_mul_overflow Overflow sur multiplication
__ubsan_handle_divrem_by_zero Division par zéro
__ubsan_handle_shift_out_of_bounds Shift invalide
__ubsan_handle_type_mismatch_v1 Null deref, alignement, vptr
__ubsan_handle_missing_return Fonction non-void sans return

Unsigned integer overflow : un cas particulier

L'overflow d'entier non signé n'est pas un comportement indéfini — le standard définit un wrap-around modulo 2^N. Cependant, un overflow non signé est presque toujours un bug logique. Un compteur qui wrappe de UINT_MAX à 0, une soustraction qui wrappe de 0 à UINT_MAX — ce sont des bugs, même s'ils ne sont pas des UB.

UBSan propose un check dédié, exclu de -fsanitize=undefined par défaut :

g++ -fsanitize=unsigned-integer-overflow -g -O1 -o prog main.cpp

Exemple

// unsigned_overflow.cpp
#include <cstdio>
#include <cstdint>

int main() {
    uint32_t a = 0;
    uint32_t b = a - 1;    // Wrap-around : b = 4294967295
    std::printf("0 - 1 = %u\n", b);
    
    return 0;
}

Rapport

unsigned_overflow.cpp:6:22: runtime error: unsigned integer overflow: 
0 - 1 cannot be represented in type 'unsigned int'

Quand l'activer

Activez-le si votre code n'utilise pas intentionnellement le wrap-around non signé. Désactivez-le (ou supprimez les faux positifs) si votre code fait du hashing, de la cryptographie, ou du calcul modulaire où le wrap-around est voulu.

# La couverture maximale (avec tous les overflows)
g++ -fsanitize=undefined,unsigned-integer-overflow \
    -g -O1 -fno-omit-frame-pointer -o prog main.cpp

Pour marquer une fonction qui utilise intentionnellement le wrap-around :

__attribute__((no_sanitize("unsigned-integer-overflow")))
uint32_t hash_combine(uint32_t seed, uint32_t value) {
    return seed ^ (value + 0x9e3779b9 + (seed << 6) + (seed >> 2));
}

Intégration CMake

La configuration CMake standard pour UBSan, compatible avec la structure présentée en section 29.4 :

option(ENABLE_UBSAN "Enable UndefinedBehaviorSanitizer" OFF)

if(ENABLE_UBSAN)
    add_compile_options(
        -fsanitize=undefined
        -fno-omit-frame-pointer
        -fno-sanitize-recover=all      # Fatal en CI
    )
    add_link_options(-fsanitize=undefined)
endif()

Pour la couverture maximale :

if(ENABLE_UBSAN)
    set(UBSAN_FLAGS
        -fsanitize=undefined,unsigned-integer-overflow
        -fno-omit-frame-pointer
        -fno-sanitize-recover=all
    )
    add_compile_options(${UBSAN_FLAGS})
    add_link_options(-fsanitize=undefined,unsigned-integer-overflow)
endif()

Différences GCC vs Clang

Les deux compilateurs supportent UBSan, mais avec quelques différences :

Aspect GCC 15 Clang 20
Checks de base ✅ Tous supportés ✅ Tous supportés
-fsanitize-trap ✅ Supporté ✅ Supporté
unsigned-integer-overflow ✅ Supporté ✅ Supporté
-fsanitize=implicit-conversion ❌ Non supporté ✅ Supporté
-fsanitize=local-bounds ❌ Non supporté ✅ Supporté
Qualité des messages d'erreur Bonne Légèrement meilleure

Clang propose deux checks supplémentaires intéressants :

  • implicit-conversion : détecte les conversions implicites qui changent la valeur (par exemple, un int négatif implicitement converti en unsigned).
  • local-bounds : détecte les accès hors limites sur des tableaux locaux dont la taille est connue à la compilation.

Si vous avez Clang disponible, c'est un argument pour l'utiliser comme compilateur de sanitizer, même si votre build de production utilise GCC.


Checklist UBSan

  • Activer UBSan avec ASan-fsanitize=address,undefined comme configuration de développement par défaut
  • -fno-sanitize-recover=all en CI — chaque UB détecté fait échouer le build
  • print_stacktrace=1 — toujours inclure les piles d'appels dans les rapports
  • Considérer unsigned-integer-overflow — l'activer si votre code ne fait pas de calcul modulaire intentionnel
  • -fno-sanitize=alignment si nécessaire — pour du code réseau/sérialisation qui fait des casts de buffers
  • Zéro UB toléré — un comportement indéfini est toujours un bug, même quand il "fonctionne"

À retenir : le comportement indéfini est un bug qui fonctionne par accident. UBSan le détecte avec un surcoût quasi négligeable (~5-20 %), ce qui en fait le sanitizer le plus facile à adopter. Combiné avec ASan, il forme le socle de détection automatique qui devrait être actif par défaut dans tout projet C++ sérieux. Si un UB est détecté, corrigez-le — même s'il "ne pose pas de problème" aujourd'hui. C'est un problème en attente.

⏭️ ThreadSanitizer (-fsanitize=thread)