Le jour où j'ai laissé 3 200 € de monnaie virtuelle s'évaporer en prod
OneRP — ma plateforme multijoueur — tournait avec 247 joueurs connectés. Un matin, on découvre 3 200 € de monnaie créée par bug. Pas volée. Créée. Voici l'incident, la nuit blanche, et la décision qui a changé ma façon de bâtir des produits seniors.
Cet article décortique le projetOneRP06:14 du matin, un samedi
Mon téléphone sonne. C'est Marc, mon co-modérateur sur OneRP — la plateforme de roleplay multijoueur que je maintiens depuis 18 mois.
"Florian, on a un problème. La banque a 3 200 € de plus que ce qu'elle devrait."
Je suis à moitié réveillé. Je demande "3 200 € volés ?". Il dit non. Les comptes des joueurs sont OK. C'est de la monnaie qui n'aurait jamais dû exister.
Je me lève. Je connecte mon laptop. La prod tourne avec 247 joueurs. Les transactions défilent en temps réel sur Grafana. Tout a l'air normal.
Et pourtant 3 200 € sont apparus dans le système entre 02:00 et 05:00 du matin.
Le bug
J'ai mis 4 heures à le trouver. C'était un classique de la concurrence dont je connaissais la théorie depuis 10 ans, mais que je n'avais jamais vu en vrai sur mon code.
Un joueur fait un virement à 02:33. Un autre fait un virement à 02:33.000123. Les deux transactions lisent la même valeur de solde, soustraient chacune leur montant, écrivent leur résultat. L'une des deux écritures écrase l'autre.
Le solde du compte source est correct (on a soustrait deux fois). Le solde des deux comptes destinataires est correct (chacun a reçu son montant). Total système : +X € créés à partir de rien.
C'est le double-spend. Le bug que toute fintech connaît. Sauf que je n'avais jamais pensé devoir m'en méfier sur un serveur de jeu.
Pourquoi ? Parce que techniquement, mon code était "correct" :
public async Task TransferAsync(string fromId, string toId, decimal amount)
{
var from = await _db.Players.FirstAsync(p => p.Id == fromId);
var to = await _db.Players.FirstAsync(p => p.Id == toId);
from.Cash -= amount;
to.Cash += amount;
await _db.SaveChangesAsync();
}
Ça compile. Ça passe les tests. Ça marche 99,9 % du temps. Et 1 fois sur 10 000, ça crée de la monnaie.
À 14 200 transactions par jour, "1 sur 10 000", c'est un incident par semaine.
La nuit où j'ai compris
Le samedi soir, j'ai patché en mettant l'isolation Serializable sur les transactions
monétaires. Cinq lignes de code. L'incident a été contenu.
Mais le dimanche, je me suis posé la vraie question :
Combien d'autres patterns pareils sont en train de tuer ma prod en silence ?
J'ai fait l'audit. J'ai trouvé :
- 11 méthodes qui muteraient
Cash,Bank,WalletouTreasurysans isolation. - 4 méthodes qui faisaient
TriggerClientEventaprès unawait— le runtime FiveM ne déclenchait jamais l'event. Les joueurs ne recevaient pas leurs notifications. - 3 méthodes qui itéraient un
ConcurrentDictionarypendant qu'un autre thread le mutait. Crash JIT possible, juste pas reproductible en dev.
Et personne dans l'équipe ne savait que ces patterns étaient dangereux.
La décision
Deux options :
- Écrire un guide "voici 17 règles à respecter". Les gens lisent les 3 premières et oublient.
- Encoder les règles dans le compilateur. Le build casse si on les viole. Personne ne les ignore.
J'ai choisi (2). En 3 jours, j'ai écrit 17 analyseurs Roslyn custom qui interceptent chacun de ces patterns au moment où le code est tapé dans l'IDE.
Aujourd'hui, quand un développeur écrit :
player.Cash -= 500;
await _db.SaveChangesAsync();
Son éditeur souligne la ligne en rouge et affiche :
ONERP017 — MutateAsync touchant 'Cash' doit utiliser MutateSerializableAsync
pour éviter les races read-then-write (double-spend)
Le code ne compile pas. Le PR ne merge pas. Le bug ne peut plus exister.
Ce que ça change
Avant les analyseurs (6 mois de prod) :
- 11 crashs serveur liés à du code "qui marchait en dev".
- 4 incidents "le joueur n'a pas reçu son objet" — irrécupérables.
- 2 cas de double-spend, 3 200 € au total.
Après (6 mois) :
- 0 crash.
- 0 incident notif.
- 0 double-spend.
Coût total des 17 règles : 3 jours de mon temps. Le retour sur investissement a été atteint en 4 semaines.
Pourquoi je raconte cette histoire
Parce que c'est ce moment-là — pas un design pattern, pas une discussion d'archi chez Amazon, pas un livre — qui m'a transformé en développeur senior.
Le développeur junior croit qu'il suffit que le code "marche" pour qu'il soit bon.
Le développeur intermédiaire ajoute des tests.
Le développeur senior comprend que certains bugs ne peuvent pas être attrapés par des tests. Pas parce qu'il manque des cas. Parce que l'ordre exact dans lequel les threads s'exécutent en prod n'est pas reproductible en environnement de dev.
Pour ces bugs-là, il faut bouger la défense plus à gauche. Pas dans les tests. Pas dans le code review. Dans le compilateur.
C'est exactement le boulot d'un analyseur Roslyn.
Ce que je fais différemment depuis
Sur chaque nouveau projet, je commence par identifier les patterns dangereux propres au contexte — pas les bugs Java/Python génériques, les bugs spécifiques à ce runtime, cette DB, ce framework.
Sur SaleCast (e-commerce ML.NET), j'ai écrit un analyseur qui interdit AsNoTracking
sur les entités monétaires — sinon EF Core ne détecte pas les changements concurrents.
Sur PromptVault (extension navigateur), j'ai écrit un analyseur qui interdit
localStorage.setItem sans préfixe de tenant — sinon les données fuitent entre
extensions de navigateur.
Sur Racine (app familiale), j'ai écrit un analyseur qui force chaque requête EF Core
à filtrer par FamilyId — sinon une famille peut voir les données d'une autre.
Trois projets, trois contextes, trois jeux de règles différents. Aucun n'a eu d'incident de production lié aux patterns que j'ai compile-time-enforced.
La leçon
Le bon développeur écrit du code correct. Le développeur senior écrit du code qui rend l'incorrect impossible à écrire.
Mes 17 règles sur OneRP coûtent 3 jours et un fichier .csproj. Elles évitent
des semaines de debug en prod à 2 heures du matin.
Si je devais ne garder qu'un signal pour évaluer un dev senior dans un entretien, ce serait celui-là : "Raconte-moi un bug que tu as rendu impossible à écrire."
Ceux qui peuvent répondre sont ceux qu'on veut embaucher.
Florian Sola
Lead Technique · Haute performance temps réel · 9 ans d'expérience