Optimiseur : le ménage du verrou ne croise plus sa prise ni son rendu

TestTuneRecoverNeverReapsOwnLock échouait au hasard sous Linux (2 fois sur
5 déjà en v0.15.0, CI rouge). Le ménage (tuneReapStale) déplaçait un verrou
vivant, le relisait puis le remettait en place ; si release() passait entre
les deux, il ne trouvait plus le fichier et rendait l'inscription. Le verrou
remis en place devenait orphelin, et le ménage suivant le prenait pour une
optimisation interrompue : moteur relancé, preset « rétabli » à tort.

Un mutex de processus (tuneFileMu) sérialise désormais les opérations sur
le fichier — prise, réécriture, rendu, déplacement-relecture du ménage.
L'arrêt d'un essai orphelin et le retour du preset restent hors du mutex.
Vérifié sous Linux : 40 passages sans échec, suite complète verte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-07 15:29:08 +02:00
1 parent e7fb63b1dd
commit 853e59c085
1 file changed
+46 -11
+46 -11
View File
@@ -146,6 +146,15 @@ func tuneActive() (tuneOwner, bool) {
// tuneHeld : verrous pris par CE processus et pas encore rendus (tuneHeldKey). // tuneHeld : verrous pris par CE processus et pas encore rendus (tuneHeldKey).
var tuneHeld sync.Map var tuneHeld sync.Map
// tuneFileMu sérialise, DANS CE PROCESSUS, les opérations sur le fichier de
// verrou : prise, réécriture, rendu, et le déplacement-relecture du ménage
// (tuneReapStale). Sans lui, le ménage pouvait déplacer un verrou vivant
// pendant que release() le cherchait : release, ne le trouvant plus, rendait
// l'inscription ; le ménage le remettait ensuite en place, désormais orphelin,
// et le ménage suivant le prenait pour une optimisation interrompue (moteur
// relancé, preset « rétabli »). Vu en CI : TestTuneRecoverNeverReapsOwnLock.
var tuneFileMu sync.Mutex
func tuneHeldKey(o tuneOwner) string { return fmt.Sprintf("%s|%d|%d", o.Via, o.Since, o.Nonce) } func tuneHeldKey(o tuneOwner) string { return fmt.Sprintf("%s|%d|%d", o.Via, o.Since, o.Nonce) }
// tuneOwnerAlive : le propriétaire d'un verrou vit-il encore ? Un verrou qui // tuneOwnerAlive : le propriétaire d'un verrou vit-il encore ? Un verrou qui
@@ -201,6 +210,7 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
key := tuneHeldKey(l.owner) key := tuneHeldKey(l.owner)
tuneHeld.Store(key, true) tuneHeld.Store(key, true)
for attempt := 0; attempt < 3; attempt++ { for attempt := 0; attempt < 3; attempt++ {
tuneFileMu.Lock()
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err == nil { if err == nil {
b, _ := json.Marshal(l.owner) b, _ := json.Marshal(l.owner)
@@ -209,11 +219,14 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
if werr != nil || cerr != nil { if werr != nil || cerr != nil {
_ = os.Remove(path) _ = os.Remove(path)
tuneHeld.Delete(key) tuneHeld.Delete(key)
tuneFileMu.Unlock()
return nil, fmt.Errorf("verrou de l'optimiseur : %v", errors.Join(werr, cerr)) return nil, fmt.Errorf("verrou de l'optimiseur : %v", errors.Join(werr, cerr))
} }
l.held = true l.held = true
tuneFileMu.Unlock()
return l, nil return l, nil
} }
tuneFileMu.Unlock()
if !os.IsExist(err) { if !os.IsExist(err) {
tuneHeld.Delete(key) tuneHeld.Delete(key)
return nil, err return nil, err
@@ -232,6 +245,13 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
// write réécrit le verrou d'un bloc (fichier voisin puis renommage) : un // write réécrit le verrou d'un bloc (fichier voisin puis renommage) : un
// lecteur ne voit jamais un JSON à moitié écrit. // lecteur ne voit jamais un JSON à moitié écrit.
func (l *tuneLock) write() error { func (l *tuneLock) write() error {
tuneFileMu.Lock()
defer tuneFileMu.Unlock()
return l.writeLocked()
}
// writeLocked : write, tuneFileMu déjà tenu par l'appelant.
func (l *tuneLock) writeLocked() error {
b, err := json.Marshal(l.owner) b, err := json.Marshal(l.owner)
if err != nil { if err != nil {
return err return err
@@ -291,6 +311,8 @@ func (l *tuneLock) setBackup(backup, id, name string) {
func (l *tuneLock) release() { func (l *tuneLock) release() {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
tuneFileMu.Lock()
defer tuneFileMu.Unlock()
if l.held { if l.held {
// Un fichier absent un instant (renommé par un ménage qui le remet en // Un fichier absent un instant (renommé par un ménage qui le remet en
// place, voir tuneReapStale) n'est pas un fichier rendu : on relit. // place, voir tuneReapStale) n'est pas un fichier rendu : on relit.
@@ -305,7 +327,7 @@ func (l *tuneLock) release() {
if ours { if ours {
l.owner.Phase, l.owner.Trial, l.owner.MainWasActive = "rendu", nil, false l.owner.Phase, l.owner.Trial, l.owner.MainWasActive = "rendu", nil, false
l.owner.Backup, l.owner.PresetID, l.owner.PresetName = "", "", "" l.owner.Backup, l.owner.PresetID, l.owner.PresetName = "", "", ""
_ = l.write() _ = l.writeLocked()
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
if err := os.Remove(l.path); err == nil || os.IsNotExist(err) { if err := os.Remove(l.path); err == nil || os.IsNotExist(err) {
break break
@@ -328,6 +350,24 @@ func (l *tuneLock) release() {
// périmé). Le renommage d'abord : de deux processus qui trouvent le même verrou // périmé). Le renommage d'abord : de deux processus qui trouvent le même verrou
// périmé, un seul l'emporte, et aucun ne retire un verrou neuf posé entre-temps. // périmé, un seul l'emporte, et aucun ne retire un verrou neuf posé entre-temps.
func tuneReapStale() (mainWasActive bool) { func tuneReapStale() (mainWasActive bool) {
o, ok := tuneReapClaim()
if !ok {
return false
}
if o.Trial != nil && tuneProcAlive(*o.Trial) {
fmt.Fprintf(os.Stderr, "[loki tune] essai orphelin (PID %d) d'une optimisation interrompue : arrêt\n", o.Trial.PID)
tuneKillTree(o.Trial.PID, nil)
}
tuneUndoApply(o)
return o.MainWasActive
}
// tuneReapClaim : la partie de tuneReapStale qui touche au fichier, sous
// tuneFileMu — lecture, déplacement, relecture, remise en place. Rend le
// verrou périmé effectivement retiré (ok=false : rien à écarter).
func tuneReapClaim() (tuneOwner, bool) {
tuneFileMu.Lock()
defer tuneFileMu.Unlock()
path := tuneLockPath() path := tuneLockPath()
o, ok := readTuneOwner(path) o, ok := readTuneOwner(path)
if !ok { if !ok {
@@ -338,14 +378,14 @@ func tuneReapStale() (mainWasActive bool) {
if fi, err := os.Stat(path); err == nil && time.Since(fi.ModTime()) > tuneLockFreshness { if fi, err := os.Stat(path); err == nil && time.Since(fi.ModTime()) > tuneLockFreshness {
_ = os.Remove(path) _ = os.Remove(path)
} }
return false return tuneOwner{}, false
} }
if tuneOwnerAlive(o) { if tuneOwnerAlive(o) {
return false return tuneOwner{}, false
} }
stale := fmt.Sprintf("%s.stale-%d-%d", path, os.Getpid(), time.Now().UnixNano()) stale := fmt.Sprintf("%s.stale-%d-%d", path, os.Getpid(), time.Now().UnixNano())
if os.Rename(path, stale) != nil { if os.Rename(path, stale) != nil {
return false return tuneOwner{}, false
} }
defer os.Remove(stale) defer os.Remove(stale)
// Entre la lecture et le renommage, le verrou lu a pu être rendu et un // Entre la lecture et le renommage, le verrou lu a pu être rendu et un
@@ -358,14 +398,9 @@ func tuneReapStale() (mainWasActive bool) {
if err := os.Link(stale, path); err != nil { if err := os.Link(stale, path); err != nil {
fmt.Fprintf(os.Stderr, "[loki tune] verrou vivant déplacé par erreur, remise en place impossible : %v\n", err) fmt.Fprintf(os.Stderr, "[loki tune] verrou vivant déplacé par erreur, remise en place impossible : %v\n", err)
} }
return false return tuneOwner{}, false
} }
if o.Trial != nil && tuneProcAlive(*o.Trial) { return o, true
fmt.Fprintf(os.Stderr, "[loki tune] essai orphelin (PID %d) d'une optimisation interrompue : arrêt\n", o.Trial.PID)
tuneKillTree(o.Trial.PID, nil)
}
tuneUndoApply(o)
return o.MainWasActive
} }
// sameTuneLock : a et b désignent-ils la même prise de verrou (propriétaire et // sameTuneLock : a et b désignent-ils la même prise de verrou (propriétaire et