diff --git a/BEFUNDE.md b/BEFUNDE.md index 6467b28..6089801 100644 --- a/BEFUNDE.md +++ b/BEFUNDE.md @@ -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 diff --git a/backend/cmd/aether/main.go b/backend/cmd/aether/main.go index 5dfdb3c..a9c09a1 100644 --- a/backend/cmd/aether/main.go +++ b/backend/cmd/aether/main.go @@ -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) - }() + }) } } diff --git a/backend/internal/documents/worker.go b/backend/internal/documents/worker.go index d183baf..9d532c1 100644 --- a/backend/internal/documents/worker.go +++ b/backend/internal/documents/worker.go @@ -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 { diff --git a/backend/internal/gc/gc.go b/backend/internal/gc/gc.go index ed50c95..5c365fe 100644 --- a/backend/internal/gc/gc.go +++ b/backend/internal/gc/gc.go @@ -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. diff --git a/backend/internal/sharing/handler.go b/backend/internal/sharing/handler.go index 02545e9..d5970f2 100644 --- a/backend/internal/sharing/handler.go +++ b/backend/internal/sharing/handler.go @@ -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)) diff --git a/backend/pkg/bg/bg.go b/backend/pkg/bg/bg.go new file mode 100644 index 0000000..2f0d8ea --- /dev/null +++ b/backend/pkg/bg/bg.go @@ -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() +} diff --git a/backend/pkg/bg/bg_test.go b/backend/pkg/bg/bg_test.go new file mode 100644 index 0000000..a2f910d --- /dev/null +++ b/backend/pkg/bg/bg_test.go @@ -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) + } +}