fix(bg): Hintergrundarbeit reisst den Prozess nicht mehr mit
Das Vorschaubild entstand in einer nackten Goroutine. Chis Recoverer haengt
am Anfragebehandler und sieht davon nichts -- eine Panik im Bilddekodierer
nahm den ganzen Go-Prozess mit, ausgeloest durch einen gewoehnlichen
Upload. Die Bibliothek ist seit ef60497 aktuell; das schloss **diese**
Luecke. Dies hier schliesst die **Gattung**.
pkg/bg mit zwei Funktionen, und der Unterschied ist keine Feinheit:
* bg.Go(name, fn) -- Goroutine, die eine Panik ueberlebt
* bg.Run(name, fn) -- dasselbe ohne eigene Goroutine, fuer Schleifen
Faengt man eine Panik um den **ganzen** Arbeiter ab, ueberlebt der Prozess
-- aber der Arbeiter ist tot, und von da an wird still keine Datei mehr
erkannt. Der OCR-Arbeiter sichert deshalb jeden **Auftrag** einzeln ab.
Umgestellt: Vorschaubild, Bestand nachtragen, Freigabe-Zaehler,
Speicherbereinigung, OCR-Arbeiter. Eine nackte Goroutine bleibt mit
Begruendung im Code -- die Signalbehandlung; ein recover braechte dort
nichts, weil main ohnehin wartete.
Aufgefangene Paniken werden mit Stapel und Herkunft protokolliert. Vier
Tests, darunter der wichtige: Eine Schleife laeuft nach einer Panik im
dritten Durchgang weiter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
89f0817dcb
commit
c26bcba012
7 files changed
+156
-17
No files matched your search
+22
-6
@@ -39,11 +39,28 @@ es: `internal/sharing/handler.go:232` und `main.go:185` (beide nur Datenbank,
|
||||
harmlos), `internal/gc/gc.go:257`, und der OCR-Arbeiter (`go worker.Run(ctx)`),
|
||||
der Fremdprogramme aufruft und deren Ausgabe liest.
|
||||
|
||||
**Vorschlag:** Ein kleines `sicher(func())` in `pkg/middleware` oder daneben,
|
||||
das `recover()` macht und die Panik protokolliert, statt sie durchzulassen —
|
||||
und alle Hintergrund-Goroutinen darüber starten. Fünf Zeilen, eine Stelle.
|
||||
**✅ Erledigt am 12.09.2026.** `pkg/bg` mit zwei Funktionen:
|
||||
|
||||
> Der Unterschied ist wichtig: Die Version zu heben schließt **diese** Lücke.
|
||||
* `bg.Go(name, fn)` – startet eine Goroutine, die eine Panik überlebt.
|
||||
* `bg.Run(name, fn)` – dasselbe ohne eigene Goroutine, für Schleifen.
|
||||
|
||||
Der Unterschied ist keine Feinheit: Fängt man eine Panik um den **ganzen**
|
||||
Arbeiter herum ab, überlebt der Prozess – aber der Arbeiter ist tot, und von da
|
||||
an wird still keine Datei mehr erkannt. Der OCR-Arbeiter sichert deshalb jeden
|
||||
**Auftrag** einzeln ab; ein unverdauliches Dokument kostet dann genau sich
|
||||
selbst.
|
||||
|
||||
Umgestellt: Vorschaubild, Bestand nachtragen, Freigabe-Zähler,
|
||||
Speicherbereinigung, OCR-Arbeiter. Eine nackte Goroutine bleibt mit Begründung
|
||||
im Code – die Signalbehandlung beim Herunterfahren; ein recover brächte dort
|
||||
nichts, weil `main` ohnehin wartete, und sie fasst keine fremden Daten an.
|
||||
|
||||
Eine aufgefangene Panik wird mit Stapel und Herkunftsname protokolliert. Sie
|
||||
ist ein Fehler und bleibt einer – sie reißt nur nichts mehr mit. Vier Tests in
|
||||
`pkg/bg/bg_test.go`, darunter der, auf den es ankommt: Eine Schleife läuft nach
|
||||
einer Panik im dritten Durchgang weiter.
|
||||
|
||||
> Der Unterschied ist wichtig: Die Version zu heben schloss **diese** Lücke.
|
||||
> Der `recover` schließt die **Gattung**.
|
||||
|
||||
---
|
||||
@@ -145,8 +162,7 @@ gibt:
|
||||
|
||||
## Reihenfolge, die ich vorschlagen würde
|
||||
|
||||
1. **`recover` in die Hintergrund-Goroutinen** (Punkt 1, Rest). Klein, und es
|
||||
schließt eine Gattung statt eines Einzelfalls.
|
||||
1. ~~`recover` in die Hintergrund-Goroutinen~~ ✅ 12.09.2026
|
||||
2. **Prüfsummen-Abweichung melden** (Punkt 2). Zehn Zeilen für eine Auskunft,
|
||||
die es sonst nie gibt.
|
||||
3. **Tests für `documents`** (Punkt 3). Das Paket, dessen stilles Versagen am
|
||||
|
||||
@@ -38,6 +38,7 @@ import (
|
||||
"aether/internal/tags"
|
||||
"aether/internal/thumbnails"
|
||||
appwebdav "aether/internal/webdav"
|
||||
"aether/pkg/bg"
|
||||
"aether/pkg/config"
|
||||
appdb "aether/pkg/db"
|
||||
"aether/pkg/middleware"
|
||||
@@ -176,13 +177,16 @@ func main() {
|
||||
Timeout: cfg.OCRTimeout,
|
||||
TmpDir: cfg.TmpPath,
|
||||
}, log.Logger)
|
||||
go worker.Run(ctx)
|
||||
// Die Schleife darin sichert jeden Auftrag einzeln ab (siehe
|
||||
// worker.Run). Diese Hülle fängt, was **davor** passiert – das
|
||||
// Aufräumen der Zwischendateien beim Start.
|
||||
bg.Go("OCR-Arbeiter", func() { worker.Run(ctx) })
|
||||
|
||||
// Bestand nachtragen: Beim ersten Start nach dem Einbau hat noch
|
||||
// keine einzige Datei einen Auftrag. Ohne das bliebe alles
|
||||
// unauffindbar, was vorher hochgeladen wurde – und das ist am
|
||||
// Anfang der gesamte Bestand.
|
||||
go func() {
|
||||
bg.Go("Bestand nachtragen", func() {
|
||||
n, err := docStore.EnqueueMissing(ctx, 5000)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Bestand nachtragen fehlgeschlagen")
|
||||
@@ -205,7 +209,7 @@ func main() {
|
||||
log.Info().Int("files", m).Int("version", documents.ExtractVersion).
|
||||
Msg("veraltete Texteintraege zur erneuten Erkennung eingeplant")
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,6 +637,10 @@ func main() {
|
||||
done := make(chan struct{})
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
// Die einzige nackte Goroutine, die bleibt – und das mit Absicht: Sie
|
||||
// wartet auf ein Signal und schließt `done`. Ein recover brächte hier
|
||||
// nichts, denn stürbe sie vor dem close, wartete main ohnehin für immer.
|
||||
// Sie berührt außerdem keine fremden Daten.
|
||||
go func() {
|
||||
<-quit
|
||||
log.Info().Msg("shutting down...")
|
||||
@@ -698,8 +706,14 @@ func afterUpload(fh *files.Handler, fileStore *files.Store, st storage.Storage,
|
||||
return
|
||||
}
|
||||
|
||||
// Generate thumbnail in background – non-fatal if it fails.
|
||||
go func() {
|
||||
// Vorschaubild im Hintergrund – ein Fehlschlag ist nicht schlimm.
|
||||
//
|
||||
// **Über bg.Go, nicht als nackte Goroutine.** Hier wird eine
|
||||
// hochgeladene Datei dekodiert. Chis Recoverer hängt am
|
||||
// Anfragebehandler und sieht davon nichts; eine Panik im Dekodierer
|
||||
// nähme sonst den ganzen Prozess mit – ausgelöst durch einen
|
||||
// gewöhnlichen Upload.
|
||||
bg.Go("Vorschaubild", func() {
|
||||
ctx := context.Background()
|
||||
f, err := fileStore.GetFileByID(ctx, created.ID)
|
||||
if err != nil {
|
||||
@@ -715,7 +729,7 @@ func afterUpload(fh *files.Handler, fileStore *files.Store, st storage.Storage,
|
||||
return
|
||||
}
|
||||
_ = fileStore.UpdateThumbnail(ctx, created.ID, true)
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/bg"
|
||||
)
|
||||
|
||||
// idleInterval ist die Pause, wenn nichts zu tun ist.
|
||||
@@ -67,7 +68,13 @@ func (w *Worker) Run(ctx context.Context) {
|
||||
|
||||
for {
|
||||
wait := idleInterval
|
||||
if w.step(ctx) {
|
||||
// **Je Auftrag abgesichert, nicht je Arbeiter.** Fängt man eine Panik
|
||||
// um die ganze Schleife ab, überlebt zwar der Prozess – aber der
|
||||
// Arbeiter ist tot, und von da an wird still keine Datei mehr erkannt.
|
||||
// So kostet ein unverdauliches Dokument genau sich selbst.
|
||||
var etwasGetan bool
|
||||
bg.Run("Texterkennung", func() { etwasGetan = w.step(ctx) })
|
||||
if etwasGetan {
|
||||
wait = busyInterval
|
||||
}
|
||||
select {
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/bg"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -254,7 +255,9 @@ func (c *Collector) referencedKeys(ctx context.Context) (map[string]bool, error)
|
||||
// im Intervall. Der erste Durchlauf ist verzögert, damit er nicht mit dem
|
||||
// Hochfahren konkurriert.
|
||||
func (c *Collector) Start(ctx context.Context, onDone func(Result)) {
|
||||
go func() {
|
||||
// Über bg.Go: Der GC liest Verzeichnisse und Datenbankzeilen, und ein
|
||||
// Absturz darin darf nicht den Dienst mitnehmen, den er aufräumen soll.
|
||||
bg.Go("Speicherbereinigung", func() {
|
||||
timer := time.NewTimer(5 * time.Minute)
|
||||
defer timer.Stop()
|
||||
|
||||
@@ -282,7 +285,7 @@ func (c *Collector) Start(ctx context.Context, onDone func(Result)) {
|
||||
}
|
||||
timer.Reset(Interval)
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// DerivedUsage: Wie viel Platz die **abgeleiteten** Daten belegen.
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"aether/internal/auth"
|
||||
"aether/internal/files"
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/bg"
|
||||
"aether/pkg/middleware"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
@@ -229,10 +230,10 @@ func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
// abgebrochen, sobald die Antwort draußen ist – die Zählung verlor das
|
||||
// Rennen fast immer und der Zähler blieb auf 0 stehen.
|
||||
zaehlCtx, abbrechen := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second)
|
||||
go func() {
|
||||
bg.Go("Freigabe-Zaehler", func() {
|
||||
defer abbrechen()
|
||||
h.store.IncrementDownloads(zaehlCtx, token)
|
||||
}()
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", f.MimeType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, f.Name))
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Package bg startet Hintergrundarbeit, die den Prozess nicht mitreißen kann.
|
||||
//
|
||||
// **Der Anlass ist gemessen, nicht theoretisch.** Aether erzeugt das
|
||||
// Vorschaubild einer hochgeladenen Datei in einer eigenen Goroutine. Chis
|
||||
// `Recoverer` hängt am Anfragebehandler und sieht davon nichts – eine Panik
|
||||
// dort nimmt den **ganzen Go-Prozess** mit. Am 12.09.2026 stand dafür sogar
|
||||
// eine passende Lücke offen (GO-2026-5061, Panik im WebP-Dekodierer): Ein
|
||||
// gewöhnlicher Bild-Upload hätte den Server umgelegt.
|
||||
//
|
||||
// Die Bibliothek zu aktualisieren schließt **diese** Lücke. Diese Datei
|
||||
// schließt die **Gattung**: Bilddekodierer, Fremdformate und externe Programme
|
||||
// sind Felder, in denen regelmäßig etwas gefunden wird, und keines davon darf
|
||||
// über einen Weg erreichbar sein, an dessen Ende der Prozess stirbt.
|
||||
//
|
||||
// Was hier *nicht* passiert: Fehler verschlucken. Eine aufgefangene Panik wird
|
||||
// mit Stapel protokolliert. Sie ist ein Fehler und bleibt einer – sie reißt
|
||||
// nur nichts mehr mit.
|
||||
package bg
|
||||
|
||||
import (
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Go startet fn in einer Goroutine, die eine Panik überlebt.
|
||||
//
|
||||
// name steht im Logeintrag und sollte sagen, *was* da lief ("Vorschaubild",
|
||||
// "Texterkennung") – eine Zeile „panic recovered" ohne Herkunft hilft um drei
|
||||
// Uhr nachts niemandem.
|
||||
func Go(name string, fn func()) {
|
||||
go Run(name, fn)
|
||||
}
|
||||
|
||||
// Run ist dasselbe ohne eigene Goroutine: für Schleifen, in denen **ein**
|
||||
// Durchgang scheitern darf, ohne dass die Schleife endet.
|
||||
//
|
||||
// Der Unterschied ist keine Feinheit. Fängt man eine Panik um den ganzen
|
||||
// Arbeiter herum ab, überlebt der Prozess – aber der Arbeiter ist tot, und
|
||||
// von da an wird still keine Datei mehr erkannt. Fängt man sie um den
|
||||
// einzelnen Auftrag ab, verliert man den einen Auftrag und macht weiter.
|
||||
func Run(name string, fn func()) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error().
|
||||
Str("wo", name).
|
||||
Interface("panik", r).
|
||||
Str("stapel", string(debug.Stack())).
|
||||
Msg("Hintergrundarbeit abgestürzt – aufgefangen")
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package bg
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunFaengtPanikAuf(t *testing.T) {
|
||||
// Ohne das recover in Run stürbe hier der Testprozess – das ist zugleich
|
||||
// die Aussage des Tests.
|
||||
Run("test", func() { panic("absichtlich") })
|
||||
}
|
||||
|
||||
func TestRunLaesstErgebnisDurch(t *testing.T) {
|
||||
gelaufen := false
|
||||
Run("test", func() { gelaufen = true })
|
||||
if !gelaufen {
|
||||
t.Fatal("fn wurde nicht ausgeführt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoLaeuftUndUeberlebt(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
Go("heil", func() { defer wg.Done() })
|
||||
Go("panisch", func() { defer wg.Done(); panic("absichtlich") })
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Eine Schleife, in der ein Durchgang scheitert, muss weiterlaufen – das ist
|
||||
// der Grund, warum es Run neben Go gibt.
|
||||
func TestSchleifeLaeuftNachPanikWeiter(t *testing.T) {
|
||||
durchgaenge := 0
|
||||
for i := range 5 {
|
||||
Run("schritt", func() {
|
||||
durchgaenge++
|
||||
if i == 2 {
|
||||
panic("mittendrin")
|
||||
}
|
||||
})
|
||||
}
|
||||
if durchgaenge != 5 {
|
||||
t.Fatalf("nur %d von 5 Durchgängen", durchgaenge)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user