Retour au journal de dev Rapport de piège

Codable rétro-tolérant : les vieux clients ne doivent jamais détruire le nouveau

SharkTTY synchronise vos hôtes SSH enregistrés entre appareils. En ajoutant un nouveau type de connexion à l'app, nous nous sommes posé une question dont la réponse s'est révélée inconfortable : que se passe-t-il quand un enregistrement écrit par la nouvelle app atteint un appareil qui fait encore tourner l'ancienne ? Pour un montage Swift Codable parfaitement ordinaire, la réponse était : la synchronisation de l'ancien appareil gèle en silence — et si vous « réparez » cela de la façon évidente, c'est pire : l'ancien appareil se met à détruire les données du nouveau. Aucune de ces deux pannes n'exige de code exotique. Les deux sortent tout droit des comportements par défaut.

Panne un : l'enum qui refuse l'avenir

Le montage que toute base de code Swift possède quelque part :

enum ConnectionMethod: String, Codable { case ssh, mosh }

struct Host: Codable {
    let id: UUID
    var name: String
    var connectionMethod: ConnectionMethod
    // …
}

// quelque part dans la couche de synchro :
guard let hosts = try? decoder.decode([Host].self, from: doc) else { return }

Un enum Codable à raw String est un monde clos : une valeur inconnue ne se replie pas, elle lève une erreur. Le jour où une app plus récente écrit "connectionMethod": "et", chaque ancien lecteur lève sur cet enregistrement — et comme la liste entière se décode d'un bloc, un enregistrement illisible signifie aucun enregistrement. Le try? avale l'erreur, l'import retourne aussitôt, et l'appareil garde à jamais sa liste périmée. Pas de crash, pas de ligne de log, pas de bandeau. La synchro cesse simplement… d'être une synchro, sur chaque appareil pas encore à jour — c'est-à-dire, pendant tout déploiement réel, la plupart d'entre eux.

Panne deux : le décodeur tolérant qui détruit les données

Le réflexe est un décodage indulgent — valeur inconnue, valeur par défaut :

let raw = try c.decodeIfPresent(String.self, forKey: .connectionMethod)
connectionMethod = raw.flatMap(ConnectionMethod.init(rawValue:)) ?? .ssh   // tolérant… et avec perte

Cela guérit le gel et pose une mine. Les systèmes de synchro ré-encodent : la vieille app modifie un autre hôte, sérialise sa liste complète et l'envoie — last-writer-wins. Mais elle avait décodé "et" en .ssh, c'est donc cela qu'elle réécrit. Et les champs pour lesquels elle n'a jamais eu de propriétés — les clés supplémentaires du nouvel enregistrement — ont été jetés par Codable au décodage : ils ne partent pas non plus dans l'envoi. Une seule modification sur un seul appareil en retard, et la configuration de l'appareil plus récent est réécrite en douce dans l'ancienne forme, sur tout le compte. Le gel, au moins, préservait les données. Le décodeur tolérant les efface, poliment.

Le contrat : le vieux code ne doit jamais détruire ce que le nouveau a écrit

Protobuf a réglé cela il y a des décennies avec la préservation des champs inconnus ; le Codable de Swift vous donne les outils, pas le comportement par défaut. Deux gestes le recréent.

Stocker la vérité du câble, exposer la vue typée. Chaque champ enum garde sa chaîne brute comme propriété stockée ; l'enum typé devient une vue calculée avec un repli réservé à l'affichage. Lire une valeur inconnue dégrade avec grâce ; la réécrire la reproduit à l'identique. Seule une modification délibérée de l'utilisateur — le setter — l'écrase :

private(set) var connectionMethodRaw: String
var connectionMethod: ConnectionMethod {
    get { ConnectionMethod(rawValue: connectionMethodRaw) ?? .ssh }  // repli pour l'AFFICHAGE
    set { connectionMethodRaw = newValue.rawValue }                  // les éditions délibérées gagnent
}

Garder les clés qu'on ne comprend pas. Une seconde passe avec un conteneur à clés chaînes ramasse chaque clé qui n'est pas à vous dans un sac de JSONValue (un petit enum à sept cas : null, bool, int, double, string, array, object — décodez Int64 avant Double, sinon vos ports reviennent en flottants). encode(to:) écrit vos champs connus, puis rejoue le sac tel quel :

// décoder : ramasser l'avenir
let known = Set(CodingKeys.allCases.map(\.rawValue))
let any = try decoder.container(keyedBy: AnyCodingKey.self)
for key in any.allKeys where !known.contains(key.stringValue) {
    unknownFields[key.stringValue] = try any.decode(JSONValue.self, forKey: key)
}

// encoder : rendre l'avenir, intact
var any = encoder.container(keyedBy: AnyCodingKey.self)
for (key, value) in unknownFields {
    try any.encode(value, forKey: AnyCodingKey(stringValue: key))
}

Les deux pièces en place, un vieux client devient un conduit sûr : il affiche ce qu'il peut, fait transiter le reste sans y toucher, et une flotte de versions mélangées — l'état normal du monde pendant des semaines autour de chaque sortie — cesse d'être un danger.

Verrouiller le contrat par les tests

Trois tests portent tout le contrat. Un fixture aller-retour : un enregistrement d'une version « future » — valeur d'enum inconnue, scalaire inconnu, objet imbriqué inconnu — doit se décoder sans erreur et se ré-encoder avec chaque clé d'origine intacte. Un test d'édition délibérée : affecter la propriété typée doit écraser la valeur brute pendant que les clés inconnues survivent. Et un test étalon : un enregistrement aux seuls champs connus doit s'encoder au bit près comme la sortie de l'ancien code — pas de clé unknownFields, pas de noms de stockage …Raw qui fuient sur le câble. C'est ce dernier qui permet de refactoriser l'intérieur sans bifurquer le format en douce.

Une mise en garde honnête pour finir : rien de tout cela ne répare les clients déjà expédiés sans ces protections. Leurs décodeurs refusent toujours l'avenir. La migration est ennuyeuse et incontournable — livrez d'abord les décodeurs tolérants et préservants, laissez-les se déployer, et alors seulement laissez circuler les nouvelles valeurs. L'évolution de schéma est un problème d'ordonnancement déguisé en sérialisation.

Ce durcissement arrive sur toute la couche de synchronisation de SharkTTY dans la prochaine mise à jour. Pas d'accord avec l'approche, ou brûlé autrement ? Le tableau de retours est ouvert.