From 234a183abf39051a4bb65f050071fe905d872bff Mon Sep 17 00:00:00 2001 From: "Fabien F." <8136686+lostmind84@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:37:44 +0200 Subject: [PATCH] fix(dev): un premier lancement qui echoue le dit, et se rattrape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le premier lancement de dev.sh est mort pendant postCreateCommand, sur npm ci. Le message n'etait ni dans la sortie du script ni dans celle de la CLI, mais dans ~/.npm/_logs du conteneur : spawnSync .../esbuild/bin/esbuild ETXTBSY. Le postinstall d'esbuild pose un lien dur vers le binaire de plateforme que npm vient d'extraire puis l'execute, pendant que la phase reify de npm peut encore tenir cet inode ouvert en ecriture -- et Linux refuse d'executer un fichier ouvert en ecriture. C'est une course entre deux morceaux de npm, elle ne dit rien du package-lock.json, et elle frappe un volume web/node_modules NEUF : le premier lancement d'un contributeur. post-create.sh retente donc npm ci une seule fois, et seulement si la sortie nomme ETXTBSY. Tout autre echec -- un lock desynchronise au premier chef -- sort a la premiere tentative, ce qui garde a `ci` le caractere deterministe que 14.1 lui demande. Le second defaut est le plus grave, et il survit a la panne : la CLI n'execute postCreateCommand qu'a la CREATION du conteneur. Le conteneur a moitie prepare reste en place, le devcontainer up suivant repond {"outcome":"success"} sans rien preparer, et dev.sh annonce alors « Poste pret » sur un web/node_modules vide. dev.sh et dev.ps1 nomment desormais la sortie de secours au moment ou ils echouent (--remove-existing-container), portee des deux cotes selon la regle de report du 10/08/2026, et handbook/getting-started.md la porte aussi. Deux bancs tiennent les deux moities, rouges avant le correctif. Les deux branches du rattrapage sont exercees avec un faux npm : echec ETXTBSY, deux appels et sortie 0 ; echec de lock desynchronise, un appel, aucune reprise, sortie 1. Conteneur et volume node_modules detruits, sh dev.sh rejoue de zero : sortie 0. make test vert dans le conteneur neuf -- 3 135 tests verts, 10 ecartes, 0 echec -- et make front-check a 80 585 o gzip. --- .devcontainer/post-create.sh | 31 ++++++++++++++++++++++++++++++- SUIVI.md | 1 + deploy/devcontainer_test.go | 32 ++++++++++++++++++++++++++++++++ deploy/parity_test.go | 24 ++++++++++++++++++++++++ dev.ps1 | 11 +++++++++++ dev.sh | 16 +++++++++++++++- handbook/getting-started.md | 13 +++++++++++++ 7 files changed, 126 insertions(+), 2 deletions(-) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 5d63408..5cf12bb 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -49,7 +49,36 @@ pip install --no-cache-dir -r handbook/requirements.txt # `npm ci` et non `npm install` : les versions sont gelées dans package-lock.json (§14.1), # et `ci` est DÉTERMINISTE — il ÉCHOUE sur un lock désynchronisé au lieu de le réparer # en silence, là où `install` l'aurait accepté et modifié sans le dire. -npm --prefix web ci +# +# La seconde tentative ne couvre PAS « npm a échoué », elle couvre UNE panne nommée, mesurée +# sur le premier lancement de ce conteneur : « spawnSync .../esbuild/bin/esbuild ETXTBSY » +# dans le postinstall d'esbuild. npm pose un lien dur vers le binaire de plateforme qu'il +# vient d'extraire puis l'exécute, pendant que sa propre phase reify peut encore tenir cet +# inode ouvert en écriture — et Linux refuse d'exécuter un fichier ouvert en écriture. C'est +# une course entre deux morceaux de npm, elle ne dit rien du lock, et elle frappe un volume +# web/node_modules NEUF : le premier lancement d'un contributeur, précisément. +# +# Ce que coûte de ne pas la rattraper dépasse la minute perdue : la CLI n'exécute +# postCreateCommand qu'à la CRÉATION du conteneur. Le conteneur à moitié préparé reste, le +# `devcontainer up` suivant répond « success » sans rien préparer, et dev.sh annonce « Poste +# prêt » sur un web/node_modules vide (voir la sortie de secours qu'il nomme désormais). +# +# Le filtre sur ETXTBSY est ce qui garde `ci` déterministe : tout autre échec — un lock +# désynchronisé au premier chef — sort ici, à la première tentative, sans être répété. +if ! npm_failure=$(npm --prefix web ci 2>&1); then + printf '%s\n' "$npm_failure" + case "$npm_failure" in + *ETXTBSY*) + echo '' + echo 'ETXTBSY : course connue entre les écritures de npm et son propre postinstall.' + echo 'Seconde et dernière tentative, sur un cache déjà chaud.' + npm --prefix web ci + ;; + *) + exit 1 + ;; + esac +fi echo '' echo 'Poste prêt. Ce que vous pouvez rejouer ici :' diff --git a/SUIVI.md b/SUIVI.md index 9aa1e8d..b4ec31d 100644 --- a/SUIVI.md +++ b/SUIVI.md @@ -1729,6 +1729,7 @@ engageantes : | Date | Événement | |---|---| +| 11/08/2026 | **Le premier lancement du poste de développement en conteneur est mort en route, et le lancement suivant a annoncé « Poste prêt » sur un poste qui ne l'était pas.** Le second point est le plus grave des deux, et aucun des deux ne se raconte de lui-même. **(1) La panne.** `dev.sh` s'arrête pendant `postCreateCommand`, sur `npm ci`. Le message n'est ni dans la sortie du script ni dans celle de la CLI, mais dans `~/.npm/_logs/…-debug-0.log` du conteneur : `Error: spawnSync …/web/node_modules/esbuild/bin/esbuild ETXTBSY`, levé par `validateBinaryVersion` (`esbuild/install.js:103`). Le postinstall d'esbuild pose un **lien dur** vers le binaire de plateforme que npm vient d'extraire, puis l'**exécute** — pendant que la phase `reify` de npm peut encore tenir cet inode **ouvert en écriture**, et Linux refuse d'exécuter un fichier ouvert en écriture. C'est une course entre deux morceaux de npm : elle ne dit **rien** du `package-lock.json`, et elle frappe un volume `web/node_modules` **neuf** — le premier lancement d'un contributeur, précisément. **(2) Ce qui la rend collante.** La CLI n'exécute `postCreateCommand` qu'à la **CRÉATION** du conteneur. Le conteneur à moitié préparé reste donc en place, et le `devcontainer up` suivant répond `{"outcome":"success"}` **sans rien préparer** : `dev.sh` annonce alors « Poste prêt » sur un `web/node_modules` vide, et le premier `make front-check` du contributeur échouerait sur un message qui ne parle ni du conteneur ni de sa préparation. **Mesuré, pas déduit** : le relancement de `dev.sh` a fait exactement cela, sortie `success` à l'appui. **Les deux réparations.** `post-create.sh` retente `npm ci` **une seule fois**, et **seulement** si la sortie nomme `ETXTBSY` — tout autre échec, un lock désynchronisé au premier chef, sort à la première tentative, ce qui garde à `ci` le caractère déterministe que §14.1 lui demande. `dev.sh` et `dev.ps1` **nomment la sortie de secours** au moment où `devcontainer up` échoue — `devcontainer up --workspace-folder . --remove-existing-container` —, portée des deux côtés selon la règle de report du 10/08/2026, et `handbook/getting-started.md` la porte aussi pour qui lit le guide avant de se cogner. **Deux bancs, rouges avant le correctif et montrés tels** : `TestThePostCreateScriptRetriesNpmCiOnTheETXTBSYRace` (le script nomme ETXTBSY **et** appelle `npm --prefix web ci` au moins deux fois) et `TestBothDevScriptsSayHowToRedoAFailedPreparation` (les deux scripts nomment `--remove-existing-container`). **Vérification** : les deux branches du correctif exercées avec un faux `npm` — échec ETXTBSY → **sortie 0, 2 appels**, message de reprise imprimé ; échec de lock désynchronisé → **sortie 1, 1 appel, aucune reprise** ; conteneur **et** volume `openscale-node-modules-…` détruits puis `sh dev.sh` rejoué de zéro → **sortie 0** ; `make test` vert dans le conteneur neuf en deux passes, `boundary` et `deps` compris — **3 135 tests verts, 10 écartés, 0 échec** ; `make front-check` → **80 585 o gzip**, 71,5 % du budget de §14.1 ; `go test ./deploy/` vert, `sh -n` compris. **Ce qui n'est PAS fait, et n'est pas à croire fait** : la course **n'a pas été reproduite à la demande** — trois `npm ci` sur un `web/node_modules` vide et un cache npm froid, tous verts —, donc le correctif s'appuie sur le journal de la panne réelle et sur un `npm` bouchonné, pas sur une reproduction ; `dev.ps1` n'a **pas été exécuté**, faute de Windows ici, il n'est qu'analysé par `deploy/powershell_test.go` ; et **rien ne garde** que `--remove-existing-container` existe encore dans une version future de la CLI — l'option est lue dans l'aide de la 0.88.0, elle n'est vérifiée par aucun banc | | 10/08/2026 | **Les deux installeurs marchent désormais en couple, et un banc l'exige.** Décision du propriétaire du produit : `install.ps1` et `install.sh` doivent offrir les **mêmes fonctionnalités d'installation**, et une modification validée sur l'un se reporte sur l'autre, **dans les deux sens**. L'écart de départ était large — `install.sh` n'avait **aucun paramètre**, ne posait ni mot de passe ni identité, ne tirait aucun code de secours, et sa fiche disait encore « à recopier ici à la main ». Il porte maintenant les six mêmes options, les mêmes questions, le même plancher, la même fiche et le même message de fin. **La règle n'est pas gardée par la bonne volonté** : `deploy/parity_test.go` lit le bloc `param()` de l'un et le `case` d'analyse d'arguments de l'autre, et compare les deux ensembles dans les deux sens. La correspondance PascalCase → minuscules-tirets est **calculée** et non listée ; une paire qui y échappe — `-DataRoot` ↔ `--data-dir`, parce que §11.1 sépare `/etc` et `/var/lib` et qu'aucun répertoire Linux n'est une « racine » qui contiendrait les deux — doit porter sa raison. **Une exception est permise, jamais muette** : elle doit être motivée dans le banc **et** dans le script qui n'a pas l'option, et une raison de moins de 60 caractères fait rougir — « pas d'objet » n'est pas un arbitrage. Trois exceptions Windows, vérifiées une par une dans le dépôt et pas supposées : `-AccountPassword` (le compte `openscale` est créé sans mot de passe et avec `/usr/sbin/nologin`), `-SkipAutoLogon` (`openscale-kiosk.service` est `WantedBy=multi-user.target` avec `PAMName=login` : rien à ouvrir, donc rien à sauter — et l'unique usage réel se règle par `systemctl disable`, ce que l'en-tête nomme), `-Pilot` (il existe pour laisser Access relançable, et Access ne tourne pas sur Debian). **Deux pièges mesurés, pas devinés.** (1) Sous `dash`, la longueur de chaîne du shell compte des **OCTETS** quelle que soit la locale : un plancher de quatre appliqué ainsi aurait accepté « éàç » — 3 caractères, 6 octets — que le binaire refuse. La cinquième porte aurait rétabli exactement la divergence que les quatre autres venaient d'éliminer. Elle compte donc des points de code en retirant les octets de continuation UTF-8, `LC_ALL=C` pour ne pas dépendre du `LANG` du poste ; vérifié sur « éàç » et sur quatre emoji. (2) **Le secret ne descend jamais par argv** : `/proc//cmdline` est lisible par **tous** les comptes de la machine, `/proc//environ` seulement par le propriétaire. `bootstrap.sh` fait donc descendre le mot de passe par l'environnement et jamais en argument, et `install.sh` efface la variable **avant le premier processus fils**, sans quoi `apt-get`, `useradd` et `systemctl` en auraient hérité. **La parité a rendu deux écarts que le banc a refusé de corriger lui-même**, et qui l'ont été ensuite : `install.ps1` ne lisait pas `OPENSCALE_ADMIN_PASSWORD` — il n'existait donc **aucune** façon de poser le mot de passe sous Windows sans terminal et sans ligne de commande — et il n'avertissait pas à l'exécution quand `-AdminPassword` arrivait par argv. Les deux sont portés. **Reste un écart NON traité, et il est nommé plutôt que découvert** : les deux *bootstraps* ne sont pas à parité sur leurs options propres — `bootstrap.sh` a `--force` et `--force-install` parce qu'il distingue installation et mise à jour en déléguant à `update.sh`, là où `bootstrap.ps1` lance toujours `install.ps1`. C'est une divergence de **structure** et non un oubli de ce lot ; la trancher demande une quatrième table dans le banc. **Jugé NON BLOQUANT par le propriétaire du produit le 10/08/2026** : la règle de report porte sur les *fonctionnalités d'installation*, que les deux installeurs offrent désormais à l'identique, et ces trois options-là gouvernent le chemin de téléchargement, pas ce qu'un poste devient. Le banc ne les couvre donc pas, et il ne le prétend pas — il déclare son périmètre : installeur → son propre bootstrap. À rouvrir le jour où un geste d'exploitation manque d'un côté. **Vérification** : `sh -n` et le **vrai `dash` 0.5.12 d'une Debian/Ubuntu** sous WSL sur les quatre scripts POSIX ; les fonctions extraites du script livré exécutées pour de bon — analyse d'options, saisie masquée sur un pty, comptage de points de code, relais du secret —, et **chacun des trois bancs éprouvé en le cassant**, huit mutations dont les deux sens de la parité. **Ce qui n'est PAS fait** : `install.sh` n'a **jamais été lancé en entier** — il installe des paquets, crée un compte et pose des unités systemd —, donc rien de ce qui touche `apt-get`, `useradd`, `udev`, `polkit`, `systemctl` ni `/healthz` n'a été exercé ; `uninstall.sh` ne connaît toujours que les emplacements par défaut, si bien qu'un poste installé avec `--install-dir` se désinstalle à la main — `install.sh` le **dit** au journal au moment où il déplace quelque chose, plutôt que de laisser la surprise ; et aucun `busybox sh` n'était disponible pour une troisième vérification syntaxique | | 10/08/2026 | **Une installation de poste de production a buté sur quatre choses le même jour, et deux d'entre elles rendaient le poste inutilisable.** Aucune n'était visible sans installer pour de bon. **(1) Le bouton « Redémarrer le poste » laissait le poste éteint.** Il reste sur « En cours… », l'écran client est noir, et cinq minutes plus tard l'écran annonce que le poste n'a pas répondu. La cause n'est pas dans le code du bouton : `setRecovery` (`internal/platform/service_windows.go`) n'appelait que `SetRecoveryActions`, **jamais** `SetRecoveryActionsOnNonCrashFailures`. Windows met ce drapeau à **faux** par défaut, et faux signifie « n'appliquer les reprises que si le service s'arrête **sans** signaler `SERVICE_STOPPED` ». Or l'arrêt ordonné de §13.4 se termine proprement et le signale : le SCM voyait un arrêt normal et n'appliquait aucune des trois reprises de §15.2. **La prémisse fausse était écrite en quatre endroits** — `failure.go`, `supervised_windows.go`, `internal/web/maintenance.go`, et un banc **VERT** de `cmd/openscale/maintenance_test.go` intitulé « THE CODE IS THE MECHANISM » — plus ADR-055 lui-même, qui affirmait « le SCM applique alors les reprises de §15.2 ». Les cinq sont corrigés ; l'ADR porte l'amendement daté plutôt qu'une réécriture. Le banc neuf n'assertionne pas seulement le plan envoyé au SCM : `setRecovery` prend désormais une interface de deux méthodes, **parce qu'un banc qui ne lirait que le plan resterait vert si quelqu'un retirait le second appel — c'est exactement le défaut qui a été livré**. Vérifié en le cassant : l'appel retiré, deux bancs rougissent. **Décision de périmètre du propriétaire du produit** : les postes déjà installés ne sont **pas** réparés par ce lot — leur configuration a été rattrapée à la main, et seules les prochaines installations doivent fonctionner. `update.ps1` n'est donc pas touché, et `TROUBLESHOOTING.md` dit **sans détour** qu'une mise à jour ne répare pas ce défaut, le réglage fautif vivant dans Windows et non dans le binaire. **(2) `network.listen` vide verrouillait l'administration ENTIÈRE.** Le fichier livré porte littéralement `"listen": ""` — `Export` remet tout le bloc réseau à zéro (`redact.go`), et la cible release exige un export —, donc le poste **refusait sa propre configuration de livraison**. La gravité n'est pas « une faute de plus » : `PUT /admin/api/config` valide le document **entier** et le brouillon du front envoie tout le document quelle que soit la page, si bien qu'une seule faute portant sur un champ qu'**aucun écran n'éditait** empêchait le moindre enregistrement, sur **toutes** les pages. L'opérateur a corrigé `config.json` à la main. `Config.UnmarshalJSON` ramène désormais une adresse vide à celle du profil neutre, comme il le fait déjà pour `update.repository` et `ui.min_products_for_chip` — même mécanisme, même raison. **L'ordre de livraison était contraint et il a été tenu** : la garde d'import de `configTransfer` **d'abord**, le rattrapage du décodeur **ensuite**. Dans l'autre sens, un défaut bloquant serait devenu un défaut **silencieux** — le 422 était le seul garde-fou empêchant un export importé depuis l'écran de déplacer l'écouteur d'un poste de `0.0.0.0:8085` vers la boucle locale, ce qui aurait fermé l'administration au réseau. La page **Poste** gagne enfin les deux champs `network.listen` et `admin_on_lan`, la mécanique de déplacement à chaud (`Rebind`, compte à rebours de 60 s, retour arrière) étant livrée depuis toujours : **seul le champ manquait**, et §14.4 ne le listait pas — c'est de là que venait l'oubli. Le banc nommé `TestCloningAStationShowsTheSAMEEightCharacters` tombait ; il est **réécrit et non supprimé**, son assertion remontée sur le JSON brut de l'export, qui est le niveau où la garantie vit. **(3) Le plancher du mot de passe d'administration descend de 8 à 4**, sur demande du propriétaire du produit. Le chiffre existait en **six exemplaires** — deux en Go, un en TypeScript, un en Svelte, deux en PowerShell — que rien ne reliait ; il est ramené à **une autorité**, `web.MinPasswordLength`, les copies qu'un autre langage impose étant liées à elle par un banc qui **lit le fichier source**. Un banc en Go, qui **analyse l'arbre syntaxique** et non le texte, refuse désormais qu'une longueur de mot de passe soit comparée à un nombre écrit en clair. **Un vrai défaut, indépendant de la valeur, a été trouvé au passage** : la route HTTP comptait des **octets** là où la ligne de commande comptait des **runes** — « é » vaut un caractère au clavier et deux octets sur le fil, donc la route acceptait un secret que le terminal refusait. Les quatre portes comptent maintenant des points de code, PowerShell compris. **Et un piège d'encodage mesuré** : sur une console en `chcp 65001`, un tube vers un processus natif préfixe la marque d'ordre des octets à l'entrée standard, et un mot de passe haché avec elle aurait **muré le poste** ; `readSecretLine` la retire. **(4) L'installation pose maintenant ce qu'elle seule peut savoir** (ADR-060) — mot de passe d'administration, numéro et nom du poste —, et déclare la balance **absente**, faute d'en avoir une de branchée. Nouvelle action `openscale config station`. **Aucune option de `config` ne prend de secret** : le mot de passe passe par l'entrée standard, un argument se lisant dans la liste des processus. **Un piège que la consigne n'avait pas prévu, trouvé par la mesure** : `[Environment]::UserInteractive` **ne suffit pas** à décider qu'on peut poser une question — `Read-Host -AsSecureString` lit la **console** et non le tube, et une installation scriptée avec `UserInteractive` à vrai s'est arrêtée indéfiniment sur la première invite, tuée à 120 s. **Empreinte : `6c06605a` → `79ba8bfc` tant que la balance est déclarée absente, puis `6c06605a` de nouveau** dès qu'elle est redéclarée — mesuré dans les deux sens, et **dit** sur la fiche d'installation, dans le message de fin de l'installeur et dans `INSTALLATION.md`, parce que §15.5 fait comparer les quatre empreintes à l'œil et qu'un écart non annoncé se prend pour une panne. **Ce que la relecture adverse a rattrapé, et qui aurait annulé le bénéfice** : `--no-scale` vide `scale.type`, or **aucun geste de l'écran ne le remettait** — la détection automatique ne faisait que *rendre un rapport*, le protocole se retapant de mémoire dans un volet replié. Les cinq textes qui promettaient « la détection remet la balance en service » étaient donc faux. La détection **servait pourtant déjà le driver reconnu** (`DetectionDTO.driver`), et `cmd/openscale/detect.go` l'écrivait dans son propre commentaire depuis toujours : « *what goes into the form is the driver that recognised what came out of the cable* ». L'écran le jetait. Un bouton **« Utiliser cette balance »** écrit désormais présence, protocole et port d'un seul geste. Au passage, le service simulé du banc de la page Matériel était **malhonnête** — il répondait un driver pour *tous* les ports, y compris muets —, si bien que le banc « un port non reconnu n'offre rien » aurait été vert pour la mauvaise raison. **Deux défauts de l'installeur trouvés par la même relecture** : `if ($StationNumber)` est **faux pour 0** en PowerShell, donc un « 0 » tapé à l'invite était avalé en silence et le journal annonçait quand même « identité posée » ; et l'avertissement « identité NON posée » était **inatteignable** dans le seul cas pour lequel il avait été écrit. Les deux sont reproduits au banc **avant** correction, puis exécutés pour de bon. **Vérification** : `go build ./...`, `go test ./...` en **deux passes** — avec cgo et `-race`, puis sans cgo —, **3 141 tests verts, 12 écartés, 0 échec** sur 35 paquets ; `go vet` et `gofmt` sans remarque ; **1 021 tests web** sur 38 fichiers, `svelte-check` à **0 erreur** ; les six scripts `.ps1` s'analysent sous PowerShell 5.1 **et** 7. Front embarqué reconstruit **une seule fois**, anciens morceaux supprimés du répertoire commité. **Ce qui n'est PAS fait, et n'est pas à croire fait** : **aucun poste réel n'a été installé** avec ce binaire — la chaîne complète `install.ps1` sur une vraie machine, la recette de redémarrage et le comportement observé du bouton restent à éprouver, et la mesure `sc qfailureflag` sur le poste de production n'a **pas** été relevée ; `doctor` **ne contrôle pas** le drapeau de reprise, donc ce défaut resterait invisible sans matériel — c'est le contrôle qui l'aurait attrapé et il n'est pas écrit ; **Linux reste asymétrique**, `deploy/linux/install.sh` ne pose aucune question et ne tire aucun code de secours, ce qui est **dit** dans `INSTALLATION.md` plutôt que laissé croire ; le limiteur de tentatives d'`admin` **ne verrouille jamais** à rythme régulier — son compteur repart à zéro à chaque fenêtre d'une minute, soit ~236 essais/heure —, ce qui est **assumé par écrit** en §14.4 et laissé à un lot séparé pour qu'on puisse mesurer lequel du plancher ou du limiteur a servi. **Un incident d'outillage à retenir** : `TROUBLESHOOTING.md` a été **vidé à zéro octet** par un agent en fin de passe, et c'est la suite de tests qui l'a dit (`TestTheDocumentationIsWrittenForAVolunteer`, cinq symptômes manquants d'un coup) — restauré depuis `HEAD` et la section réécrite. Un banc qui lit la documentation vaut un banc qui lit du code | | 07/08/2026 | **Deux tests du kiosque tombaient au hasard depuis une semaine, et aucun des deux ne parlait du produit : les deux bancs mesuraient l'ordonnanceur.** Relevé sur les 120 derniers runs de la CI plutôt que deviné — 15 échecs, dont **cinq sur trois tests d'`internal/kiosk`**, entre le 30/07 et le 07/08/2026. **Défaut A, trois chutes** (`TestAStationThatDoesNotAnswerYetShowsTheWaitingPage` ×2, `TestTheGraceIsBoundedAndEndsOnTheStartingPage` ×1), toutes sur « poste muet : ouvert sur "http://127.0.0.1:8085" » : `newBench` posait `alive = true` **et démarrait le superviseur** avant de rendre la main, si bien qu'un test qui écrivait ensuite `alive.Store(false)` courait contre la **première scrutation**, laquelle est la première instruction du superviseur. Sur un runner assez chargé pour l'ordonnancer d'abord, il ouvrait l'écran client — et le message accusait le superviseur d'avoir ouvert sur un poste muet alors que le poste répondait encore à l'instant où il avait regardé. **Reproduit à l'identique** en glissant `time.Sleep(20 ms)` entre les deux lignes. L'état initial devient donc un **constructeur** et non un champ écrit après coup : `newBenchOnAStationThatDoesNotAnswerYet`, qui pose l'état **avant** `Run`, où aucune temporisation ne peut plus rien casser. **Défaut B, deux chutes** (`TestTheWordingChangesOnceTheStationHasAnswered`), sur « page d'attente revenue après 3 s : le délai de grâce a été resservi » — **un message faux, et le code le prouve** : `awaitStation` ne tourne qu'une fois, avant le premier navigateur, donc la grâce ne PEUT pas être resservie. La vraie faute est que `nextLaunch` avançait l'horloge fausse de 50 ms **à chaque tour de boucle**, y compris les tours passés à attendre que l'ordonnanceur exécute le superviseur : l'horloge fausse comptait la charge de la machine, et toutes les assertions de durée de ce fichier se lisent sur elle. **Mesuré en affamant la goroutine** (`GOMAXPROCS=1`, passe `-race`) : le test tombe **six fois sur six** en annonçant « page d'attente revenue après **1h50m** » — 1h50 de temps faux écoulés en 0,11 s de temps réel. L'horloge ne bouge désormais **que si quelqu'un l'attend**, ce que `Clock.Pending()` sait déjà dire ; et les trois tests de la grâce attendent explicitement que le superviseur soit garé sur l'horloge avant de la pousser, sans quoi la durée est distribuée alors que rien n'est inscrit pour la recevoir. **Vérifié dans la condition qui faisait tomber** : 50 exécutions du paquet à `GOMAXPROCS=1` sous `-race`, toutes vertes, là où six sur six tombaient. **Ce qui n'est PAS corrigé, et n'est pas à croire corrigé** : `TestACorruptedCatalogIsQuarantinedAgainstTheRealChain` (`internal/station`) est tombé une fois le 31/07/2026 sur « 0 ligne(s) ERR-CAT-03 en niveau erreur, attendu 1 » et **n'a pas été reproduit** — 120 exécutions affamées, toutes vertes. L'explication d'une ligne technique encore en vol est faible : **deux tours de boucle complets** séparent le troisième refus de l'assertion. Il reste ouvert, et il n'a pas été touché : corriger sur une intuition aurait rendu le prochain signalement illisible. Les trois autres échecs relevés (`TestANominalStationIsGreenExceptWhatItDoesNotHave`, `TestTheLastLineIsWhatAVolunteerReadsOutOverTheTelephone`, `TestATestBinaryIsNotSupervised`) sont **groupés sur un seul run d'une branche de développement**, ce qui ressemble à un travail en cours et non à une instabilité | diff --git a/deploy/devcontainer_test.go b/deploy/devcontainer_test.go index 4491b64..f57a340 100644 --- a/deploy/devcontainer_test.go +++ b/deploy/devcontainer_test.go @@ -296,6 +296,38 @@ func TestThePostCreateScriptDeclaresTheWorkspaceASafeDirectory(t *testing.T) { } } +// TestThePostCreateScriptRetriesNpmCiOnTheETXTBSYRace guards the repair of the only first +// launch this container has ever failed, and the failure did not look like what it was. +// +// `npm ci` died in esbuild's postinstall on « spawnSync .../esbuild/bin/esbuild ETXTBSY »: +// npm hard-links the platform binary it has just extracted, then executes it while its own +// reify phase may still hold that inode open for writing — and Linux refuses to execute a +// file open for writing. It is a race between two parts of npm, not a broken package-lock, +// and it strikes a FRESH web/node_modules volume, i.e. exactly a contributor's first run. +// +// What it costs is worse than the lost minute: the CLI runs postCreateCommand ONLY when it +// CREATES the container. The half-prepared container stays, a second `devcontainer up` +// reports success without preparing anything, and dev.sh announces « Poste prêt » over an +// empty web/node_modules — see TestBothDevScriptsSayHowToRedoAFailedPreparation, which +// holds the other half of this repair. +// +// The retry is deliberately NARROW: only an output naming ETXTBSY is retried, so a genuinely +// desynchronised lock file keeps failing on the first attempt, loudly, the way §14.1 wants. +func TestThePostCreateScriptRetriesNpmCiOnTheETXTBSYRace(t *testing.T) { + script := readFile(t, postCreateScript) + if !strings.Contains(script, "ETXTBSY") { + t.Error("post-create.sh ne nomme plus ETXTBSY : la seconde tentative de `npm ci` " + + "ne vise plus la panne mesurée, et un premier lancement sur un volume " + + "web/node_modules neuf peut de nouveau mourir sur une course interne à npm") + } + if attempts := strings.Count(codeOnly(script), "npm --prefix web ci"); attempts < 2 { + t.Errorf("post-create.sh appelle `npm --prefix web ci` %d fois : sans seconde "+ + "tentative, la course ETXTBSY laisse un conteneur à moitié préparé que la CLI "+ + "ne repréparera jamais — elle ne rejoue postCreateCommand qu'à la création", + attempts) + } +} + // TestThePostCreateCommandRunsTheScriptThisBenchReads: the bench above is worth nothing if // devcontainer.json stops calling the file it inspects. func TestThePostCreateCommandRunsTheScriptThisBenchReads(t *testing.T) { diff --git a/deploy/parity_test.go b/deploy/parity_test.go index 5120c0d..6759f1f 100644 --- a/deploy/parity_test.go +++ b/deploy/parity_test.go @@ -521,6 +521,30 @@ func TestDevScriptsCheckTheSameThings(t *testing.T) { checkTheReasonIsWritten(t, devGroupReasonException) } +// TestBothDevScriptsSayHowToRedoAFailedPreparation holds a trap neither script could show +// on its own, and that a first launch actually fell into. +// +// `devcontainer up` runs postCreateCommand ONLY when it CREATES the container. A preparation +// that dies half-way — `npm ci` on the ETXTBSY race of +// TestThePostCreateScriptRetriesNpmCiOnTheETXTBSYRace, a network cut during `pip install` — +// leaves the container in place, and the next plain `devcontainer up` answers +// {"outcome":"success"} without preparing anything. Both scripts would then print « Poste +// prêt » over an empty web/node_modules, and the contributor's first `make front-check` +// would fail on something that names neither the container nor its preparation. +// +// Naming --remove-existing-container is the whole repair: it is the only way back to a +// container the CLI will prepare again. +func TestBothDevScriptsSayHowToRedoAFailedPreparation(t *testing.T) { + const wayOut = "--remove-existing-container" + for _, script := range []string{devScriptPath, devPowerShellScriptPath} { + if !strings.Contains(readFile(t, script), wayOut) { + t.Errorf("%s ne nomme pas %q : après une préparation échouée, relancer le script "+ + "tel quel répondrait « Poste prêt » sur un conteneur à moitié préparé — la CLI "+ + "ne rejoue postCreateCommand qu'à la CRÉATION du conteneur", script, wayOut) + } + } +} + // --- Les lecteurs --------------------------------------------------------------------- // dashedSpelling renders a PowerShell parameter the way a sh script spells it. diff --git a/dev.ps1 b/dev.ps1 index 537fc9a..9115d0a 100644 --- a/dev.ps1 +++ b/dev.ps1 @@ -120,8 +120,19 @@ Write-Host ' devcontainer est disponible.' Write-Host '' Write-Host '3. Tout est présent -- lancement du conteneur de développement' +# Le message d'échec dit un piège qu'on ne devine pas, et c'est le même des deux côtés (voir +# le commentaire équivalent de dev.sh) : la CLI n'exécute postCreateCommand qu'à la CRÉATION +# du conteneur. Une préparation qui meurt en route laisse le conteneur EN PLACE, et le +# « devcontainer up » suivant répond {"outcome":"success"} sans rien préparer -- ce script +# annoncerait « Poste prêt » sur un web/node_modules vide. devcontainer up --workspace-folder . if ($LASTEXITCODE -ne 0) { + Write-Host '' + Write-Host " Le lancement a échoué. Si la construction de l'image est passée et que" + Write-Host " c'est la PRÉPARATION qui a lâché, ne relancez pas cette commande telle" + Write-Host ' quelle : la CLI ne rejoue la préparation que sur un conteneur NEUF.' + Write-Host ' Repartez de :' + Write-Host ' devcontainer up --workspace-folder . --remove-existing-container' exit $LASTEXITCODE } diff --git a/dev.sh b/dev.sh index 429d593..64701a6 100644 --- a/dev.sh +++ b/dev.sh @@ -136,7 +136,21 @@ echo ' devcontainer est disponible.' echo '' echo '3. Tout est présent -- lancement du conteneur de développement' -devcontainer up --workspace-folder . +# L'échec n'est pas laissé à « set -e », parce que le message qui manque ici est celui d'un +# piège qu'on ne devine pas : la CLI n'exécute postCreateCommand qu'à la CRÉATION du +# conteneur. Une préparation qui meurt en route -- npm, pip, une coupure réseau -- laisse le +# conteneur EN PLACE, et le « devcontainer up » suivant répond {"outcome":"success"} sans +# rien préparer ; ce script annoncerait alors « Poste prêt » sur un web/node_modules vide, et +# le premier « make front-check » du contributeur échouerait sur un message qui ne parle ni +# du conteneur ni de sa préparation. Repartir d'un conteneur neuf est la seule sortie. +if ! devcontainer up --workspace-folder .; then + echo '' + echo " Le lancement a échoué. Si la construction de l'image est passée et que c'est la" + echo ' PRÉPARATION qui a lâché, ne relancez pas cette commande telle quelle : la CLI ne' + echo ' rejoue la préparation que sur un conteneur NEUF. Repartez de :' + echo ' devcontainer up --workspace-folder . --remove-existing-container' + exit 1 +fi echo '' echo 'Poste prêt. Ce que vous pouvez rejouer depuis ce conteneur :' diff --git a/handbook/getting-started.md b/handbook/getting-started.md index 8bcfb63..31e9bb0 100644 --- a/handbook/getting-started.md +++ b/handbook/getting-started.md @@ -66,6 +66,19 @@ Vous pouvez alors rejouer, avant de pousser, tout ce que la CI vérifie **sauf u les scripts d'installation sous Windows PowerShell 5.1, qu'aucun conteneur Linux ne peut exécuter. C'est le job `scripts` de la CI qui les juge, à chaque pull request. +!!! warning "Si le lancement échoue APRÈS la construction de l'image" + + La CLI n'exécute la préparation du conteneur — `postCreateCommand` : golangci-lint, + mkdocs, `npm ci` — qu'à la **création** de celui-ci. Une préparation qui meurt en route + laisse donc le conteneur en place, et un second `dev.sh` répondrait « Poste prêt » sans + rien préparer, sur un `web/node_modules` vide. Repartez d'un conteneur neuf : + + ```bash + devcontainer up --workspace-folder . --remove-existing-container + ``` + + Les deux scripts le disent aussi au moment où ils échouent. + !!! note "Sous Windows, si les compilations traînent" Le dépôt reste sur votre disque Windows et le conteneur le lit à travers un montage :