L'essentiel
  • Entre juin 1985 et janvier 1987, six surdosages massifs de radiothérapie ont été documentés sur les onze Therac-25 installés, avec des doses estimées jusqu'à cent fois la dose thérapeutique.
  • AECL avait retiré les protections matérielles indépendantes en se fiant uniquement au logiciel, jugé fiable par héritage des modèles précédents.
  • Le même défaut logiciel existait sur le Therac-20 sans jamais blesser personne : les interlocks matériels empêchaient le faisceau de partir.
  • La fenêtre de vulnérabilité durait environ 8 secondes et ne s'ouvrait que si l'opérateur tapait vite, ce qui rendait le bug irreproductible en laboratoire.
  • En Python, threading.Lock protège les variables partagées, et remplacer une contrainte structurelle par une vérification applicative est toujours un pari.

Lecture complète : 17 min

En 1985, une machine de radiothérapie canadienne a commencé à surdoser ses patients. Le coupable : une race condition, deux tâches logicielles qui modifient la même variable en même temps sans se coordonner. Six accidents documentés jusqu’en 1987, trois morts. Voici la mécanique exacte du bug, et comment l’éviter dans ton propre code.

Trois d’entre eux sont morts. Les autres ont survécu avec des séquelles permanentes. Pas à cause d’une panne mécanique. Pas à cause d’une erreur humaine. À cause d’une race condition dans un logiciel que personne ne testait vraiment, parce que les ingénieurs étaient convaincus qu’il était correct.

Le Therac-25 est le cas d’école absolu sur ce qui se passe quand on retire des protections matérielles sans renforcer les protections logicielles, et quand on déploie du code médical sans tests de sécurité sérieux.

Ce qui s’est passé entre 1985 et 1987 se produit encore aujourd’hui dans des contextes moins dramatiques mais structurellement identiques. Voici l’histoire, la mécanique du bug, et comment Python t’expose aux mêmes pièges.


Le Therac-25 : contexte et chronologie

Le Therac-25 était une machine de radiothérapie fabriquée par Atomic Energy of Canada Limited. Elle remplaçait deux modèles précédents, le Therac-6 et le Therac-20, qui fonctionnaient avec des protections matérielles indépendantes du logiciel.

Sur les anciens modèles, si le logiciel ordonnait un dosage trop élevé, un interlock physique bloquait la machine. Sur le Therac-25, AECL a décidé que ces protections matérielles étaient redondantes. Le logiciel était jugé suffisamment fiable pour les remplacer seul.

C’était une erreur fatale.

Le logiciel du Therac-25 était écrit en assembleur PDP-11 par un seul développeur, sans documentation de conception, sans revue de code, et sans tests formels. Il était considéré comme une amélioration directe du logiciel des modèles précédents, donc présumé correct par héritage.

Onze machines ont été installées, cinq aux États-Unis et six au Canada. Six accidents de surdosage massif y sont documentés, d’après l’enquête de Nancy Leveson et Clark Turner publiée dans IEEE Computer :

DateLieuDose estiméeIssue
3 juin 1985Marietta, Géorgie15 000 à 20 000 radsSéquelles permanentes, perte de l’usage du bras
26 juillet 1985Hamilton, Ontario13 000 à 17 000 radsDécès en novembre, l’autopsie relève des lésions d’irradiation
Décembre 1985Yakima, Washingtonnon établieSéquelles
21 mars 1986Tyler, Texas16 500 à 25 000 radsDécès cinq mois plus tard
11 avril 1986Tyler, Texasnon établieDécès le 1er mai
17 janvier 1987Yakima, Washingtonnon établieDécès

Une dose thérapeutique normale tourne autour de 200 rads. Les patients en ont reçu jusqu’à cent fois plus en une fraction de seconde.

Ce qui est frappant dans cette chronologie, c’est moins la répétition que la réponse d’AECL entre chaque incident. Après Hamilton, le constructeur a conclu à la défaillance d’un microswitch et modifié la machine de Yakima en conséquence. Le vrai bug n’était pas là. Après Tyler, l’hôpital a demandé un arrêt des traitements, AECL a répondu que le surdosage était impossible. Il a fallu qu’un physicien de Tyler reproduise lui même la séquence de touches pour que le lien avec le logiciel devienne indiscutable.

À chaque étape, l’explication fournie était techniquement plausible et fausse. C’est le vrai enseignement du dossier : un diagnostic qui referme l’incident sans le reproduire n’est pas un diagnostic.

Timeline des incidents Therac-25 de 1985 à 1987


Qu’est-ce qu’une race condition ?

Une race condition est un bug qui se produit quand deux processus ou threads accèdent à une ressource partagée en même temps, et que le résultat final dépend de l’ordre d’exécution, qui n’est pas garanti. Le bug n’est pas reproductible de façon déterministe : il apparaît ou n’apparaît pas selon des facteurs de timing hors de ton contrôle.

Dans le cas du Therac-25, la mécanique est documentée jusqu’au niveau de la variable.

Le logiciel stockait le mode et l’énergie du traitement dans une structure partagée de deux octets appelée MEOS. L’octet de poids faible commandait la position physique de la tourelle, qui porte le filtre atténuateur. L’octet de poids fort servait à configurer les paramètres du faisceau. Deux tâches concurrentes touchaient à cette structure :

  • Le gestionnaire de clavier, qui la mettait à jour au fil des touches pressées.
  • La routine Datent, qui la lisait pour configurer la machine.

La fenêtre de vulnérabilité durait environ 8 secondes, le temps que les aimants de courbure se positionnent. Si l’opérateur modifiait le mode pendant ce délai, le gestionnaire de clavier pouvait signaler la saisie comme terminée alors que Datent était déjà sortie. La modification était bien enregistrée dans MEOS, mais plus personne ne la relisait.

Les deux octets se retrouvaient alors incohérents entre eux : la tourelle dans une position, les paramètres du faisceau dans une autre. Le logiciel ne contenait aucune vérification de cohérence entre les deux. La machine tirait à pleine énergie avec le filtre atténuateur hors du chemin du faisceau.

Ce que personne ne comprenait au début, c’est que les opérateurs expérimentés étaient plus exposés que les débutants. Il fallait taper vite pour rentrer dans la fenêtre. Les signalements décrivaient donc une erreur aléatoire, impossible à reproduire en laboratoire par quelqu’un qui saisissait prudemment.

Et quand la machine détectait un problème, elle affichait Malfunction 54, un code qui signifiait « dose input 2 », soit une dose trop élevée ou trop faible. Rien dans ce message ne distinguait les deux cas. Un opérateur voyant un code d’erreur sans conséquence apparente relançait le traitement, ce qui était le comportement normal sur cette machine.


Ce que le Therac-20 faisait et que le Therac-25 ne faisait plus

Le fait le plus dérangeant du dossier n’est pas que le logiciel contenait un bug. C’est que le Therac-20 contenait le même défaut logiciel sans jamais blesser personne. La différence tenait entièrement à des circuits matériels indépendants qui empêchaient physiquement le faisceau de partir dans une configuration incohérente.

Sur les modèles précédents, le logiciel n’était pas la dernière ligne de défense. Il était une ligne parmi d’autres.

Therac-20Therac-25
Surveillance du balayage du faisceauCircuits de protection matériels indépendantsLogiciel
Blocage d’une configuration incohérenteInterlocks mécaniquesLogiciel
Conséquence du même bug logicielFusible grillé, traitement interrompuSurdosage délivré au patient

AECL n’a pas décidé de rendre la machine dangereuse. Le raisonnement était l’inverse : le calculateur pouvait piloter et surveiller le matériel, donc dupliquer toutes les protections physiques devenait une dépense sans contrepartie. Le raisonnement est correct si et seulement si le logiciel est correct.

Traduit dans ton contexte, ça donne quelque chose de très concret. Chaque fois que tu remplaces une contrainte structurelle par une vérification applicative, tu fais le pari du Therac-25. Une contrainte d’unicité en base remplacée par un if avant l’insertion. Une permission gérée par row level security remplacée par un filtre côté client. Un quota imposé par l’API du fournisseur remplacé par un compteur dans ton code. Le pari est parfois raisonnable. Il n’est jamais gratuit, et il mérite d’être conscient.


Reproduire une race condition en Python

Le module threading de Python expose exactement ce type de vulnérabilité.

import threading
import time

# Variable partagée : simule le registre de configuration du Therac-25
mode_traitement = {
    "type": "basse_energie",
    "filtre_actif": True,
    "dose_max": 200
}

def modifier_mode(nouveau_mode: str):
    """Simule la tâche de saisie opérateur"""
    global mode_traitement
    mode_traitement["type"] = nouveau_mode
    time.sleep(0.0001)  # Fenêtre de vulnérabilité entre les deux lignes
    if nouveau_mode == "haute_energie":
        mode_traitement["filtre_actif"] = True
        mode_traitement["dose_max"] = 200
    else:
        mode_traitement["filtre_actif"] = True
        mode_traitement["dose_max"] = 100

def executer_traitement():
    """Simule la tâche d'exécution machine"""
    global mode_traitement
    # Lecture pendant la modification : état incohérent possible
    if mode_traitement["type"] == "haute_energie" and not mode_traitement["filtre_actif"]:
        print("DANGER : haute énergie sans filtre actif")
    else:
        dose = mode_traitement["dose_max"]
        print(f"Traitement : {mode_traitement['type']}, dose {dose} cGy")

# Simulation de la race condition
threads = []
for _ in range(100):
    t1 = threading.Thread(target=modifier_mode, args=("haute_energie",))
    t2 = threading.Thread(target=executer_traitement)
    threads.extend([t1, t2])
    t1.start()
    t2.start()

for t in threads:
    t.join()

Lance ce code plusieurs fois. Le résultat change à chaque exécution. Parfois tout se passe bien. Parfois executer_traitement lit le dictionnaire pendant que modifier_mode est en plein milieu de ses mises à jour, et l’état est incohérent.

C’est exactement le comportement que les ingénieurs d’AECL ne parvenaient pas à reproduire de façon fiable en laboratoire.

Diagramme de race condition : deux threads accédant à la variable partagée, état incohérent mis en évidence


Comment écrire du code sécurisé : les leçons du Therac-25

Les leçons du Therac-25 tiennent en trois principes : ne jamais supprimer une protection matérielle sans protection logicielle équivalente et auditée, protéger toute ressource partagée entre threads avec des verrous explicites, et traiter toute valeur inattendue comme une erreur bloquante plutôt que de la corriger silencieusement.

La correction immédiate pour une race condition en Python : threading.Lock.

import threading
import time

class ConfigurationMachine:
    def __init__(self):
        self._lock = threading.Lock()
        self._mode = "basse_energie"
        self._filtre_actif = True
        self._dose_max = 100
    
    def modifier_mode(self, nouveau_mode: str) -> None:
        with self._lock:
            # Tout se passe à l'intérieur du verrou : état toujours cohérent
            if nouveau_mode == "haute_energie":
                self._mode = nouveau_mode
                self._filtre_actif = True
                self._dose_max = 200
            elif nouveau_mode == "basse_energie":
                self._mode = nouveau_mode
                self._filtre_actif = True
                self._dose_max = 100
            else:
                raise ValueError(f"Mode inconnu : {nouveau_mode}. Traitement bloqué.")
    
    def executer_traitement(self) -> None:
        with self._lock:
            if self._mode == "haute_energie" and not self._filtre_actif:
                raise RuntimeError(
                    "Configuration dangereuse détectée : haute énergie sans filtre. "
                    "Traitement annulé."
                )
            print(f"Traitement : {self._mode}, filtre={self._filtre_actif}, dose={self._dose_max} cGy")

# Test
machine = ConfigurationMachine()
threads = []
for _ in range(100):
    t1 = threading.Thread(target=machine.modifier_mode, args=("haute_energie",))
    t2 = threading.Thread(target=machine.executer_traitement)
    threads.extend([t1, t2])
    t1.start()
    t2.start()

for t in threads:
    t.join()

Deuxième principe : les valeurs inattendues bloquent, elles ne passent pas silencieusement.

def valider_configuration(mode: str, filtre: bool, dose: int) -> None:
    """Toute configuration invalide lève une exception. Jamais de correction silencieuse."""
    modes_valides = {"basse_energie", "haute_energie"}
    if mode not in modes_valides:
        raise ValueError(f"Mode '{mode}' invalide. Modes acceptés : {modes_valides}")
    
    if mode == "haute_energie" and not filtre:
        raise ValueError("Haute énergie sans filtre : configuration interdite.")
    
    if dose <= 0 or dose > 300:
        raise ValueError(f"Dose {dose} cGy hors plage autorisée [1, 300].")

Tests de sécurité logicielle : par où commencer

Le Therac-25 n’avait pas de tests formels. Ses développeurs considéraient que l’expérience terrain sur les anciens modèles valait une suite de tests.

Ce n’est pas suffisant. Voici trois niveaux de tests que chaque projet devrait avoir.

Niveau 1 : tests des cas limites et des états invalides

import pytest
from machine import ConfigurationMachine

def test_mode_invalide_leve_exception():
    m = ConfigurationMachine()
    with pytest.raises(ValueError, match="Mode inconnu"):
        m.modifier_mode("mode_inventé")

def test_haute_energie_sans_filtre_bloque():
    m = ConfigurationMachine()
    m._mode = "haute_energie"
    m._filtre_actif = False
    with pytest.raises(RuntimeError, match="Configuration dangereuse"):
        m.executer_traitement()

def test_transition_valide_ne_leve_pas_exception():
    m = ConfigurationMachine()
    m.modifier_mode("haute_energie")
    m.executer_traitement()

Niveau 2 : tests de concurrence avec concurrent.futures

import pytest
from concurrent.futures import ThreadPoolExecutor
from machine import ConfigurationMachine

def test_pas_de_race_condition_sous_charge():
    machine = ConfigurationMachine()
    erreurs = []
    
    def cycle_complet():
        try:
            machine.modifier_mode("haute_energie")
            machine.executer_traitement()
            machine.modifier_mode("basse_energie")
        except RuntimeError as e:
            erreurs.append(str(e))
    
    with ThreadPoolExecutor(max_workers=20) as executor:
        futures = [executor.submit(cycle_complet) for _ in range(200)]
        for f in futures:
            f.result()
    
    assert len(erreurs) == 0, f"Race condition détectée : {erreurs}"

Niveau 3 : fuzzing sur les entrées

from hypothesis import given, strategies as st
from machine import valider_configuration

@given(
    mode=st.text(),
    filtre=st.booleans(),
    dose=st.integers()
)
def test_validation_ne_crashe_jamais_silencieusement(mode, filtre, dose):
    """Toute entrée invalide doit lever une exception, jamais passer silencieusement."""
    try:
        valider_configuration(mode, filtre, dose)
    except (ValueError, TypeError):
        pass  # Comportement attendu sur entrée invalide

Le fuzzing avec hypothesis génère automatiquement des centaines de cas limites auxquels tu n’aurais pas pensé. C’est la méthode qui se rapproche le plus de ce qu’aurait dû faire AECL avant de déployer le Therac-25.


Questions fréquentes

Qu’est-ce que le Therac-25 et pourquoi est-il important pour les développeurs ?

Le Therac-25 était une machine de radiothérapie impliquée dans six surdosages massifs entre juin 1985 et janvier 1987, causés par une race condition dans son logiciel. C’est l’un des cas les plus étudiés en génie logiciel parce qu’il illustre les conséquences concrètes d’un code sans tests formels, d’une confiance excessive dans l’héritage logiciel, et de la suppression de protections matérielles sans équivalent logiciel rigoureux.

Pourquoi le même bug était inoffensif sur le Therac-20 ?

Parce que le Therac-20 disposait de circuits de protection matériels indépendants du logiciel et d’interlocks mécaniques. Quand le défaut logiciel se manifestait sur ce modèle, ces circuits empêchaient le faisceau de s’activer : l’incident se soldait par un fusible grillé et un traitement interrompu. Sur le Therac-25, AECL avait considéré ces protections comme redondantes puisque le calculateur pouvait surveiller le matériel. Le logiciel était devenu la seule et dernière barrière.

Pourquoi le bug a-t-il mis deux ans à être identifié ?

Parce qu’il n’était pas reproductible par quelqu’un qui cherchait à le reproduire prudemment. La fenêtre de déclenchement ne s’ouvrait que si l’opérateur modifiait le mode de traitement pendant les 8 secondes de positionnement des aimants, donc seulement s’il tapait vite. Chaque incident a par ailleurs reçu d’AECL une explication technique plausible et fausse, comme la défaillance d’un microswitch après l’accident de Hamilton. Le lien avec le logiciel n’a été établi qu’après qu’un physicien de Tyler a reproduit lui même la séquence de touches.

Qu’est-ce qu’une race condition en Python et comment la détecter ?

Une race condition survient quand deux threads accèdent à une ressource partagée en même temps sans synchronisation. En Python, le module threading y expose directement. La détection passe par des tests de concurrence avec ThreadPoolExecutor, des outils comme ThreadSanitizer pour les extensions C, et des revues de code ciblant toute variable globale ou partagée modifiée dans plusieurs threads.

Comment threading.Lock protège contre les race conditions ?

threading.Lock garantit qu’une seule thread peut exécuter le bloc de code protégé à la fois. Toutes les autres threads qui tentent d’entrer dans le même bloc attendent que le verrou soit libéré. Cela transforme une opération de lecture-modification-écriture en opération atomique, éliminant la fenêtre de vulnérabilité où l’état est incohérent.

Le GIL Python protège-t-il contre les race conditions ?

Non. Le GIL de Python empêche l’exécution parallèle du bytecode Python sur plusieurs coeurs, mais il n’empêche pas les race conditions. Le GIL est relâché lors des opérations I/O et peut être relâché entre deux instructions Python. Des opérations qui semblent atomiques, comme dict["key"] = value, ne le sont pas nécessairement. Il faut utiliser threading.Lock explicitement.

Comment écrire du code sécurisé en Python pour des applications critiques ?

Trois règles fondamentales issues du Therac-25 : protéger toute ressource partagée entre threads avec des verrous explicites, traiter toute valeur inattendue comme une erreur bloquante plutôt que de la corriger silencieusement, et tester systématiquement les cas limites et les états invalides avant de tester les scénarios normaux. Pour les applications médicales ou financières, ajouter du fuzzing avec hypothesis et des tests de stress sous charge concurrente.


Ce que le Therac-25 change à ta façon de coder

Le Therac-25 n’était pas un projet bâclé par des développeurs incompétents. C’était un projet développé par des ingénieurs expérimentés qui faisaient confiance à leur code parce qu’il ressemblait à du code qui avait déjà fonctionné.

C’est précisément là que ça a mal tourné.

La confiance aveugle dans l’héritage, l’absence de tests formels sur les cas limites, la suppression de protections « redondantes » sans audit rigoureux. Ces trois erreurs sont reproductibles dans n’importe quel projet, à n’importe quelle échelle.

Si tu construis quelque chose que des gens utilisent, voici ce que je retiens :

  1. Toute ressource partagée entre threads prend un verrou. Pas d’exception.
  2. Les valeurs inattendues lèvent des exceptions explicites. Jamais de correction silencieuse.
  3. Les tests de cas limites passent avant les tests de scénarios normaux.

Le code parfait n’existe pas. Mais du code qui échoue bruyamment sur les entrées invalides, plutôt que silencieusement sur les états incohérents, c’est déjà une différence entre un bug visible et un bug qui tue.

Et si tu ne devais retenir qu’une question de tout ce dossier, prends celle du physicien de Tyler : est-ce que quelqu’un a reproduit le problème, ou est-ce qu’on s’est contenté d’une explication qui tenait debout ?

Dernière mise à jour : juin 2026

Cet article fait partie de la série 10 bugs informatiques qui ont tué, crashé et coûté des milliards.