diff --git a/internal/loki/backend_build.go b/internal/loki/backend_build.go index cb8ca79..5f12a7f 100644 --- a/internal/loki/backend_build.go +++ b/internal/loki/backend_build.go @@ -44,20 +44,45 @@ func emitBuildLine(line string) { // Sert à téer la sortie des commandes de runStep quand un sink est actif. // (mutex : stdout et stderr d'une même commande peuvent écrire en parallèle) type sinkWriter struct { - mu sync.Mutex - buf []byte + mu sync.Mutex + buf []byte + lastProg time.Time // dernière ligne de progression (\r) émise } +// sinkProgressEvery : une barre de progression (tqdm, téléchargements de +// l'installeur de Strata) se redessine sur place avec \r, sans jamais finir sa +// ligne. On en montre une version toutes les 10 s : sans elle, un téléchargement +// de 76 Go laissait le journal figé pendant une heure ; toutes, elles le noieraient. +const sinkProgressEvery = 10 * time.Second + func (s *sinkWriter) Write(p []byte) (int, error) { s.mu.Lock() defer s.mu.Unlock() s.buf = append(s.buf, p...) for { - i := strings.IndexByte(string(s.buf), '\n') + i := strings.IndexAny(string(s.buf), "\r\n") if i < 0 { break } - emitBuildLine(strings.TrimRight(string(s.buf[:i]), "\r")) + if s.buf[i] == '\n' { + emitBuildLine(strings.TrimRight(string(s.buf[:i]), "\r")) + s.buf = s.buf[i+1:] + continue + } + // \r : fin de ligne Windows (\r\n) ou barre qui se redessine. Le \n peut + // arriver dans l'écriture suivante : on attend de le savoir. + if i+1 >= len(s.buf) { + break + } + if s.buf[i+1] == '\n' { + emitBuildLine(string(s.buf[:i])) + s.buf = s.buf[i+2:] + continue + } + if prog := strings.TrimSpace(string(s.buf[:i])); prog != "" && time.Since(s.lastProg) >= sinkProgressEvery { + s.lastProg = time.Now() + emitBuildLine(prog) + } s.buf = s.buf[i+1:] } return len(p), nil diff --git a/internal/loki/backend_build_sink_test.go b/internal/loki/backend_build_sink_test.go new file mode 100644 index 0000000..006e578 --- /dev/null +++ b/internal/loki/backend_build_sink_test.go @@ -0,0 +1,29 @@ +package loki + +import ( + "reflect" + "testing" + "time" +) + +// Une barre de progression (\r sans \n) doit apparaître dans le journal, sans +// le noyer ; \r\n reste une fin de ligne ordinaire, même coupé entre deux écritures. +func TestSinkWriterProgression(t *testing.T) { + var got []string + setBuildSink(func(l string) { got = append(got, l) }) + defer setBuildSink(nil) + + s := &sinkWriter{} + s.Write([]byte("Step 5\r")) + s.Write([]byte("\n modèle : 1 / 76 GB\r modèle : 2 / 76 GB\r")) + s.Write([]byte(" modèle : 3 / 76 GB\r")) + s.lastProg = time.Now().Add(-time.Minute) // 10 s écoulées + s.Write([]byte(" modèle : 40 / 76 GB\r[ok] fini\n")) + + // « 3 » finissait sa propre écriture : il attendait de savoir si un saut de + // ligne suivait, et part donc avec l'écriture suivante. + want := []string{"Step 5", "modèle : 1 / 76 GB", "modèle : 3 / 76 GB", "[ok] fini"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("lignes %q, attendu %q", got, want) + } +} diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 237c712..eb7ec46 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -9097,10 +9097,21 @@ function lcStartPolling(){ lcPollJob(); } +// Une seule requête à la fois : l'intervalle d'une seconde relançait un appel +// avant la réponse du précédent quand le serveur était occupé (téléchargement, +// accès distant), et les deux ajoutaient les MÊMES lignes au journal. +let lcPollInflight = false; async function lcPollJob(quiet){ + if(lcPollInflight) return; + lcPollInflight = true; + const from = lcLogNext; let j; - try{ j = await jget('/api/llamacpp/job?from='+lcLogNext); }catch(_){ return; } + try{ j = await jget('/api/llamacpp/job?from='+from); }catch(_){ return; } + finally{ lcPollInflight = false; } if(!j.exists) return; + // Le journal a été remis à zéro ou avancé entre-temps (nouveau job) : ces + // lignes ne suivent plus ce qui est affiché. + if(from !== lcLogNext) return; const phaseEl = document.getElementById('lc-job-phase'); if(j.lines && j.lines.length){ const pre = document.getElementById('lc-log'); diff --git a/internal/loki/ui/src/js/10-llamacpp.js b/internal/loki/ui/src/js/10-llamacpp.js index 0a49cd0..82fa646 100644 --- a/internal/loki/ui/src/js/10-llamacpp.js +++ b/internal/loki/ui/src/js/10-llamacpp.js @@ -453,10 +453,21 @@ function lcStartPolling(){ lcPollJob(); } +// Une seule requête à la fois : l'intervalle d'une seconde relançait un appel +// avant la réponse du précédent quand le serveur était occupé (téléchargement, +// accès distant), et les deux ajoutaient les MÊMES lignes au journal. +let lcPollInflight = false; async function lcPollJob(quiet){ + if(lcPollInflight) return; + lcPollInflight = true; + const from = lcLogNext; let j; - try{ j = await jget('/api/llamacpp/job?from='+lcLogNext); }catch(_){ return; } + try{ j = await jget('/api/llamacpp/job?from='+from); }catch(_){ return; } + finally{ lcPollInflight = false; } if(!j.exists) return; + // Le journal a été remis à zéro ou avancé entre-temps (nouveau job) : ces + // lignes ne suivent plus ce qui est affiché. + if(from !== lcLogNext) return; const phaseEl = document.getElementById('lc-job-phase'); if(j.lines && j.lines.length){ const pre = document.getElementById('lc-log');