diff --git a/SUIVI.md b/SUIVI.md index 7e46570..ec0fa29 100644 --- a/SUIVI.md +++ b/SUIVI.md @@ -3,6 +3,30 @@ > Tableau de bord. À mettre à jour au fil de l'eau — c'est le premier fichier à lire > pour savoir où on en est. +**Le démarrage à froid de la v0.6 montrait une panne qui n'existait pas (29/07/2026).** +Poste redémarré après l'installation : session `openscale` ouverte seule, puis une page +blanche pendant deux minutes, puis un redémarrage du navigateur que personne n'avait +demandé. Rien n'était cassé — c'est l'addition de deux mécanismes voulus. Le service est en +démarrage automatique **différé** et Windows fixe ce différé à **120 s** par défaut +(`AutoStartDelay` absent du registre) ; la tâche du kiosque, elle, part 5 s après +l'ouverture de session. Mesuré : démarrage `17:47:54`, kiosque `17:48:15`, service +`17:50:11`, navigateur relancé sur l'écran client `17:50:12`. Le « redémarrage » observé +**était** le mécanisme de retour du superviseur, pas la panne. + +Ce qui a été fait, dans l'ordre où ça se voit à l'écran : + +| # | Correction | État | +|---|---|---| +| 1 | `AutoStartDelay = 20` posé par `install.ps1`, sauvegardé et restauré comme les autres réglages écrasés | ✅ | +| 2 | Délai de grâce de 20 s : rien n'est affiché tant que le poste n'a jamais répondu | ✅ | +| 3 | Deux formulations d'attente — « Application en cours de démarrage… » puis « Le poste redémarre… » — et trois points animés en CSS | ✅ | +| 4 | `C:\ProgramData\OpenScale\kiosk.log` : la sortie du superviseur n'allait nulle part | ✅ | + +Le diagnostic a dû se faire à la pince — heures de création des processus, journal système — +faute justement de ce journal. **Reste ouvert** : `kiosk.log` n'est pas dans +`diagnostic.zip`, ce qui oblige `TROUBLESHOOTING.md` à demander un second fichier alors que +la promesse était « le fichier de diagnostic, et lui seul ». + **La mise à jour se déclenche depuis l'écran, et elle est livrée (29/07/2026).** ADR-040 : le poste sonde une fois par jour l'API des publications du dépôt suivi, porte une pastille au tableau de bord, télécharge l'archive au clic, **vérifie son empreinte SHA-256**, @@ -42,6 +66,7 @@ est dépassé est **effacée** au lieu d'opposer `ErrAlreadyRunning` pour toujou conséquence directe de ce que le banc a mesuré, un `Start()` qui rend `nil` sans rien lancer. Et un lancement qui **échoue** efface son `pending.json` : le processus est encore vivant pour le faire. + **Installer la v0.5 comme un bénévole a buté six fois (29/07/2026).** L'archive publiée a été posée sur `PC-RECEPTION` par `install.ps1` sans option, puis conduite étape par étape comme `INSTALLATION.md` la décrit. Le poste tourne — service automatique, balance GRAM sur diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index a36d782..9630b6b 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -42,14 +42,22 @@ Il fonctionne même quand le poste ne démarre pas — c'est là qu'il sert le p 4. **Voyez-vous l'écran de connexion de Windows ?** Alors ce n'est pas le poste qui est en panne : voir la section suivante, c'est la panne la plus fréquente et la plus coûteuse. -5. **Voyez-vous le bureau de Windows, avec la barre des tâches ?** L'écran client ne +5. **Le poste vient-il de démarrer ?** Les vingt premières secondes après l'ouverture de + session, l'écran reste noir **exprès** : le service finit de démarrer, et le poste + préfère ne rien montrer plutôt que d'afficher une page qu'il remplacerait aussitôt. + Comptez jusqu'à vingt avant de conclure. +6. **Voyez-vous le bureau de Windows, avec la barre des tâches ?** L'écran client ne s'est pas lancé. Ouvrez le Planificateur de tâches, trouvez **OpenScale-Kiosk**, clic droit → **Exécuter**. Si ça remet l'écran, la tâche existe mais ne s'est pas déclenchée : relancez `install.ps1`. -6. **Voyez-vous une page blanche avec « Le poste redémarre… » ?** Le service n'a pas - encore répondu. Attendez cinq secondes. Si la phrase reste, allez à « Le poste ne - répond pas du tout ». -7. **Voyez-vous « Le poste rencontre un problème — ERR-KSK-02 » ?** L'affichage +7. **Voyez-vous une page blanche avec « Application en cours de démarrage… » ?** Le + service met plus de vingt secondes à répondre. Attendez encore une minute — un poste + qui a beaucoup de photos produit à relire au premier démarrage est plus lent. Si la + phrase reste, allez à « Le poste ne répond pas du tout ». +8. **Voyez-vous une page blanche avec « Le poste redémarre… » ?** Même chose, mais le + poste avait déjà fonctionné depuis son démarrage : c'est le service qui s'est arrêté + en cours de journée. Attendez cinq secondes, puis même section. +9. **Voyez-vous « Le poste rencontre un problème — ERR-KSK-02 » ?** L'affichage n'arrive pas à rester ouvert : le navigateur se ferme dès qu'il s'ouvre. Le poste a cessé de réessayer exprès, pour ne pas clignoter devant les clients. Lancez `doctor`, et prévenez un responsable avec le fichier de diagnostic. @@ -345,6 +353,14 @@ Si l'écran ne s'ouvre pas : & "C:\Program Files\OpenScale\openscale.exe" doctor --zip ``` +**Et s'il ne s'est rien passé à l'écran au démarrage**, joignez le journal de l'écran +client, qui dit ce qu'il a affiché et pourquoi — c'est le seul document qui répond à +« qu'est-ce qu'il y avait sur l'écran avant que j'arrive ? » : + +``` +C:\ProgramData\OpenScale\kiosk.log +``` + --- ## Les codes, pour en parler au téléphone diff --git a/cmd/openscale/kiosk.go b/cmd/openscale/kiosk.go index 8755439..9fac01e 100644 --- a/cmd/openscale/kiosk.go +++ b/cmd/openscale/kiosk.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "flag" "fmt" @@ -23,11 +24,28 @@ import ( // label: everything it knows about the station it learns from one URL. That is what // makes it restartable at will, and what makes « the browser died » a non-event. func runKiosk(ctx context.Context, args []string, out io.Writer) error { - options, err := parseKioskOptions(args, out) + // What the option parsing has to say is kept aside until the journal exists. It + // cannot be opened any earlier — its own path is one of the options — and « la + // configuration est illisible » is exactly the line somebody looks for afterwards. + var parsed bytes.Buffer + options, err := parseKioskOptions(args, io.MultiWriter(out, &parsed)) if err != nil { return err } + if options.logPath != "" { + journal, err := kiosk.OpenLog(options.logPath, kiosk.DefaultLogSize) + if err != nil { + // A journal is a diagnostic aid, never a reason to leave a customer in front + // of a black screen. The sentence goes to the stream that is left. + fmt.Fprintf(out, "openscale kiosk : %v\n", err) + } else { + defer func() { _ = journal.Close() }() + _, _ = journal.Write(parsed.Bytes()) + out = io.MultiWriter(out, journal) + } + } + browser, found := kiosk.Find(browserCandidates(), kiosk.LookBrowser(programDirectories())) if !found { // The one failure no relaunch fixes, and the only one this subcommand refuses @@ -59,6 +77,9 @@ func runKiosk(ctx context.Context, args []string, out io.Writer) error { type kioskOptions struct { url string profileDir string + // logPath is where the supervisor's lines are kept. Empty means « nowhere but the + // standard output », which is what a station under systemd already has. + logPath string } // parseKioskOptions resolves the address of the client screen. @@ -74,9 +95,10 @@ func parseKioskOptions(args []string, out io.Writer) (kioskOptions, error) { configPath = fs.String("config", os.Getenv("OPENSCALE_CONFIG"), "fichier de configuration") address = fs.String("url", "", "adresse de l'écran client, sinon celle de la configuration") profile = fs.String("profile", "", "répertoire de profil du navigateur") + logPath = fs.String("log", platform.DefaultKioskLogPath(), "journal du superviseur") ) fs.Usage = func() { - fmt.Fprint(out, `Usage : openscale kiosk [--config fichier] [--url http://hôte:port] [--profile répertoire] + fmt.Fprint(out, `Usage : openscale kiosk [--config fichier] [--url http://hôte:port] [--profile répertoire] [--log fichier] Le superviseur de navigateur : il ouvre l'écran client en plein écran et le relance s'il se ferme. C'est ce que lance la tâche planifiée « `+taskName+` » à l'ouverture de @@ -88,6 +110,8 @@ Options : --url adresse de l'écran client ; prioritaire sur la configuration --profile profil dédié du navigateur, effacé à chaque démarrage ; sinon un répertoire sous le dossier temporaire du compte + --log journal du superviseur, en plus de la sortie standard ; + --log "" ne journalise que sur la sortie standard `) } positional, err := parseMixed(fs, args) @@ -99,7 +123,7 @@ Options : return kioskOptions{}, fmt.Errorf("argument inattendu %q : kiosk ne prend que des options", positional[0]) } - o := kioskOptions{url: *address, profileDir: *profile} + o := kioskOptions{url: *address, profileDir: *profile, logPath: *logPath} if o.profileDir == "" { o.profileDir = kiosk.DefaultProfileDir() } diff --git a/deploy/deploy_test.go b/deploy/deploy_test.go index 8051eb1..27d0eac 100644 --- a/deploy/deploy_test.go +++ b/deploy/deploy_test.go @@ -837,6 +837,19 @@ if ($text -notmatch 'K7M4Q2XR') { throw 'la fiche ne porte pas le code de secour if ($text -match 'RECOPIER ICI') { throw 'la fiche demande de recopier un code qu''elle porte déjà' } if ($text -notmatch 'seule copie') { throw 'la fiche ne dit pas qu''elle est la seule copie du code' } +# --- 9. Un instantané écrit par une version ANTÉRIEURE se relit sans exploser ------ +# restore.json n'est jamais réécrit : celui d'un poste installé il y a six mois ne +# connaît pas les sections que l'installeur d'aujourd'hui y met. Sous +# « Set-StrictMode -Version Latest », lire une propriété absente ÉCHOUE — et ce serait +# la désinstallation, le geste qui doit toujours marcher, qui casserait. +$old = Read-Snapshot -Path $restore +if ($null -ne (Get-SnapshotValue (Get-SnapshotValue $old 'service_control') 'AutoStartDelay')) { + throw 'une section absente de l''instantane a rendu une valeur' +} +if ((Get-SnapshotValue $old.winlogon 'DefaultUserName') -ne 'ancien') { + throw 'Get-SnapshotValue perd une valeur presente' +} + Write-Output 'TOUT-EST-VERIFIE' ` writeScript(t, harness, body) diff --git a/deploy/windows/common.ps1 b/deploy/windows/common.ps1 index 9a3f767..ecda54d 100644 --- a/deploy/windows/common.ps1 +++ b/deploy/windows/common.ps1 @@ -38,6 +38,12 @@ $script:BinaryName = 'openscale.exe' # Les clés que l'installeur écrase, donc celles qu'il doit savoir remettre (important-15). $script:WinlogonKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' $script:WindowsUpdateKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' +$script:ServiceControlKey = 'HKLM:\SYSTEM\CurrentControlSet\Control' + +# Le différé appliqué aux services à démarrage automatique différé, en secondes. Il vaut +# 120 s par défaut chez Windows, et le service du poste en fait partie : c'est ce qui +# faisait attendre le kiosque deux minutes après une coupure de courant. +$script:AutoStartDelaySeconds = 20 # La suspension USB sélective : le GUID du sous-groupe « Paramètres USB » et celui du # réglage lui-même. Ils sont RECOPIÉS de docs/02-architecture.md §15.2 — on ne devine pas @@ -405,6 +411,25 @@ function Get-RegistryValue { $property.$Name } +function Get-SnapshotValue { + <# + .SYNOPSIS + Lit une valeur d'instantané, ou $null quand la section ou la valeur manque. + .DESCRIPTION + « Set-StrictMode -Version Latest » fait ÉCHOUER l'accès à une propriété absente d'un + PSCustomObject. Un restore.json écrit par une version antérieure de l'installeur n'a + pas les sections que la version d'aujourd'hui y met : sans cette fonction, désinstaller + un poste installé il y a six mois s'arrête sur « The property cannot be found », et + c'est la désinstallation — le geste qui doit toujours marcher — qui casse. + #> + [CmdletBinding()] + param($Section, [Parameter(Mandatory)][string]$Name) + + if ($null -eq $Section) { return $null } + if (-not ($Section.PSObject.Properties.Name -contains $Name)) { return $null } + $Section.$Name +} + function Get-SystemSettings { <# .SYNOPSIS @@ -433,6 +458,11 @@ function Get-SystemSettings { ActiveHoursStart = Get-RegistryValue $script:WindowsUpdateKey 'ActiveHoursStart' ActiveHoursEnd = Get-RegistryValue $script:WindowsUpdateKey 'ActiveHoursEnd' } + service_control = @{ + # Le différé des services à démarrage automatique différé. Il vaut pour TOUTE la + # machine, pas seulement pour le nôtre : c'est ce qui en fait un réglage à remettre. + AutoStartDelay = Get-RegistryValue $script:ServiceControlKey 'AutoStartDelay' + } power = @{ # Le plan actif est identifié par son GUID : « SCHEME_CURRENT » n'a de sens que # pendant la session qui l'a lu, et une désinstallation a lieu des mois plus tard. @@ -557,6 +587,22 @@ function Restore-SystemSettings { } } + # Le différé des services, remis AVANT le reste parce qu'il peut manquer de + # l'instantané : les postes installés avant que l'installeur ne touche à ce réglage ont + # un restore.json qui n'en parle pas, et restore.json n'est jamais réécrit. « Absent » + # veut alors dire « c'est nous qui l'avons posé », donc on le supprime — au risque + # assumé de rendre son défaut à une machine qui en avait choisi un autre avant nous. + $serviceControl = Get-SnapshotValue $Snapshot 'service_control' + $autoStartDelay = Get-SnapshotValue $serviceControl 'AutoStartDelay' + if ($null -eq $autoStartDelay) { + Remove-ItemProperty -Path $script:ServiceControlKey -Name 'AutoStartDelay' -ErrorAction Ignore + Write-Step 'démarrage différé des services : réglage retiré (il n''existait pas avant l''installation)' $LogFile + } + else { + Set-ItemProperty -Path $script:ServiceControlKey -Name 'AutoStartDelay' -Value $autoStartDelay -Type DWord + Write-Step 'démarrage différé des services remis à sa valeur d''origine' $LogFile + } + foreach ($name in 'SetActiveHours', 'ActiveHoursStart', 'ActiveHoursEnd') { $value = $Snapshot.windows_update.$name if ($null -eq $value) { diff --git a/deploy/windows/install.ps1 b/deploy/windows/install.ps1 index 9a4a7ee..119b5f1 100644 --- a/deploy/windows/install.ps1 +++ b/deploy/windows/install.ps1 @@ -222,6 +222,15 @@ else { } # --- 4. Service et tâche du kiosque ------------------------------------------------ +# Le service est en démarrage automatique DIFFÉRÉ (internal/platform/service_windows.go) : +# les disques, la pile réseau et le spouleur d'impression passent devant. Windows fixe ce +# différé à 120 s par défaut, et personne ne l'a choisi — mesuré sur PC-RECEPTION le +# 29/07/2026 : démarrage à 17:47:54, service à 17:50:11, soit deux minutes pendant +# lesquelles le kiosque n'avait rien d'autre à afficher que sa page d'attente. 20 s +# laissent passer ce qui doit passer sans faire attendre le premier client du samedi. +Set-ItemProperty $script:ServiceControlKey 'AutoStartDelay' $script:AutoStartDelaySeconds -Type DWord +Write-Step "démarrage différé des services ramené à $($script:AutoStartDelaySeconds) s (défaut Windows : 120 s)" $paths.LogFile + $startMode = if ($Pilot) { 'demand' } else { 'auto' } & $paths.Binary service install --start $startMode --config $paths.Config --data $paths.DataDir Assert-Success 'openscale service install' diff --git a/docs/02-architecture.md b/docs/02-architecture.md index 692d91c..d694605 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -124,7 +124,7 @@ flowchart TB end subgraph P2["PROCESSUS 2 — « balance kiosk » · session graphique"] - KIOSK["internal/kiosk — superviseur de navigateur
msedge ou chromium en mode kiosque
relance en moins de 2 s · inhibe la veille
page de secours locale si le service ne répond pas"] + KIOSK["internal/kiosk — superviseur de navigateur
msedge ou chromium en mode kiosque
relance en moins de 2 s · inhibe la veille
20 s de grâce au démarrage à froid, écran noir
page locale ensuite si le service ne répond pas"] end WEB -->|"HTTP + SSE"| KIOSK @@ -3989,6 +3989,14 @@ La **suspension USB sélective** provoque en pratique la moitié des « la balan **Le navigateur** : ordre de recherche `msedge.exe` → `chrome.exe` → `chromium.exe`, arguments `--kiosk`, `--user-data-dir` (profil dédié effacé à chaque démarrage), `--no-first-run`, `--disable-session-crashed-bubble` (pas de « Restaurer les pages ? » après coupure), `--noerrdialogs`, `--check-for-update-interval=31536000`, `--autoplay-policy=no-user-gesture-required` (le bip d'étiquette). Sur Edge, `--edge-kiosk-type=fullscreen` en plus. Le superviseur relance en < 2 s ; **au-delà de 20 morts en moins de 10 s dans l'heure**, il ouvre une page de secours locale (« Le poste rencontre un problème — ERR-KSK-02 ») au lieu de clignoter devant les clients. Il appelle `SetThreadExecutionState(ES_CONTINUOUS|ES_DISPLAY_REQUIRED|ES_SYSTEM_REQUIRED)` toutes les 30 s — ceinture et bretelles par-dessus `powercfg`. +**Le démarrage à froid**, mesuré sur `PC-RECEPTION` le 29/07/2026 et corrigé le même jour. Le service est en démarrage automatique **différé** — les disques, la pile réseau et le spouleur passent devant — et Windows fixe ce différé à **120 s** par défaut, valeur que personne n'avait choisie : démarrage machine à 17:47:54, tâche du kiosque à 17:48:15, service à 17:50:11. Deux minutes de page d'attente, terminées par un redémarrage du navigateur que celui qui regarde l'écran lit comme une panne qui s'est réparée toute seule. Trois règles en découlent : + +1. `install.ps1` écrit `HKLM\SYSTEM\CurrentControlSet\Control\AutoStartDelay = 20` (sauvegardé dans `restore.json`, remis à la désinstallation). Le différé garde son sens, il cesse d'être un minuteur aveugle. +2. Le superviseur accorde un **délai de grâce de 20 s** à un poste qui n'a **jamais** répondu : pendant ce temps il n'affiche **rien**, écran noir, ce qu'une machine qui vient de démarrer a de toute façon l'air d'être. Le cas nominal devient **un seul lancement du navigateur, directement sur l'écran client**. La grâce est servie une fois : un service qui tombe à midi retrouve sa page d'attente en moins de 2 s. +3. La page locale a **deux formulations d'attente**, pas une : « **Application en cours de démarrage…** » tant que le poste n'a jamais répondu, « **Le poste redémarre…** » ensuite. Les deux portent trois points animés **en CSS** — jamais en JavaScript, la page est ouverte en `file://` — que la page ERR-KSK-02 n'a pas : elle n'attend rien. + +**Le journal du kiosque** : `C:\ProgramData\OpenScale\kiosk.log` (+ une génération `.1`, 256 Kio chacune), en plus de la sortie standard. La tâche planifiée ne redirige rien, et les lignes du superviseur — « le poste ne répond pas encore », « le poste répond de nouveau » — sont exactement celles qu'on cherche le lendemain. `--log ""` les laisse à la seule sortie standard, ce dont une station systemd n'a pas besoin. + **Ce qui reste possible** : `Ctrl+Alt+Suppr` (impossible à bloquer sans stratégie) et `Alt+F4`. Dans les deux cas l'agent relance en < 2 s. **On l'assume et on le documente**, plutôt que de prétendre à un verrouillage parfait comme le faisait l'existant avec son `FindWindowA(NULL, "La Cagette")`. Le niveau 3 optionnel (Assigned Access / Shell Launcher v2) est fourni dans `harden.ps1` ; il est optionnel car il complique le dépannage — et c'est précisément pour ce niveau que le **code de secours** existe (§14.4). ### 15.3 Linux @@ -4070,7 +4078,8 @@ Le kiosque : unité séparée, `ExecStart=/usr/bin/cage -d -- /usr/local/bin/bal | Beaucoup de produits non pesables | **rien, jamais** | inventaire neutre : « 39 non pesables — préemballés ou sans code-barres » | **aucune** : ces produits ne relèvent pas de la balance | | Disque plein | (rien : la pesée sort) | feu rouge Journal, `ERR-SYS-05`, « 12 pesées non journalisées » | libérer de l'espace | | Config invalide | plein écran « Poste en configuration d'usine (ERR-CFG-01) » | **toutes** les fautes en français | corriger, ou restaurer une version | -| Service mort | page de secours « Le poste redémarre… » | — | attendre 5 s ; sinon `openscale doctor` | +| Service pas encore démarré (démarrage à froid) | écran noir pendant 20 s, puis page « Application en cours de démarrage… » | — | attendre ; sinon `openscale doctor` | +| Service mort en cours de journée | page de secours « Le poste redémarre… » | — | attendre 5 s ; sinon `openscale doctor` | | **Redémarrage sans intervention non configuré** | (rien — la panne ne se voit qu'après une coupure de courant, quand le poste reste sur l'écran de connexion Windows) | tableau de bord : **« redémarrage sans intervention : NON CONFIGURÉ »** en orange, `ERR-SYS-08` | relancer `install.ps1` en administrateur (§15.2 étape 3), puis refaire la recette de redémarrage (§15.5) | | Mot de passe admin perdu | — | **code de secours 8 caractères** (fiche d'installation) | réinitialiser depuis l'écran | diff --git a/internal/kiosk/kiosk_test.go b/internal/kiosk/kiosk_test.go index 562ed36..30912df 100644 --- a/internal/kiosk/kiosk_test.go +++ b/internal/kiosk/kiosk_test.go @@ -107,6 +107,70 @@ func TestTheRescuePageIsAFileTheBrowserCanOpen(t *testing.T) { } } +// TestTheStartingPageDoesNotClaimAnythingRestarted is the wording of a cold boot. +// +// « Le poste redémarre… » in front of a machine that has just been switched on is +// false, and false in the direction that worries: it tells a volunteer that something +// went wrong when nothing did. +func TestTheStartingPageDoesNotClaimAnythingRestarted(t *testing.T) { + dir := t.TempDir() + if _, err := WriteRescuePage(dir, RescueStarting, "http://127.0.0.1:8085", 0); err != nil { + t.Fatalf("écriture de la page de démarrage : %v", err) + } + raw, err := os.ReadFile(filepath.Join(dir, RescueFileName)) + if err != nil { + t.Fatalf("relecture : %v", err) + } + page := string(raw) + if !strings.Contains(page, "démarrage") { + t.Errorf("la page de démarrage ne parle pas de démarrage :\n%s", page) + } + if strings.Contains(page, "redémarre") { + t.Error("la page de démarrage annonce un redémarrage : rien n'a redémarré") + } + if strings.Contains(page, CodeCrashLoop) { + t.Error("la page de démarrage porte ERR-KSK-02, qui est le code de la boucle de plantage") + } +} + +// TestTheWaitingPagesShowASignOfLifeWithoutAScript is what turns two minutes of waiting +// into two minutes of waiting rather than two minutes of « c'est planté ». +// +// In CSS and never in JavaScript: the page is opened over file://, so a script would be +// the one thing on this screen that a browser policy could refuse. The crash-loop page is +// deliberately left STILL — it is not waiting for anything, and an animation there would +// promise a return that is not coming. +func TestTheWaitingPagesShowASignOfLifeWithoutAScript(t *testing.T) { + for name, reason := range map[string]RescueReason{ + "démarrage": RescueStarting, + "attente": RescueWaiting, + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + if _, err := WriteRescuePage(dir, reason, "http://127.0.0.1:8085", 0); err != nil { + t.Fatalf("écriture : %v", err) + } + raw, _ := os.ReadFile(filepath.Join(dir, RescueFileName)) + page := string(raw) + if !strings.Contains(page, "@keyframes") { + t.Error("la page d'attente est immobile : rien ne dit qu'elle travaille") + } + if strings.Contains(page, " 0 && j.size+int64(len(p)) > j.maxBytes { + if err := j.rotate(); err != nil { + return 0, err + } + } + written, err := j.file.Write(p) + j.size += int64(written) + return written, err +} + +// Close closes the journal. +func (j *journal) Close() error { + j.mu.Lock() + defer j.mu.Unlock() + return j.file.Close() +} + +// rotate moves the current generation aside and starts an empty one. +func (j *journal) rotate() error { + if err := j.file.Close(); err != nil { + return err + } + // Rename over any previous generation: two generations, always, and no third file + // appearing on a station where nobody prunes anything. + if err := os.Rename(j.path, j.path+".1"); err != nil { + return err + } + file, err := os.OpenFile(j.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return err + } + j.file, j.size = file, 0 + return nil +} diff --git a/internal/kiosk/log_test.go b/internal/kiosk/log_test.go new file mode 100644 index 0000000..ff9b8ab --- /dev/null +++ b/internal/kiosk/log_test.go @@ -0,0 +1,95 @@ +package kiosk + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// The kiosk journal exists because of one afternoon spent inferring what a station had +// shown at boot from process start times. The supervisor's French lines went to the +// standard output of a scheduled task, which redirects nowhere, so the two sentences that +// would have answered the question — « le poste ne répond pas encore », « le poste répond +// de nouveau » — were written to a stream nobody was reading. + +// TestTheJournalKeepsTheLastLinesAndNotTheFirst is what makes the file safe to leave on a +// station for years: a browser that dies in a loop writes one line per relaunch, and a +// file that only grows is a disk-space alert waiting to happen (§10.4). +func TestTheJournalKeepsTheLastLinesAndNotTheFirst(t *testing.T) { + path := filepath.Join(t.TempDir(), "kiosk.log") + const cap_ = 64 + + journal, err := OpenLog(path, cap_) + if err != nil { + t.Fatalf("ouverture du journal : %v", err) + } + for _, line := range []string{"première\n", "deuxième\n", "troisième\n"} { + if _, err := journal.Write([]byte(strings.Repeat(line, 3))); err != nil { + t.Fatalf("écriture : %v", err) + } + } + if err := journal.Close(); err != nil { + t.Fatalf("fermeture : %v", err) + } + + current, err := os.ReadFile(path) + if err != nil { + t.Fatalf("relecture du journal : %v", err) + } + if int64(len(current)) > cap_ { + t.Fatalf("le journal fait %d octets pour un plafond de %d", len(current), cap_) + } + if !strings.Contains(string(current), "troisième") { + t.Fatalf("le journal a perdu la dernière ligne :\n%s", current) + } + previous, err := os.ReadFile(path + ".1") + if err != nil { + t.Fatalf("la génération précédente n'a pas été gardée : %v", err) + } + if !strings.Contains(string(previous), "première") { + t.Errorf("la génération précédente ne porte pas ce qui a été écrit avant :\n%s", previous) + } +} + +// TestTheJournalSurvivesTwoWritersIsWhatTheSupervisorNeeds: logf is called from the +// supervision loop AND from the goroutine that renews the sleep inhibition. Two writers +// on one file is not a corner case here, it is the ordinary shape of this package. +func TestTheJournalSurvivesTwoWritersIsWhatTheSupervisorNeeds(t *testing.T) { + path := filepath.Join(t.TempDir(), "kiosk.log") + journal, err := OpenLog(path, 128) + if err != nil { + t.Fatalf("ouverture du journal : %v", err) + } + defer func() { _ = journal.Close() }() + + var writers sync.WaitGroup + for writer := 0; writer < 2; writer++ { + writers.Add(1) + go func() { + defer writers.Done() + for line := 0; line < 50; line++ { + if _, err := journal.Write([]byte("une ligne de journal\n")); err != nil { + t.Errorf("écriture concurrente : %v", err) + return + } + } + }() + } + writers.Wait() +} + +// TestAJournalThatCannotBeOpenedIsSaidAndNotGuessed: the caller has to be able to tell +// « pas de journal » from « journal vide », because the second is a station that showed +// nothing and the first is a station whose disk refused. +func TestAJournalThatCannotBeOpenedIsSaidAndNotGuessed(t *testing.T) { + // A path whose parent is a FILE: no operating system creates a directory there. + parent := filepath.Join(t.TempDir(), "fichier") + if err := os.WriteFile(parent, []byte("x"), 0o644); err != nil { + t.Fatalf("préparation : %v", err) + } + if _, err := OpenLog(filepath.Join(parent, "kiosk.log"), 1024); err == nil { + t.Fatal("un journal impossible à ouvrir a été accepté") + } +} diff --git a/internal/kiosk/rescue.go b/internal/kiosk/rescue.go index 5de4e18..dd72bf9 100644 --- a/internal/kiosk/rescue.go +++ b/internal/kiosk/rescue.go @@ -11,11 +11,15 @@ import ( type RescueReason int const ( - // RescueWaiting is « le service n'a pas encore répondu ». It is the ordinary state - // of the ten seconds after a power cut: the scheduled task fires at logon, the - // service is still opening its database, and a customer must see a sentence rather - // than a browser error page. + // RescueWaiting is « le poste a répondu, et il ne répond plus ». Something the + // customer was using went away: the sentence may legitimately talk about a station + // coming back. RescueWaiting RescueReason = iota + // RescueStarting is the same silence, before the station has EVER answered — a cold + // boot, where the service is on delayed automatic start and the kiosk task fires five + // seconds after logon. Nothing has restarted, and a page that says so worries a + // volunteer about a station that is merely switching on. + RescueStarting // RescueCrashLoop is the twenty-first quick death of §15.2 — ERR-KSK-02. RescueCrashLoop ) @@ -69,16 +73,19 @@ func fileURL(path string) string { // working station, and a volunteer must be able to read the one instruction from three // metres away. func rescueHTML(reason RescueReason, address string, shortLives int) string { - title, message, instruction := "", "", "" + title := rescueTitle(reason) + message, instruction := "", "" switch reason { case RescueCrashLoop: - title = "Le poste rencontre un problème" message = CodeCrashLoop + " — l'affichage n'arrive pas à rester ouvert " + fmt.Sprintf("(%d arrêts en moins de 10 secondes dans la dernière heure).", shortLives) instruction = "Prévenez un responsable. Ouvrez « openscale doctor » sur ce poste : " + "il dira ce qui manque. En attendant, la caisse peut peser au comptoir." + case RescueStarting: + message = "L'écran de pesée s'ouvre dès que le service a fini de démarrer." + instruction = "Patientez quelques secondes. Si ce message reste affiché, " + + "prévenez un responsable — il lancera « openscale doctor »." default: - title = "Le poste redémarre…" message = "L'écran de pesée revient dès que le service répond." instruction = "Patientez quelques secondes. Si ce message reste affiché, " + "prévenez un responsable — il lancera « openscale doctor »." @@ -102,14 +109,61 @@ func rescueHTML(reason RescueReason, address string, shortLives int) string { p { font-size: clamp(1.1rem, 2.5vw, 1.75rem); max-width: 40ch; line-height: 1.4; margin: 0 0 1rem; } .instruction { color: #333333; } .address { font-size: 1rem; color: #555555; margin-top: 3rem; } - +` + rescueAnimation(reason) + `

` + html.EscapeString(title) + `

` + html.EscapeString(message) + `

-

` + html.EscapeString(instruction) + `

+` + rescueDots(reason) + `

` + html.EscapeString(instruction) + `

Poste attendu sur ` + html.EscapeString(address) + `

` } + +// rescueTitle is the one line read from three metres away, and the only difference +// between the two waiting reasons. +func rescueTitle(reason RescueReason) string { + switch reason { + case RescueCrashLoop: + return "Le poste rencontre un problème" + case RescueStarting: + return "Application en cours de démarrage…" + default: + return "Le poste redémarre…" + } +} + +// rescueAnimation is the three-dot pulse, in CSS and never in JavaScript. +// +// A page opened over file:// is the one place on this station where a script is what a +// browser policy could refuse, and the page whose whole job is to be displayed when +// nothing else works must not depend on anything. +// +// It is absent from the crash-loop page ON PURPOSE: that page is not waiting for +// anything, and something that moves on it would promise a return that is not coming — +// the flicker §15.2 opened it to stop, at a slower speed. +func rescueAnimation(reason RescueReason) string { + if reason == RescueCrashLoop { + return "" + } + return ` .dots { display: flex; gap: 0.9rem; margin: 0.5rem 0 1.5rem; } + .dots span { + width: 0.9rem; height: 0.9rem; border-radius: 50%; background: #1A1A1A; + opacity: 0.25; animation: pulse 1.4s ease-in-out infinite; + } + .dots span:nth-child(2) { animation-delay: 0.2s; } + .dots span:nth-child(3) { animation-delay: 0.4s; } + @keyframes pulse { 0%, 100% { opacity: 0.25; } 50% { opacity: 1; } } +` +} + +// rescueDots is the markup the animation above moves. +func rescueDots(reason RescueReason) string { + if reason == RescueCrashLoop { + return "" + } + // aria-hidden: it carries no information a screen reader would want — the sentence + // above it already says what is happening. + return "
\n" +} diff --git a/internal/kiosk/supervisor.go b/internal/kiosk/supervisor.go index 1aca25d..9a1e5cd 100644 --- a/internal/kiosk/supervisor.go +++ b/internal/kiosk/supervisor.go @@ -25,6 +25,17 @@ const ( // One second: a customer watching « Le poste redémarre… » is watching a station // that is about to work, and the wait must not be added to the service's own. StationRecheck = 1 * time.Second + // StartGrace is how long a station that has NEVER answered is given before anything + // is put on the screen. + // + // It buys the ordinary cold boot: the kiosk task fires five seconds after logon, the + // service is on delayed automatic start, and the twenty seconds below cover the gap + // on a station whose AutoStartDelay has been shortened. Inside the grace the screen + // stays black — which is what a machine that has just booted looks like anyway — + // instead of showing a page for a few seconds and then restarting the browser in + // front of whoever is watching. Beyond it, the page appears: a grace that never ended + // would be a black screen nobody can read. + StartGrace = 20 * time.Second // ProbeBudget bounds one liveness question. It is a network deadline, spent in the // kernel's TCP stack, and no business decision rests on it. ProbeBudget = 2 * time.Second @@ -75,9 +86,16 @@ type Options struct { type Supervisor struct { options Options crashes CrashCounter - // rescue is the file:// URL of the local page, written at start and rewritten when - // the crash-loop threshold is crossed. + // rescue is the file:// URL of the local page, written at start and rewritten + // whenever the reason it carries changes. rescue string + // rescueReason is what the page on disk currently says, so that it is rewritten when + // the answer changes and never once per relaunch. + rescueReason RescueReason + // answered is true once the station has served at least one /healthz in the life of + // this supervisor. It is what tells « the poste is starting » from « the poste is + // coming back », which are the same silence and two different sentences. + answered bool // rescueMode is true once §15.2's twenty-first quick death has happened. Nothing // clears it: the page says « prévenez un responsable », and a supervisor that // silently went back to a screen it had just declared broken would make that @@ -123,15 +141,20 @@ func (s *Supervisor) Run(ctx context.Context) error { // consequence, and the line below is what a volunteer reads afterwards. s.logf("le profil du navigateur n'a pas pu être effacé : %v", err) } - rescue, err := WriteRescuePage(s.options.ProfileDir, RescueWaiting, s.options.URL, 0) + // Written here and not at the first need: a profile directory that refuses to be + // written to is a station that will never show anything, and it must fail now rather + // than in front of a customer. + rescue, err := WriteRescuePage(s.options.ProfileDir, RescueStarting, s.options.URL, 0) if err != nil { return err } - s.rescue = rescue + s.rescue, s.rescueReason = rescue, RescueStarting go s.keepAwake(ctx) s.logf("superviseur démarré : %s sur %s", s.options.Browser.Name, s.options.URL) + s.awaitStation(ctx) + first := true for ctx.Err() == nil { if !first && !s.pause(ctx, RelaunchDelay) { @@ -143,6 +166,51 @@ func (s *Supervisor) Run(ctx context.Context) error { return nil } +// awaitStation gives a station that has never answered StartGrace to come up, showing +// nothing at all in the meantime. +// +// It runs ONCE, before the first browser of this supervisor. Afterwards a station that +// goes silent gets the waiting page inside the two seconds §15.2 promises: the grace +// covers a boot, not a failure, and re-serving it later would turn a browser that died at +// noon into twenty seconds of black screen in front of the queue. +func (s *Supervisor) awaitStation(ctx context.Context) { + if s.answering(ctx) { + return + } + s.logf("le poste ne répond pas encore : %s d'attente avant d'afficher quoi que ce soit", StartGrace) + + ticks, stop := s.options.Clock.Ticker(StationRecheck) + defer stop() + grace := s.options.Clock.After(StartGrace) + for { + select { + case <-ctx.Done(): + return + case <-grace: + s.logf("le poste n'a pas répondu en %s : page de démarrage", StartGrace) + return + case <-ticks: + if s.answering(ctx) { + return + } + } + } +} + +// answering reports whether the station serves, and remembers a yes for good. +// +// The budget is spent in the kernel's TCP stack and bounds one question, never a business +// decision — the same ProbeBudget the liveness probe carries on its own client. +func (s *Supervisor) answering(ctx context.Context) bool { + probeCtx, cancel := ports.WithBudget(ctx, s.options.Clock, ProbeBudget) + defer cancel() + if !s.options.Alive(probeCtx) { + return false + } + s.answered = true + return true +} + // showOnce launches the browser once and returns when it has died — or when the // station came back while the rescue page was showing. func (s *Supervisor) showOnce(ctx context.Context) { @@ -212,7 +280,7 @@ func (s *Supervisor) watch(ctx context.Context, process Process, exited <-chan s s.logf("superviseur arrêté") return stopped case <-recheck: - if s.options.Alive(ctx) { + if s.answering(ctx) { s.logf("le poste répond de nouveau : retour à l'écran client") _ = process.Kill() <-exited @@ -234,15 +302,37 @@ func (s *Supervisor) target(ctx context.Context) (string, bool) { if s.rescueMode { return s.rescue, false } - probeCtx, cancel := ports.WithBudget(ctx, s.options.Clock, ProbeBudget) - defer cancel() - if s.options.Alive(probeCtx) { + if s.answering(ctx) { return s.options.URL, false } - s.logf("le poste ne répond pas encore sur %s : page de secours", s.options.URL) + reason := RescueStarting + if s.answered { + reason = RescueWaiting + } + s.logf("le poste ne répond pas sur %s : %s", s.options.URL, rescueTitle(reason)) + s.showRescue(reason) return s.rescue, true } +// showRescue rewrites the local page when what it has to say has changed. +// +// When it has changed, and not before every launch: the page is rewritten twice in the +// life of an ordinary station — never, or once when a station that had answered goes +// silent — and a file rewritten every second would be a disk woken up for nothing. +func (s *Supervisor) showRescue(reason RescueReason) { + if s.rescueReason == reason { + return + } + page, err := WriteRescuePage(s.options.ProfileDir, reason, s.options.URL, s.crashes.ShortLives()) + if err != nil { + // The page already on disk carries the other wording, which is still true enough + // to read: showing it beats showing the browser's own error page. + s.logf("la page locale n'a pas pu être réécrite : %v", err) + return + } + s.rescue, s.rescueReason = page, reason +} + // enterRescue rewrites the local page with the crash-loop wording of §15.2 and points // the supervisor at it. // @@ -259,13 +349,7 @@ func (s *Supervisor) enterRescue() { s.rescueMode = true s.logf("%s : %d arrêts de moins de %s dans la dernière heure — page de secours", CodeCrashLoop, s.crashes.ShortLives(), ShortLife) - page, err := WriteRescuePage(s.options.ProfileDir, RescueCrashLoop, - s.options.URL, s.crashes.ShortLives()) - if err != nil { - s.logf("la page de secours n'a pas pu être écrite : %v", err) - return - } - s.rescue = page + s.showRescue(RescueCrashLoop) } // wipeProfile removes the dedicated browser profile. diff --git a/internal/kiosk/supervisor_test.go b/internal/kiosk/supervisor_test.go index 9e8cb10..8ed595b 100644 --- a/internal/kiosk/supervisor_test.go +++ b/internal/kiosk/supervisor_test.go @@ -119,6 +119,40 @@ func newBench(t *testing.T) *bench { return b } +// advance moves the fake clock in small steps, letting the supervisor run between +// them. +// +// In small steps and not one jump: a jump delivers the whole grace period before the +// supervisor has reached its first probe, and the test would then prove that a +// supervisor which never looked at the clock waits correctly. +func (b *bench) advance(d time.Duration) { + const step = 100 * time.Millisecond + for elapsed := time.Duration(0); elapsed < d; elapsed += step { + b.clock.Advance(step) + time.Sleep(time.Millisecond) + } +} + +// nothingLaunched reports whether the browser has stayed closed. +func (b *bench) nothingLaunched() bool { + select { + case <-b.launched: + return false + default: + return true + } +} + +// rescuePage is the local page as it stands on disk right now. +func (b *bench) rescuePage(t *testing.T) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join(b.profile, RescueFileName)) + if err != nil { + t.Fatalf("relecture de la page de secours : %v", err) + } + return string(raw) +} + // targetOf reads the URL out of a command line, which is the argument right after // --kiosk. func targetOf(arguments []string) string { @@ -173,12 +207,13 @@ func TestTheClientScreenComesBackInUnderTwoSeconds(t *testing.T) { } } -// TestAStationThatDoesNotAnswerYetShowsTheWaitingPage is the ten seconds after a power -// cut: the scheduled task fires at logon, the service is still opening its database, and -// a customer must read a sentence instead of a browser error page. +// TestAStationThatDoesNotAnswerYetShowsTheWaitingPage is what follows the grace period: +// 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.advance(StartGrace) first, target := b.nextLaunch(t) if !strings.HasPrefix(target, "file:///") { @@ -201,6 +236,93 @@ func TestAStationThatDoesNotAnswerYetShowsTheWaitingPage(t *testing.T) { second.die() } +// TestNothingIsShownWhileTheServiceIsStillStarting is the grace period of §15.2. +// +// At a cold boot the station is not DOWN, it is starting: the scheduled task fires five +// seconds after logon and the service, on delayed automatic start, answers later. A page +// that appears for those few seconds and is then replaced by a browser relaunch reads, to +// whoever is standing in front of the screen, as a station that failed and recovered by +// 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.advance(StartGrace / 2) + if !b.nothingLaunched() { + t.Fatal("un navigateur a été lancé pendant le délai de grâce") + } + + // The service finishes starting inside the grace: the browser must then open ONCE, + // straight on the client screen, and the customer never sees a local page. + b.alive.Store(true) + process, target := b.nextLaunch(t) + if target != b.stationOK { + t.Fatalf("le poste a répondu pendant le délai de grâce, navigateur ouvert sur %q", target) + } + defer process.die() + if !b.nothingLaunched() { + t.Fatal("le navigateur a été lancé deux fois : le client verrait un redémarrage") + } +} + +// TestTheGraceIsBoundedAndEndsOnTheStartingPage keeps the grace from becoming a black +// screen nobody can read. +// +// 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.advance(StartGrace) + + _, target := b.nextLaunch(t) + if !strings.HasPrefix(target, "file:///") { + t.Fatalf("après le délai de grâce, ouvert sur %q au lieu de la page locale", target) + } + if page := b.rescuePage(t); !strings.Contains(page, rescueTitle(RescueStarting)) { + t.Fatalf("la page ouverte ne dit pas %q", rescueTitle(RescueStarting)) + } +} + +// TestTheWordingChangesOnceTheStationHasAnswered is why there are two waiting reasons +// rather than one. +// +// « Le poste redémarre… » describes a station that is COMING BACK, and at a cold boot it +// is simply false: nothing has restarted. Once the station has answered at least once, it +// becomes the true sentence, and the same page must say so. +// +// The second thing this proves: the grace period is served ONCE. A browser that dies +// while the station is down comes back on the waiting page inside the two seconds §15.2 +// promises, and not after another grace period of black screen. +func TestTheWordingChangesOnceTheStationHasAnswered(t *testing.T) { + b := newBench(t) + + first, target := b.nextLaunch(t) + if target != b.stationOK { + t.Fatalf("premier lancement sur %q, attendu l'écran client", target) + } + + b.alive.Store(false) + died := b.clock.Now() + first.die() + + _, target = b.nextLaunch(t) + if !strings.HasPrefix(target, "file:///") { + t.Fatalf("poste devenu muet : ouvert sur %q, attendu la page locale", target) + } + if elapsed := b.clock.Now().Sub(died); elapsed >= 2*time.Second { + t.Fatalf("page d'attente revenue après %s : le délai de grâce a été resservi", elapsed) + } + page := b.rescuePage(t) + if !strings.Contains(page, rescueTitle(RescueWaiting)) { + t.Fatalf("un poste qui a déjà répondu doit lire %q", rescueTitle(RescueWaiting)) + } + if strings.Contains(page, rescueTitle(RescueStarting)) { + t.Fatalf("la page dit encore %q alors que le poste avait répondu", rescueTitle(RescueStarting)) + } +} + // TestTheTwentyFirstQuickDeathOpensTheRescuePage is the anti-flicker rule of §15.2, end // to end: twenty deaths keep relaunching the client screen, the twenty-first opens the // still page carrying ERR-KSK-02 — on a station that ANSWERS, which is the case the two diff --git a/internal/platform/paths.go b/internal/platform/paths.go index a99c3b3..6aa424f 100644 --- a/internal/platform/paths.go +++ b/internal/platform/paths.go @@ -25,6 +25,9 @@ const ( // ConfigName is the configuration file of §11.1, next to its five rotating // versions config.json.1 … .5. ConfigName = "config.json" + // KioskLogName is where the browser supervisor's French lines are kept, next to + // its own previous generation kiosk.log.1. + KioskLogName = "kiosk.log" windowsRoot = `C:\ProgramData\OpenScale` linuxConfig = "/etc/openscale" @@ -54,5 +57,20 @@ func DefaultDataDir() string { return linuxData } +// DefaultKioskLogPath reports where the browser supervisor writes what it did. +// +// Beside the configuration and NOT inside the data directory: the kiosk runs as the +// unprivileged station account, this file is a diagnostic aid rather than data of the +// station, and putting it next to the database would put the two in the same disk-space +// alert (§10.4). On Windows the installer already grants that directory to the account; +// on Linux the unit's stdout goes to the journal as well, and this file is the copy that +// survives a journal nobody kept. +func DefaultKioskLogPath() string { + if runtime.GOOS == "windows" { + return filepath.Join(windowsRoot, KioskLogName) + } + return filepath.Join(linuxData, KioskLogName) +} + // DatabasePath reports the database file inside a data directory. func DatabasePath(dataDir string) string { return filepath.Join(dataDir, DatabaseName) }