From 5d37bef0ce010a92da5bc178723635398c481db7 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 4 Oct 2026 15:24:21 +0200 Subject: [PATCH] =?UTF-8?q?Moteur=20:=20une=20bascule=20plus=20r=C3=A9cent?= =?UTF-8?q?e=20p=C3=A9rime=20la=20v=C3=A9rification=20du=20rendu,=20plan?= =?UTF-8?q?=20et=20retour=20en=20POST?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La vérification du rendu du gabarit après une mise à jour attend jusqu'à vingt minutes que le nouveau moteur réponde. Un retour à la version précédente, une autre version installée ou une seconde mise à jour pendant cette attente la laissaient courir : elle comparait alors l'ancien moteur à celui d'une autre bascule, ou écrasait le relevé de la suivante. - Chaque bascule ouvre une génération ; une vérification périmée s'arrête sans rien ranger, et son relevé n'écrase plus le suivant. Retour et « utiliser » effacent le relevé, qui parlerait d'un moteur arrêté. - /api/engine/plan (registre) et /api/engine/rollback (configuration et redémarrage) refusent tout autre verbe que POST — l'interface n'utilise que POST. - Tests : vérification annulée en attente, relevé périmé ignoré, GET refusé. Co-Authored-By: Claude Opus 5.5 --- internal/loki/web_engine.go | 3 ++ internal/loki/web_engine_reco.go | 64 ++++++++++++++++++++++++--- internal/loki/web_engine_reco_test.go | 61 +++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 6 deletions(-) diff --git a/internal/loki/web_engine.go b/internal/loki/web_engine.go index d51f4fb..c2fa436 100644 --- a/internal/loki/web_engine.go +++ b/internal/loki/web_engine.go @@ -194,6 +194,8 @@ func engineRunUpdate(tag string, preserveOff bool) { ref, haveBefore := engineRenderBefore(ctx) cancel() + // La vérification d'une bascule précédente parlerait du moteur qu'on quitte. + engineRenderCancel() if err := engineSwitchTo(bin); err != nil { lcFail(err) return @@ -280,6 +282,7 @@ func handleEngineUse(w http.ResponseWriter, r *http.Request) { return } engineRememberPrevious(left, bin) + engineRenderCancel() if serviceIsActive() { _ = serviceAction("restart") } diff --git a/internal/loki/web_engine_reco.go b/internal/loki/web_engine_reco.go index ce8b3d1..d710cc0 100644 --- a/internal/loki/web_engine_reco.go +++ b/internal/loki/web_engine_reco.go @@ -265,6 +265,12 @@ type engineRenderCheck struct { var ( engineRenderMu sync.Mutex engineRenderLast *engineRenderCheck + // engineRenderGen : la vérification en cours. Une bascule plus récente + // (autre mise à jour, retour à la version précédente, autre version + // installée) la périme : une vérification d'avant, encore à attendre son + // moteur, ne doit ni écraser le relevé suivant ni comparer l'ancien + // moteur au moteur d'une autre bascule. + engineRenderGen uint64 // engineRenderWait : délai laissé au nouveau moteur pour charger le modèle // (un gros MoE en mmap peut demander plusieurs minutes). Variable pour les // tests. @@ -272,9 +278,40 @@ var ( engineRenderPoll = 5 * time.Second ) -func engineRenderSet(c engineRenderCheck) { +// engineRenderBegin ouvre une vérification : celles d'avant sont périmées. +func engineRenderBegin() uint64 { + engineRenderMu.Lock() + defer engineRenderMu.Unlock() + engineRenderGen++ + return engineRenderGen +} + +// engineRenderCancel : une bascule sans vérification (retour à la version +// précédente, autre version installée) périme la vérification en cours et +// efface son relevé, qui parlerait d'un moteur qui ne tourne plus. +func engineRenderCancel() { + engineRenderMu.Lock() + defer engineRenderMu.Unlock() + engineRenderGen++ + engineRenderLast = nil +} + +// engineRenderCurrent : la vérification gen est-elle encore la dernière ? +func engineRenderCurrent(gen uint64) bool { + engineRenderMu.Lock() + defer engineRenderMu.Unlock() + return gen == engineRenderGen +} + +// engineRenderSet range le relevé de la vérification gen — rien si une +// bascule plus récente l'a périmée. +func engineRenderSet(gen uint64, c engineRenderCheck) { c.At = time.Now() engineRenderMu.Lock() + if gen != engineRenderGen { + engineRenderMu.Unlock() + return + } engineRenderLast = &c engineRenderMu.Unlock() if c.Status != "pending" { @@ -331,7 +368,8 @@ func engineRenderBefore(ctx context.Context) (engineRenderRef, bool) { // avant : un ancien moteur qui aurait survécu à l'arrêt répondrait aussi, et // comparer l'ancien à lui-même conclurait à tort « identique ». func engineRenderAfter(ref engineRenderRef, tag string) { - engineRenderSet(engineRenderCheck{Status: "pending", Tag: tag}) + gen := engineRenderBegin() + engineRenderSet(gen, engineRenderCheck{Status: "pending", Tag: tag}) ctx, cancel := context.WithTimeout(context.Background(), engineRenderWait) defer cancel() p := tplLocalProber() @@ -346,22 +384,25 @@ func engineRenderAfter(ref engineRenderRef, tag string) { return err == nil && props.BuildInfo != ref.build } for !ready() { + if !engineRenderCurrent(gen) { + return // bascule plus récente : plus rien à comparer + } select { case <-ctx.Done(): - engineRenderSet(engineRenderCheck{Status: "unknown", Tag: tag, Detail: "le nouveau moteur n'a pas répondu à temps : rendu non vérifié"}) + engineRenderSet(gen, engineRenderCheck{Status: "unknown", Tag: tag, Detail: "le nouveau moteur n'a pas répondu à temps : rendu non vérifié"}) return case <-time.After(engineRenderPoll): } } after, err := tplRenderPrint(ctx, p, ref.shape) if err != nil { - engineRenderSet(engineRenderCheck{Status: "unknown", Tag: tag, Detail: "rendu impossible sur le nouveau moteur : " + err.Error()}) + engineRenderSet(gen, engineRenderCheck{Status: "unknown", Tag: tag, Detail: "rendu impossible sur le nouveau moteur : " + err.Error()}) return } if same, d := tplRenderDiff(ref.text, after); same { - engineRenderSet(engineRenderCheck{Status: "same", Tag: tag}) + engineRenderSet(gen, engineRenderCheck{Status: "same", Tag: tag}) } else { - engineRenderSet(engineRenderCheck{Status: "changed", Tag: tag, Detail: d}) + engineRenderSet(gen, engineRenderCheck{Status: "changed", Tag: tag, Detail: d}) } } @@ -410,6 +451,11 @@ func enginePrevious(cur string) map[string]any { // changerait au prompt. {recommended:true} : la plus récente ≥ b10864 // réellement publiée ; sinon la dernière publiée. Rien n'est téléchargé. func handleEnginePlan(w http.ResponseWriter, r *http.Request) { + // Interroge le registre : POST seulement, comme le clic qui l'appelle. + if r.Method != http.MethodPost { + sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "POST requis"}) + return + } if !engineOCISupported() { sendJSON(w, 200, map[string]any{"ok": false, "error": engineUnsupportedWhy()}) return @@ -454,6 +500,11 @@ func handleEnginePlan(w http.ResponseWriter, r *http.Request) { // réseau. Le moteur quitté devient à son tour « le précédent » : deux clics // ramènent où l'on était. func handleEngineRollback(w http.ResponseWriter, r *http.Request) { + // Change la configuration et redémarre le moteur : POST seulement. + if r.Method != http.MethodPost { + sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "POST requis"}) + return + } if tuneDenyHTTP(w) { return } @@ -469,6 +520,7 @@ func handleEngineRollback(w http.ResponseWriter, r *http.Request) { return } engineRememberPrevious(cur, bin) + engineRenderCancel() if serviceIsActive() { _ = serviceAction("restart") } diff --git a/internal/loki/web_engine_reco_test.go b/internal/loki/web_engine_reco_test.go index cbca1df..303d1e1 100644 --- a/internal/loki/web_engine_reco_test.go +++ b/internal/loki/web_engine_reco_test.go @@ -183,6 +183,67 @@ func TestRetourVersionPrecedente(t *testing.T) { if rec.Code != 400 { t.Errorf("rollback sans précédente : HTTP %d, attendu 400", rec.Code) } + // Les deux routes nouvelles changent la configuration ou interrogent le + // registre : jamais sur un GET. + for _, h := range []http.HandlerFunc{handleEngineRollback, handleEnginePlan} { + rec = httptest.NewRecorder() + h(rec, httptest.NewRequest("GET", "/api/engine/x", nil)) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("GET : HTTP %d, attendu 405", rec.Code) + } + } +} + +// Une bascule plus récente périme la vérification du rendu encore en attente +// de son moteur : elle s'arrête sans rien ranger, et son relevé n'écrase pas +// celui de la bascule suivante. +func TestVerificationDuRenduPerimee(t *testing.T) { + testHome(t) + freshTplProbe(t) + oldPoll, oldWait := engineRenderPoll, engineRenderWait + engineRenderPoll, engineRenderWait = time.Millisecond, 10*time.Second + t.Cleanup(func() { + engineRenderPoll, engineRenderWait = oldPoll, oldWait + engineRenderMu.Lock() + engineRenderLast = nil + engineRenderMu.Unlock() + }) + f := &fakeTpl{mode: "qwen3", build: "b10678-old"} + f.start(t) + ref, ok := engineRenderBefore(context.Background()) + if !ok { + t.Fatal("rendu de référence impossible") + } + // L'ancien moteur répond toujours : la vérification attend. + done := make(chan struct{}) + go func() { engineRenderAfter(ref, "server-cuda-b11351"); close(done) }() + deadline := time.Now().Add(5 * time.Second) + for { + if c := engineRenderSnapshot(); c != nil && c.Status == "pending" { + break + } + if time.Now().After(deadline) { + t.Fatal("vérification jamais commencée") + } + time.Sleep(time.Millisecond) + } + // Retour à la version précédente : vérification périmée, relevé effacé. + engineRenderCancel() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("vérification périmée toujours en attente") + } + if c := engineRenderSnapshot(); c != nil { + t.Errorf("relevé d'une bascule annulée : %+v", c) + } + // Un relevé d'une génération passée n'écrase rien. + gen := engineRenderBegin() + engineRenderSet(gen, engineRenderCheck{Status: "same", Tag: "neuf"}) + engineRenderSet(gen-1, engineRenderCheck{Status: "changed", Tag: "ancien"}) + if c := engineRenderSnapshot(); c == nil || c.Tag != "neuf" { + t.Errorf("relevé écrasé par une vérification périmée : %+v", c) + } } // Le verdict « le prompt rendu va changer » : seulement quand c'est établi