Présent ne veut pas dire chargeable
Un paquet peut être dans l’archive, à la bonne version. Rien ne manque sur le disque. Le code qui l’importe ne le trouve quand même pas. Tous nos contrôles d’empaquetage étaient aveugles à cette classe de bug.
Nous l’avons appris de la façon habituelle. Mnemosyne OS v1.3.7 est sortie le 10 août avec une promesse simple : toute la boucle tourne sur votre machine. Modèle, mémoire, recherche, sans clé, sans compte. Dans l’application installée, elle ne chargeait aucun modèle local.
Ce billet raconte comment la version a franchi tous nos garde-fous et s’est
quand même publiée cassée. Il nomme les trois défauts. Il décrit le test de dix
secondes qui tourne désormais entre un paquet et sa signature. Si vous avez
déjà vu un ERR_MODULE_NOT_FOUND n’apparaître qu’en production, vous
connaissez déjà la fin. L’intéressant est de comprendre pourquoi aucun de nos
contrôles ne pouvait le voir venir.
Ce que cette version apportait
La 1.3.7 comptait à cause d’un seul chiffre. Ce chiffre abîmait en silence l’expérience locale. Une machine à 4 Go choisit par défaut le modèle local d’entrée de gamme. Avant cette version, ce modèle était Phi-3 Mini, avec une fenêtre de contexte de 4096 tokens. Mnemosyne OS ancre ses réponses dans les souvenirs qu’elle retrouve, et ces souvenirs font partie du prompt. Dès la première question ancrée en mémoire, le contexte débordait. La promesse « ça marche hors ligne » était vraie sur le papier et fausse en pratique.
Le catalogue local rafraîchi embarque 8 modèles avec des fenêtres de contexte de 262k tokens. Cette version repose sur ce contraste : de 4k à 262k. Ce n’est pas un argument de performance. Un petit modèle local se souvient toujours moins bien qu’un gros modèle cloud, et nous n’allons pas prétendre le contraire. Mais une fenêtre de 262k peut contenir vos souvenirs dans le prompt. Une fenêtre de 4k n’y arrivait pas.
La même version corrige trois autres points :
- En BYOK, la liste des modèles vient désormais du fournisseur lui-même. Six fournisseurs étaient morts parce que nous passions un identifiant de catalogue là où l’API attendait un nom de modèle. L’endpoint ne connaît pas nos identifiants internes.
- Un journal local du coût de chaque appel, aux tarifs que vous saisissez vous-même. L’application n’embarque aucune table de prix. Les prix changent, et une table périmée qui a l’air fiable est pire que pas de table du tout.
- La gouvernance des coffres : marquer un coffre MAXIMUM tient enfin réellement la route cloud fermée.
Le premier clic
Puis l’application installée a tenté de charger un modèle :
Cannot find package 'lifecycle-utils' imported from
…/app.asar.unpacked/node_modules/node-llama-cpp/dist/index.js
Nous avons trouvé trois défauts. Tous les trois ont la même forme : quelque chose est présent, mais pas là où le code qui en a besoin va le chercher.
1. asarUnpack couvrait le paquet, pas sa fermeture de dépendances
Nous dépaquetons node-llama-cpp parce qu’il embarque des binaires natifs.
Dépaqueté, son dist/index.js s’exécute depuis app.asar.unpacked et résout
ses imports sur le vrai système de fichiers. Il ne peut jamais atteindre une
dépendance restée dans app.asar. Si vous dépaquetez un paquet sans son arbre
transitif, le module échoue au premier maillon resté empaqueté.
Cette règle exacte était déjà écrite dans le même fichier de config, douze lignes plus haut, pour la fermeture d’un autre paquet. Personne ne l’avait appliquée ici. Quatre-vingt-huit entrées manquaient. Chacune pouvait être déduite du graphe résolu.
2. Le script d’aplatissement pnpm gardait une version et jetait les autres en silence
Notre script aplatit un store pnpm en un node_modules ordinaire pour
l’empaquetage. Quand deux versions d’un même nom existaient, il gardait la
première rencontrée et comptait toutes les autres comme « déjà présente ».
L’ordre des répertoires décidait, c’est-à-dire l’alphabet.
signal-exit@3.0.7 (CommonJS) a battu 4.1.0 (ESM). Le consommateur qui a
besoin de l’export nommé de la v4 est mort avec :
Named export 'onExit' not found
Les deux consommateurs avaient raison. L’un exige ^3, l’autre ^4, et aucune
version unique ne peut servir les deux. Le correctif est ce que npm fait depuis
toujours : imbriquer la version satisfaisante sous chaque dépendant. Le script
dit aussi désormais ce qu’il a imbriqué, au lieu d’incrémenter un compteur
muet. Le premier passage a signalé 8 conflits. L’un d’eux était
node-llama-cpp, qui réclamait un lifecycle-utils plus récent que celui
gardé par l’arbre aplati.
3. Une fermeture calculée avant l’imbrication rate ce que l’imbrication introduit
L’imbrication d’un paquet en v7 a tiré une dépendance transitive qu’aucun passage précédent n’avait vue. Cette dépendance est restée empaquetée. Ce défaut est apparu après la correction des défauts 1 et 2, alors que le binaire avait été déclaré bon. Il est apparu deux fois.
Tous les garde-fous demandaient « le fichier est-il là ? »
C’est la vraie leçon, et elle dépasse largement Electron.
La 1.3.7 a passé un contrôle d’asar scellé, un contrôle de taille et un contrôle de signature. Chacun posait une question de présence. Aucun ne demandait si la chose se charge. Présence et résolvabilité sont deux propriétés différentes. Les bugs d’empaquetage vivent dans l’écart entre les deux.
Nous avons donc écrit le contrôle qui pose l’autre question. Il importe le point
d’entrée natif depuis le paquet construit, exactement comme le fera
Electron. C’est une résolution ESM sur le vrai système de fichiers, la même
résolution qui ne peut pas atteindre une dépendance restée prisonnière de
l’archive. Le contrôle prend dix secondes. Il ne demande aucune installation ni
GPU, parce qu’importer le module ne charge pas de modèle. Une sortie non nulle
fait échouer le job, avant la signature. Il est câblé dans les trois jobs
de compilation (Windows, macOS, Linux). Il localise app.asar.unpacked en
cherchant dans le répertoire de release, parce que les trois plateformes
l’enterrent à trois endroits différents. Un chemin codé en dur par job serait à
un renommage près d’un garde-fou qui ne vérifie rien, en silence.
Le test négatif reproduit l’erreur publiée au mot près. Nous voulions voir le garde-fou échouer une fois avant de lui faire confiance.
Ce que nous avons fait de la version déjà en ligne
Nous avons basculé la release en pre-release environ vingt minutes après sa publication. Notre updater ignore les pre-releases. À partir de cet instant, aucune installation ne s’est vu proposer le binaire cassé. Les artefacts macOS et Linux ont été téléchargés zéro fois avant leur retrait.
Nous avons depuis reconstruit tous les installeurs avec les trois défauts corrigés et le test d’import en place. La 1.3.7 est de nouveau publique.
Ce que nous avons vu pendant que la machine étouffait
La même semaine nous a donné une autre observation, et elle va dans l’autre sens.
Une seule machine à 31 Go avec une RTX 4050 faisait tourner tout ceci en même temps. Une longue passe de benchmark était en cours. L’OS lui-même avait un modèle 2B résident sur le GPU. 26 coffres se montaient, le pipeline d’embedding travaillait, et des compilations Electron tournaient. Il restait environ 5 Go de RAM. Les logs montrent ce que ça coûte :
event-loop stall: 34355ms
metrics.worker: no reply within 10000ms
La machine était étranglée et l’inférence en a souffert. Elle ne tournait pas confortablement sous charge. Ce qui nous intéresse, c’est comment elle a souffert. L’échantillonneur de métriques a abandonné en le disant. Les coffres ont continué à se monter. L’inférence est allée au bout, et la réponse est arrivée. Le système a dégradé lisiblement au lieu de mourir. Mnemosyne OS promet que vous pouvez vérifier ce qu’elle retient. Sa façon d’échouer fait donc partie du produit.
Un test à ajouter à votre propre compilation
Si vous empaquetez un module natif dans une application de bureau, ajoutez un test. Il importe votre vrai point d’entrée depuis l’artefact que vous vous apprêtez à signer, dans le même système de modules que votre runtime. Une liste de fichiers ne fait pas ça. Un import le fait.
Le nôtre coûte dix secondes et aurait attrapé les trois défauts.
Mnemosyne OS est un OS de mémoire local-first. Le binaire courant est sur la page de téléchargement.