diff --git a/SUIVI.md b/SUIVI.md index dcaad7f..ac19851 100644 --- a/SUIVI.md +++ b/SUIVI.md @@ -1710,6 +1710,7 @@ engageantes : | Date | Événement | |---|---| +| 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é | | 07/08/2026 | **Le one-liner d'installation mourait sur sa première commande, chez qui ne l'avait pas ouvert en administrateur — c'est-à-dire chez presque tout le monde.** Signalé sur le forum des supermarchés coopératifs, capture à l'appui : bannière affichée, puis `iex : Impossible de convertir la valeur «System.String» en type «System.Management.Automation.SwitchParameter»`, `MetadataError`, `RuntimeException,Microsoft.PowerShell.Commands.InvokeExpressionCommand`. **La position accusait `iex`, au caractère 96 de la ligne tapée** — soit exactement le premier caractère de `iex` dans la commande publiée, vérifié en comptant —, donc ni le fichier, ni la ligne, ni la variable. **Troisième défaut de la famille du 01/08/2026, dans une troisième forme.** `bootstrap.ps1` déclare `[switch]$Relaunched` et écrivait, quarante lignes plus bas, `$relaunched = Join-Path $env:TEMP 'openscale-bootstrap.ps1'` : les noms de variables PowerShell sont **insensibles à la casse**, ces deux-là sont la même, et celle d'un paramètre est **typée** — un chemin rangé dans une `[switch]` lève. `$ErrorActionPreference = 'Stop'`, posé trois lignes après le `param`, transformait l'erreur en terminating et la faisait ressortir attribuée à l'`Invoke-Expression` appelant, ce qui explique le message illisible. **Pourquoi personne ne l'avait vu** : la ligne vit dans la branche d'auto-élévation, qui ne s'exécute **que** dans une console non élevée — l'en-tête du script dit lui-même « neuf fois sur dix » —, et une installation lancée depuis une fenêtre administrateur la saute entièrement. **Reproduit avant de toucher quoi que ce soit**, au banc et non par lecture : le script réel passé à `iex` dans une console non élevée, `Invoke-WebRequest` et `Start-Process` bouchonnés, rend l'erreur du forum **au message, à la catégorie et à l'identifiant près** ; le même banc sur le fichier corrigé va jusqu'à « l'installation continue dans la nouvelle fenetre ». La variable s'appelle `$relaunchedScript`. **Le garde-fou ferme la famille au lieu du cas** : `TestNoLocalVariableCollidesWithAParameterByCaseAlone` refuse, dans **tous** les `.ps1` du dépôt, une affectation à un nom qui ne diffère d'un paramètre déclaré que par la casse. **La règle porte sur la casse et non sur le fait d'affecter un paramètre**, parce que ce dépôt en affecte exprès — `$AccountPassword` reçoit ce qui vient d'être tapé, `$Pilot` s'allume sur une réponse : ceux-là écrivent le nom qu'ils ont déclaré, et une casse qui diverge est quelqu'un qui croit ouvrir une variable neuve. **La première version du test comptait 15 signalements dont 14 faux** — un `-Directory` d'une fonction contre un `$directory` de trois autres, qui sont quatre portées et pas une collision —, donc il suit désormais les portées : il n'attribue une affectation qu'aux paramètres de la fonction qui la contient, ou du script si aucune. `if`, `foreach` et `try` n'ouvrent **pas** de portée, ce qui est précisément par où le défaut est passé. **Deuxième vraie collision trouvée par le test**, dans `make.ps1` : `$version = Or-Else $Version …` écrasait le paramètre `-Version` au lieu de le compléter — sans lever, une chaîne entrant dans une `[string]`, mais en effaçant ce que l'opérateur avait demandé. **3 115 tests Go** (3 103 verts, 12 écartés) sur 35 paquets, 0 échec, `go vet` et `gofmt` sans remarque. **Ce qui n'est PAS fait, et n'est pas à croire fait** : aucun poste n'a été installé pour de bon — le banc s'arrête à l'appel d'élévation, et tout ce qui suit (release, empreinte, les trois questions, `install.ps1`) reste non exercé sur ce chemin ; et le test lit les scripts par un découpage de texte et non par l'analyseur de PowerShell — il neutralise les chaînes d'une ligne, **pas les here-strings**, dont `common.ps1` porte un de 60 lignes. Celui-là ne le trompe pas, son corps étant équilibré en accolades, mais **par chance et non par construction** : vérifié en posant une collision de l'autre côté, que le test attribue bien à la fonction qui la contient | | 02/08/2026 | **La CI validait une pull request en 3 min 55 ; elle valide en 1 min 43** (PR #47), et les deux causes ont été mesurées avant d'être touchées. **(1) Les montages de test payaient le coût argon2 d'un vrai login.** `internal/web` mettait **59,5 s** dans la passe `-race` — le paquet le plus lourd du dépôt, à lui seul la moitié de la passe. Ses tests écrivaient leurs empreintes avec `HashSecret`, donc à 64 MiB, t=3, p=2 : le coût d'un login sur l'i3 du poste, payé 34 fois deux, plus 21 vérifications. Or `VerifySecret` relit m, t et p **dans la chaîne stockée** — `TestVerificationReadsTheCostFromTheStoredHash` énonçait déjà cette propriété — donc une empreinte écrite au coût minimal se vérifie au coût minimal, sans qu'aucun chemin de production bouge : `HashSecret` garde ses 64 MiB, `TestArgon2idRoundTrip` les épingle toujours. **59,5 s → 9,0 s en CI**, et 21,3 s → 2,9 s sur poste de développement, ce qui allège autant `make test`. **(2) Huit étapes en série qui ne se devaient rien.** Le travail « Tests et frontières » enchaînait 33 s de `go vet`, 2 min 13 de `-race`, 51 s de passe sans cgo, puis les gardes, pendant que les trois autres travaux avaient fini depuis deux minutes. Découpé en `race`, `test` et `guards`. **Le critère est écrit dans `ci.yml` parce qu'il n'est pas esthétique** : une étape ne peut changer de travail que si elle ne lit rien de celui qu'elle quitte — vrai des quatre gardes, faux des planchers de couverture, qui lisent le profil de la passe sans cgo et restent avec elle. **Le temps-runner cumulé baisse aussi** (423 s → 407 s) : le découpage ajoute un `setup-go` par travail, argon2 en rendait davantage. **Ce qui reste** : `race` plafonne à 1 min 33, et l'écart avec le « moins de 10 s » de §16.4 n'est plus dans un paquet mais réparti sur quatre — `station` 16,7 s, `cmd/openscale` 16,0 s, `store` 13,7 s, `web` 9,0 s. **(3) Dans la foulée** (PR #48), `ci.yml` reçoit un `concurrency` : pousser un correctif sur une branche en validation annule le run qui juge l'état qu'on vient de remplacer. **Pas sur `main`**, où chaque commit est un état livrable dont le verdict est une trace — même arbitrage que `docs.yml`, conclusion inverse. Ce n'est pas une minute gagnée sur un run, c'est de la place sous la limite de travaux simultanés, **seule limite d'Actions qui s'applique ici** : le dépôt est public, donc les runners hébergés ne décomptent aucune minute. | | 01/08/2026 | **Le filet ERR-UI-01 prenait un avertissement du navigateur pour un plantage, et rechargeait l'écran client sans fin.** Signalé depuis le poste pilote : la grille réglée sur dix colonnes depuis l'écran d'administration, enregistrée, et *« ça marche plus, Une erreur est survenue est affichée »*. **Le poste, lui, allait parfaitement bien** — `config validate` sans faute, `/readyz` à `ready:true`, `doctor` sans échec : c'est l'**écran** qui tombait, et c'est le journal technique qui l'a dit, pas l'écran. **43 entrées `ERR-UI-01`, toutes identiques**, détail `ResizeObserver loop completed with undelivered notifications`, **une toutes les 5,12 s** — la valeur de `RELOAD_AFTER_S`, donc la cadence du filet lui-même. Ce message **n'est pas une exception** : c'est l'avis qu'un navigateur émet quand un cycle mesure → style → mesure ne converge pas dans la frame. Aucune exception levée, aucune pile. Mais il arrive par le **même événement `error` sur `window`** qu'une vraie exception, et `installErrorNet` attrapait tout ce qui passait : voile, rechargement à 5 s, remesure, même avis. **Le premier diagnostic était faux et le journal l'a corrigé** : les dix colonnes n'étaient pas la cause — la boucle est repartie à 18:20:47 UTC et a tenu huit minutes **avec la grille en automatique**. Le déclencheur du jour cachait la vraie portée : *n'importe quel* hoquet de mise en page — un écran rebranché, une rotation — blanchissait un poste en libre-service et le rechargeait sans fin, devant un client. **La règle posée** : un événement `error` **sans exception derrière** (`e.error` nul) dont le message commence par `ResizeObserver loop` ne lève pas le voile et ne programme aucun rechargement. Préfixe et non phrase entière, la queue variant d'un navigateur à l'autre ; `e.error` nul en plus du préfixe, sans quoi une vraie `TypeError` qui *nomme* `ResizeObserver` serait avalée. **Le taire entièrement a été écarté** : cette ligne est la seule qui ait nommé le défaut, et une grille qui ne converge pas reste un symptôme. Elle part donc par **sa propre route**, `POST /api/v1/ui/layout-notice`, journalisée **`ERR-UI-02` niveau `warn`** — « La grille de l'écran client n'a pas convergé ; l'écran reste utilisable » —, **une fois par chargement de page**, un avis se répétant à chaque frame. **La journaliser en `ERR-UI-01` a été écarté aussi** : la ligne était fausse deux fois — ce n'est ni une erreur, ni du JavaScript de ce dépôt — et elle atterrissait dans le fichier de diagnostic qu'un bénévole envoie au support. Ce dépôt sait déjà ce que coûte une ligne rouge sur un poste sain : on apprend à ignorer le rouge. Le préfixe `ERR-` reste correct malgré le niveau, `ERR-CAT-05` s'écrivant déjà en `warn` dans `localdrop.go`. **Pourquoi ça a vécu, et c'est la vraie leçon** : `main.ts` monte l'application à l'import, donc **rien ne pouvait exercer le filet sans démarrer un écran entier** ; et `web/test/setup.ts` remplace `ResizeObserver` par une classe qui n'observe rien tandis que jsdom ne fait aucune mise en page, donc **l'événement réel n'existait nulle part dans la suite**. Le filet vit désormais dans `web/src/lib/error-net.ts`, et son banc **pose** l'événement tel que le navigateur l'écrit au lieu de l'attendre d'une mesure. **Vérifié en cassant le correctif** : filtre neutralisé, les 4 tests qui portent le défaut tombent et les 9 autres tiennent ; côté Go, la route absente donne `405, attendu 202` avant, `202` après. **13 tests web, 1 test Go**, `882 passed` sur la suite web, `go test ./...` sans échec, `svelte-check` à 0 erreur, budget client 80 558 o gzip sur 112 640. **Puis vérifié sur le poste et pas seulement au banc** : front embarqué régénéré, binaire posé, événements posés dans le bundle réellement servi — l'avis donne voile **absent** et une ligne `ERR-UI-02`/`warn`, une `TypeError` donne le voile, `ERR-UI-01`/`error` et le rechargement. Écran client ouvert, compteur `sessionStorage` à **1 seul chargement**, cadence 5,12 s disparue. **Ce qui n'est PAS fait** : le cycle mesure → style → mesure de `Grid.svelte` n'est **pas** corrigé — `$effect` lit `.name-box → clientWidth` dans `measuredWidthPx`, qui alimente `tileScale`, qui pilote `--tile-pad`, qui change la largeur de ce même `.name-box` ; il n'a pas de point fixe, et la ligne `ERR-UI-02` à chaque chargement en est la trace. Il ne se voit pas — le navigateur saute une livraison et garde la dernière mise en page valide — mais les commentaires du fichier décrivent, pour cette famille exacte, des noms ajustés à une largeur puis dessinés dans une autre : **plausible ici, non mesuré**. `TROUBLESHOOTING.md` ne nomme pas `ERR-UI-02`, et n'en part pas comme d'un symptôme — il n'y en a pas | diff --git a/internal/kiosk/supervisor_test.go b/internal/kiosk/supervisor_test.go index f016c4f..3058246 100644 --- a/internal/kiosk/supervisor_test.go +++ b/internal/kiosk/supervisor_test.go @@ -83,6 +83,33 @@ func (f *fakeBrowser) die() { f.once.Do(func() { close(f.exit) }) } // newBench starts a supervisor whose browser and clock the test drives. func newBench(t *testing.T) *bench { + t.Helper() + return startBench(t, stationAnswers) +} + +// newBenchOnAStationThatDoesNotAnswerYet is the cold boot: the service has not finished +// starting when the supervisor does, which is the case StartGrace exists for. +// +// It is a CONSTRUCTOR and not a field a test writes afterwards, and that distinction is the +// fix of 07/08/2026. The supervisor probes the station on its first instruction, so a test +// that started it and only then wrote the station down was racing that first probe — and +// lost whenever the runner was loaded enough to schedule the supervisor first. It reported +// « poste muet : ouvert sur "http://127.0.0.1:8085" », which accuses the supervisor of +// opening the client screen on a silent station when the station had simply still been +// answering at the instant it looked. Three CI runs between 30/07 and 07/08/2026. +func newBenchOnAStationThatDoesNotAnswerYet(t *testing.T) *bench { + t.Helper() + return startBench(t, stationIsSilent) +} + +// The two states a station can be in when the supervisor starts, named rather than spelled +// `true` and `false` at the call site — the two constructors above are what tests read. +const ( + stationAnswers = true + stationIsSilent = false +) + +func startBench(t *testing.T, answering bool) *bench { t.Helper() b := &bench{ clock: fake.NewClock(start), @@ -92,7 +119,7 @@ func newBench(t *testing.T) *bench { returned: make(chan error, 1), stationOK: "http://127.0.0.1:8085", } - b.alive.Store(true) + b.alive.Store(answering) supervisor, err := New(Options{ URL: b.stationOK, @@ -146,6 +173,25 @@ func (b *bench) advance(d time.Duration) { } } +// waitUntilParked blocks until the supervisor is waiting ON THE FAKE CLOCK. +// +// This is the synchronisation the whole file rests on. A fake clock only means something +// while somebody is registered to receive what it hands out; moving it at any other instant +// charges the scheduling of the machine to the timeline of the station. +func (b *bench) waitUntilParked(t *testing.T) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for { + if waiters, _ := b.clock.Pending(); waiters > 0 { + return + } + if time.Now().After(deadline) { + t.Fatal("le superviseur ne s'est jamais mis en attente de l'horloge") + } + time.Sleep(time.Millisecond) + } +} + // nothingLaunched reports whether the browser has stayed closed. func (b *bench) nothingLaunched() bool { select { @@ -193,7 +239,18 @@ func (b *bench) nextLaunch(t *testing.T) (*fakeBrowser, string) { if time.Now().After(deadline) { t.Fatal("aucun lancement de navigateur dans le budget du test") } - b.clock.Advance(50 * time.Millisecond) + // ★ L'HORLOGE NE BOUGE QUE SI QUELQU'UN L'ATTEND, et c'est le correctif du + // 07/08/2026. Cette boucle avançait de 50 ms à chaque tour, y compris les tours + // passés à attendre que l'ordonnanceur Go veuille bien exécuter le superviseur : + // l'horloge fausse comptait donc la CHARGE DE LA MACHINE. Affamé sur un seul + // processeur, le même test a mesuré « page d'attente revenue après 1h50m » là où le + // superviseur avait mis quelques centaines de millisecondes — et sur la CI il a + // franchi deux fois en une semaine l'assertion des deux secondes de §15.2, en + // accusant le délai de grâce d'avoir été resservi alors qu'awaitStation ne tourne + // qu'une fois. + if waiters, _ := b.clock.Pending(); waiters > 0 { + b.clock.Advance(50 * time.Millisecond) + } time.Sleep(time.Millisecond) } } @@ -224,8 +281,8 @@ func TestTheClientScreenComesBackInUnderTwoSeconds(t *testing.T) { // the service is taking longer than StartGrace, and a customer must read a sentence // instead of a browser error page. func TestAStationThatDoesNotAnswerYetShowsTheWaitingPage(t *testing.T) { - b := newBench(t) - b.alive.Store(false) + b := newBenchOnAStationThatDoesNotAnswerYet(t) + b.waitUntilParked(t) b.advance(StartGrace) first, target := b.nextLaunch(t) @@ -258,9 +315,9 @@ func TestAStationThatDoesNotAnswerYetShowsTheWaitingPage(t *testing.T) { // itself. Showing nothing at all is both truer and less alarming — the machine has just // booted, a black screen is what one expects. func TestNothingIsShownWhileTheServiceIsStillStarting(t *testing.T) { - b := newBench(t) - b.alive.Store(false) + b := newBenchOnAStationThatDoesNotAnswerYet(t) + b.waitUntilParked(t) b.advance(StartGrace / 2) if !b.nothingLaunched() { t.Fatal("un navigateur a été lancé pendant le délai de grâce") @@ -285,8 +342,8 @@ func TestNothingIsShownWhileTheServiceIsStillStarting(t *testing.T) { // A service that never comes up — a database that will not open, a port already taken — // must end on a sentence, not on the desktop of the station account. func TestTheGraceIsBoundedAndEndsOnTheStartingPage(t *testing.T) { - b := newBench(t) - b.alive.Store(false) + b := newBenchOnAStationThatDoesNotAnswerYet(t) + b.waitUntilParked(t) b.advance(StartGrace) _, target := b.nextLaunch(t)