Moteur : une bascule plus récente périme la vérification du rendu, plan et retour en POST

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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 15:24:21 +02:00
1 parent 5b834d54b2
commit 5d37bef0ce
3 files changed
+122 -6

No files matched your search

+3
View File
@@ -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")
}
+58 -6
View File
@@ -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")
}
+61
View File
@@ -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