From c62004052956508654cdc9e824f60ceb0f61f986 Mon Sep 17 00:00:00 2001 From: "Fabien F." <8136686+lostmind84@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:02:18 +0200 Subject: [PATCH] fix(kiosque): le demarrage a froid cesse de ressembler a une panne MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Poste redemarre apres l'installation de la v0.6 : page blanche pendant deux minutes, puis un redemarrage du navigateur que personne n'avait demande. Rien n'etait casse -- c'est l'addition de deux mecanismes voulus. Le service est en demarrage automatique DIFFERE et Windows fixe ce differe a 120 s par defaut, valeur que personne n'avait choisie ; la tache du kiosque, elle, part 5 s apres l'ouverture de session. Mesure sur PC-RECEPTION le 29/07/2026 : demarrage 17:47:54, kiosque 17:48:15, service 17:50:11, navigateur relance sur l'ecran client 17:50:12. Le « redemarrage » observe ETAIT le mecanisme de retour du superviseur, pas la panne. install.ps1 ramene AutoStartDelay a 20 s, sauvegarde dans restore.json et remis par uninstall.ps1 comme les autres reglages ecrases. Get-SnapshotValue arrive avec lui : sous Set-StrictMode -Version Latest, lire une section absente d'un restore.json ecrit par une version anterieure ECHOUE, et restore.json n'est jamais reecrit -- c'est la desinstallation, le geste qui doit toujours marcher, qui aurait casse. Le superviseur accorde 20 s de grace a un poste qui n'a JAMAIS repondu, sans rien afficher : une machine qui vient de demarrer a de toute facon l'air d'un ecran noir, et une page montree trois secondes puis remplacee par une relance du navigateur se lit comme une panne qui s'est reparee toute seule. La grace est servie une fois -- un service qui tombe a midi retrouve sa page en moins de 2 s, ce qu'un test mesure sur l'horloge injectee. La page locale a deux formulations d'attente la ou elle n'en avait qu'une : « Application en cours de demarrage... » tant que le poste n'a jamais repondu, « Le poste redemarre... » ensuite. Les deux portent trois points animes en CSS, jamais en JavaScript -- la page est ouverte en file://, ou un script est la seule chose qu'une politique de navigateur pourrait refuser. La page ERR-KSK-02 reste immobile : elle n'attend rien. Enfin kiosk.log, borne a deux generations de 256 Kio. Ce diagnostic-ci s'est fait a la pince, sur les heures de creation des processus, parce que les lignes du superviseur partaient sur la sortie standard d'une tache planifiee, qui ne redirige nulle part. Elles sont ecrites sous mutex : logf est appele depuis la boucle de supervision ET depuis la goroutine qui renouvelle l'inhibition de veille. Reste ouvert, note dans SUIVI.md : kiosk.log n'est pas dans diagnostic.zip, ce qui oblige TROUBLESHOOTING.md a demander un second fichier alors que la promesse etait « le fichier de diagnostic, et lui seul ». --- SUIVI.md | 24 ++++++ TROUBLESHOOTING.md | 26 ++++-- cmd/openscale/kiosk.go | 30 ++++++- deploy/deploy_test.go | 13 +++ deploy/windows/common.ps1 | 46 +++++++++++ deploy/windows/install.ps1 | 9 +++ docs/02-architecture.md | 13 ++- internal/kiosk/kiosk_test.go | 64 +++++++++++++++ internal/kiosk/log.go | 95 ++++++++++++++++++++++ internal/kiosk/log_test.go | 95 ++++++++++++++++++++++ internal/kiosk/rescue.go | 72 ++++++++++++++--- internal/kiosk/supervisor.go | 116 +++++++++++++++++++++++---- internal/kiosk/supervisor_test.go | 128 +++++++++++++++++++++++++++++- internal/platform/paths.go | 18 +++++ 14 files changed, 711 insertions(+), 38 deletions(-) create mode 100644 internal/kiosk/log.go create mode 100644 internal/kiosk/log_test.go diff --git a/SUIVI.md b/SUIVI.md index 79228cf..8e9c434 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 ». + **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 a459d04..ea71dc3 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. @@ -321,6 +329,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 2eff806..d92256d 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 93dccfb..6a86510 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 @@ -376,6 +382,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 @@ -404,6 +429,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. @@ -528,6 +558,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 00623da..32c660d 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 43f5272..ab2eb7a 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) }