Un fichier de configuration qui fonctionnait il y a cinq secondes n'est plus qu'un fragment de JSON corrompu de 347 octets. Votre outil CLI ne démarre pas. L'utilisateur, qui a simplement appuyé sur Ctrl-C parce que la mise à jour prenait plus de temps que prévu, se retrouve face à une trace de pile d'erreur qu'il n'a pas demandée. Les deux kilo-octets de configuration valide qui existaient avant l'écriture ont disparu, remplacés par l'équivalent numérique d'un ticket de caisse à moitié imprimé.

Cela se produit parce que writeFile n'est pas atomique. Il ouvre le chemin existant, le tronque, transmet les données de Node vers le cache de pages du noyau, et finit par fermer le descripteur de fichier. Dès que la troncature a lieu, l'ancien contenu a déjà disparu. Tout ce qui se passe entre cette troncature et le close final constitue une fenêtre de vulnérabilité. Un SIGINT, une coupure de courant ou le couvercle d'un ordinateur portable qui se referme brusquement pendant cette fenêtre laisse le système de fichiers avec un désordre tronqué. Même si Node signale que la Promise est résolue, le système d'exploitation peut encore mettre les écritures en mémoire tampon. Les méthodes de commodité cachent cet écart, mais ne l'éliminent pas.

La solution n'est pas d'écrire sur place. La solution consiste à séparer l'acte d'écriture de l'acte de publication.

Écrire dans un fichier frère, puis échanger

Le modèle fiable comporte cinq étapes. Aucune d'entre elles n'est compliquée, mais ensemble, elles réduisent la fenêtre d'échec d'une écriture par flux complète à une simple opération de métadonnées du système de fichiers.

Premièrement, sérialisez l'intégralité de la charge utile en mémoire. Faites cela avant de créer tout fichier temporaire. Si JSON.stringify lève une exception parce que quelqu'un a passé un objet circulaire, vous voulez que cette exception remonte avant de toucher au disque.

Deuxièmement, écrivez les données sérialisées dans un fichier temporaire situé dans le même répertoire que la cible. Utilisez un nom aléatoire pour que deux exécutions simultanées n'entrent pas en collision. Garder le fichier temporaire dans le même répertoire est important car rename n'est atomique qu'au sein d'un seul système de fichiers. Si votre fichier temporaire se trouve sur une partition différente, le système d'exploitation se rabat sur une séquence de copie et de suppression, ce qui introduit ses propres modes d'échec et n'est plus atomique.

Troisièmement, demandez au noyau de vider ce fichier temporaire vers le stockage physique. Le fsync de Node, exposé ici via la méthode sync sur un filehandle, bloque jusqu'à ce que les tampons soient écrits sur le matériel. C'est lent, mais les écritures de configuration sont suffisamment rares pour que la durabilité en vaille les millisecondes.

Quatrièmement, renommez le fichier temporaire sur le chemin d'origine. Sur les systèmes POSIX comme sur Windows, il s'agit du point de validation. Les lecteurs ouvrant le chemin d'origine verront soit le fichier ancien complet, soit le nouveau fichier complet. Il n'y a aucun moment où un lecteur peut ouvrir le chemin et observer un tampon à moitié écrit.

Cinquièmement, synchronisez le répertoire parent. Cela permet de gérer un cas particulier subtil. Le renommage met à jour l'entrée du répertoire, mais les métadonnées du répertoire lui-même peuvent résider dans le cache de pages du noyau. Une coupure de courant soudaine après un renommage réussi peut parfois laisser le système de fichiers dans un état où la nouvelle référence d'inode n'a jamais été enregistrée de manière durable. La synchronisation du répertoire force cette mise à jour des métadonnées sur le disque et scelle la transaction.

Une implémentation Node.js concrète

Voici à quoi ressemble ce modèle en pratique en utilisant uniquement la bibliothèque standard de Node.js :

import { open, rename, rm } from "node:fs/promises";
import { dirname, basename, join } from "node:path";
import { randomUUID } from "node:crypto";

export async function writeJsonAtomic(path, value) {
  const directory = dirname(path);
  const temporary = join(directory, `.${basename(path)}.${randomUUID()}.tmp`);
  const body = `${JSON.stringify(value, null, 2)}\n`;
  let handle;

  try {
    handle = await open(temporary, "wx", 0o600);
    await handle.writeFile(body, "utf8");
    await handle.sync();
    await handle.close();
    handle = undefined;

    await rename(temporary, path);

    const directoryHandle = await open(directory, "r");
    try {
      await directoryHandle.sync();
    } finally {
      await directoryHandle.close();
    }
  } catch (error) {
    if (handle) await handle.close().catch(() => {});
    await rm(temporary, { force: true }).catch(() => {});
    throw error;
  }
}

Quelques détails dans ce code méritent votre attention.

Le flag wx signifie « écrire, mais échouer si le fichier existe déjà ». Cela protège contre une collision d'UUID ou un fichier temporaire abandonné provenant d'un processus précédent ayant planté. Si quelqu'un a déposé un fichier malveillant là où votre fichier temporaire devrait se trouver, vous en serez informé immédiatement au lieu d'écraser ce qui s'y trouve.

Le masque de permission 0o600 crée le fichier temporaire avec uniquement les droits de lecture et d'écriture pour le propriétaire. Les fichiers de configuration contiennent fréquemment des secrets, des jetons d'API ou des URL de dépôts privés. Il n'y a aucune raison de laisser d'autres utilisateurs du système jeter un œil au fichier temporaire pendant qu'il est préparé.

Notez les appels sync distincts sur le fichier, puis sur le répertoire. De nombreux développeurs sautent la synchronisation du répertoire car elle semble redondante. Elle ne l'est pas. Ext4, APFS et NTFS gèrent tous les mises à jour de répertoire différemment, mais ils partagent une habitude commune de regrouper les écritures de métadonnées pour des raisons de performance. Si vous tenez à survivre à une coupure de courant, la synchronisation du répertoire est le sceau final.

Le nettoyage dans le bloc catch est intentionnellement défensif. Si une erreur survient après l'ouverture du descripteur de fichier, le code tente de fermer le descripteur et de supprimer le fichier temporaire, en ignorant toute erreur secondaire afin que l'exception d'origine se propage proprement. Vous ne voulez pas qu'une erreur de permission lors du nettoyage masque le véritable bug qui a causé l'échec.

Là où ce modèle cesse d'être utile

Le remplacement atomique de fichier empêche les écritures partielles. Il n'empêche pas les mises à jour perdues. Si deux instances de votre CLI lisent la même configuration simultanément, effectuent toutes deux leurs modifications en mémoire, écrivent toutes deux de nouveaux fichiers temporaires et exécutent toutes deux leurs renommages, le second renommage l'emporte. Le premier processus n'a pas observé les changements du second. Selon votre application, cela pourrait signifier qu'un utilisateur ajoute un paramètre dans un terminal et qu'un autre utilisateur le supprime dans un autre, le fichier final ne reflétant que le dernier écrivain.

Si votre outil doit prendre en charge des mutateurs concurrents, vous avez besoin d'un mécanisme de coordination au-dessus de l'écriture atomique. Un fichier de verrouillage consultatif (advisory lock) fonctionne pour les cas simples. Des vecteurs de version ou un numéro de révision monotone à l'intérieur de la configuration elle-même peuvent aider à détecter les collisions afin que le second écrivain puisse réessayer. Cela ajoute de la complexité, et c'est dans la complexité que les bugs se cachent.

C'est pourquoi la limite est importante. Un simple bloc JSON qu'un processus met à jour occasionnellement est un bon candidat pour une écriture de fichier atomique. Dès que vous vous retrouvez à gérer plusieurs enregistrements, à imposer des schémas ou à vous soucier des mutations concurrentes, vous avez dépassé les capacités du système de fichiers. SQLite existe précisément pour cette raison. Il vous offre des transactions atomiques, des journaux de rollback et une gestion appropriée des lecteurs et écrivains concurrents, le tout à l'intérieur d'un seul fichier local. Un protocole de fichier ingénieux n'est pas une base de données, et vous ne devriez pas dépenser votre budget de maintenance en prétendant le contraire.

Ce qu'il faut vraiment retenir

La prochaine fois que vous utiliserez writeFile dans un outil CLI, faites une pause. La sérialisation n'est pas la partie difficile. C'est la durabilité qui l'est. Les fichiers de configuration sont trop petits pour être traités en flux et trop importants pour être tronqués. Écrivez l'intégralité de la charge utile dans un fichier frère caché, videz le tampon (flush), validez-le avec un renommage et informez le répertoire de ce changement. Vos utilisateurs peuvent appuyer sur Ctrl-C, débrancher la prise ou fermer le capot de leur ordinateur portable. Lorsque la machine redémarrera, le fichier contiendra soit l'ancien état, soit le nouveau. Rien entre les deux.