From c56bd4d943e5344b5afdb43a979f8e10811d19ac Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 1 Nov 2026 13:16:58 +0100 Subject: [PATCH] fix(server): stop the background compaction before the database is closed The compaction starts right away in NewServer and could still write to the SQLite files while a test's temp dir was removed ("directory not empty" on the CI runner, TestWebhookTokenInURLOnlyAPIKey). Server.Close stops it and waits for a running pass; tests call it before closing the store, and the server calls it on shutdown before the deferred store.Close. v0.37.1. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +++++++- README.md | 2 +- cmd/server/main.go | 2 ++ frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- internal/api/demo_test.go | 1 + internal/api/docsync.go | 7 ++++++- internal/api/s4_test.go | 1 + internal/api/server.go | 15 +++++++++++++++ internal/api/server_test.go | 7 +++++++ 10 files changed, 43 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de8156c..095c960 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.37.1] - 2026-11-01 + +### Fixed +- Beim Herunterfahren wartet der Server, bis eine laufende Verdichtung des Notiz-Protokolls fertig ist, bevor er die Datenbank schließt. Dieselbe Lücke ließ einen Test auf dem Build-Server gelegentlich scheitern. + ## [0.37.0] - 2026-11-01 ### Added @@ -617,7 +622,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.1...dev +[0.37.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.0...v0.37.1 [0.37.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.36.0...v0.37.0 [0.36.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.35.0...v0.36.0 [0.35.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.34.0...v0.35.0 diff --git a/README.md b/README.md index 766ddae..7ced83e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.37.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.37.1-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. diff --git a/cmd/server/main.go b/cmd/server/main.go index 65d54cb..9d64132 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -103,4 +103,6 @@ func main() { if err := httpServer.Shutdown(ctx); err != nil { slog.Warn("HTTP server shutdown finished with error", "error", err) } + // Let a running compaction finish before the deferred store.Close + srv.Close() } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 13c9062..7ea1b7d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.37.0", + "version": "0.37.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.37.0", + "version": "0.37.1", "license": "Unlicense", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index ebef55b..d72b9f7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.37.0", + "version": "0.37.1", "license": "Unlicense", "type": "module", "scripts": { diff --git a/internal/api/demo_test.go b/internal/api/demo_test.go index 7dfb165..520eb58 100644 --- a/internal/api/demo_test.go +++ b/internal/api/demo_test.go @@ -26,6 +26,7 @@ func TestDemoResetKeepsSessionsAndRestoresSampleData(t *testing.T) { t.Fatal(err) } srv := NewServer(&config.Config{DatabasePath: dbPath, JWTSecret: "demo-secret", DemoMode: true}, store, ws.NewHub(), nil) + t.Cleanup(srv.Close) router := srv.Router() call := func(method, path, token, body string) *httptest.ResponseRecorder { req := httptest.NewRequest(method, path, strings.NewReader(body)) diff --git a/internal/api/docsync.go b/internal/api/docsync.go index 0c14cf4..f47945d 100644 --- a/internal/api/docsync.go +++ b/internal/api/docsync.go @@ -266,9 +266,14 @@ func (s *Server) handleDocLeave(w http.ResponseWriter, r *http.Request) { const compactionInterval = 6 * time.Hour func (s *Server) startCompaction() { + defer s.bg.Done() for { s.compactOnce() - time.Sleep(compactionInterval) + select { + case <-s.stop: + return + case <-time.After(compactionInterval): + } } } diff --git a/internal/api/s4_test.go b/internal/api/s4_test.go index 8416d4d..395d456 100644 --- a/internal/api/s4_test.go +++ b/internal/api/s4_test.go @@ -24,6 +24,7 @@ func TestDemoSendsNothingAndKeepsAccounts(t *testing.T) { t.Fatal(err) } srv := NewServer(&config.Config{DatabasePath: dbPath, JWTSecret: "demo-secret", DemoMode: true}, store, ws.NewHub(), nil) + t.Cleanup(srv.Close) router := srv.Router() token := sessionLogin(t, router, "admin", "admin", "visitor-1") _ = sessionLogin(t, router, "admin", "admin", "visitor-2") diff --git a/internal/api/server.go b/internal/api/server.go index 2345220..2535eaf 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "strings" + "sync" "time" "github.com/go-chi/chi/v5" @@ -41,6 +42,11 @@ type Server struct { notifyPushHook func(userID string, msg pushMessage) error pushHTTPClient *http.Client // tests: reach a local push service push pushKeys + + // Background work that touches the database right from the start (compaction); Close stops it + stop chan struct{} + stopOnce sync.Once + bg sync.WaitGroup } func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server { @@ -51,6 +57,7 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS staticFS: staticFS, leases: collab.NewLeaseManager(nil), notifier: collab.NewNotifier(), + stop: make(chan struct{}), } srv.loginGuard = newLoginGuard(!cfg.DisableLoginProtection) srv.voice = newVoiceService(cfg.Speech.MaxParallel) @@ -63,6 +70,7 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS store.OnTaskCreated = func(th *domain.Thought) { srv.wsHub.BroadcastToUsers(srv.taskAudience(th), "TASK_CREATED", th) } + srv.bg.Add(1) go srv.startCompaction() if cfg.DemoMode && cfg.DemoResetMinutes > 0 { go srv.startDemoResetTicker() @@ -93,6 +101,13 @@ func (s *Server) taskAudience(th *domain.Thought) []string { return append(s.store.GetTaskListUserIDs(th.TaskListID), th.UserID) } +// Close stops the background compaction and waits until a running pass has finished, so the database +// can be closed safely afterwards (tests close it right away; a pass still writing would race with that). +func (s *Server) Close() { + s.stopOnce.Do(func() { close(s.stop) }) + s.bg.Wait() +} + func (s *Server) Router() http.Handler { r := chi.NewRouter() diff --git a/internal/api/server_test.go b/internal/api/server_test.go index f0617d3..3731b6e 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -46,6 +46,7 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) { } hub := ws.NewHub() srv := NewServer(cfg, store, hub, nil) + t.Cleanup(srv.Close) token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion, testSession(t, store, user.ID)) if err != nil { @@ -71,6 +72,7 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) { AllowRegistration: false, } srv := NewServer(cfg, store, ws.NewHub(), nil) + t.Cleanup(srv.Close) router := srv.Router() // 1. Fresh DB must report needsSetup = true @@ -455,6 +457,7 @@ func TestLoginWithUsernameAndDemoMode(t *testing.T) { DemoMode: true, } srv := NewServer(cfg, store, ws.NewHub(), nil) + t.Cleanup(srv.Close) router := srv.Router() // 1. Check auth status reports demoMode: true @@ -542,6 +545,7 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) { JWTSecret: "test-secret", } srv := NewServer(cfg, store, ws.NewHub(), nil) + t.Cleanup(srv.Close) router := srv.Router() adminUser, err := store.GetUserByLogin("admin") @@ -771,6 +775,7 @@ func TestWebhookTriageBadgeAndIngestion(t *testing.T) { AllowRegistration: false, } srv := NewServer(cfg, store, ws.NewHub(), nil) + t.Cleanup(srv.Close) router := srv.Router() // 1. Setup user @@ -864,6 +869,7 @@ func TestSSEGlobalEndpoint(t *testing.T) { AllowRegistration: false, } srv := NewServer(cfg, store, hub, nil) + t.Cleanup(srv.Close) router := srv.Router() setupBody := []byte(`{ @@ -1345,6 +1351,7 @@ func TestFirstBootSetupWithSampleData(t *testing.T) { } defer store.Close() srv := NewServer(&config.Config{Port: "8080", DatabasePath: dbPath, JWTSecret: "test-jwt-secret-key"}, store, ws.NewHub(), nil) + t.Cleanup(srv.Close) // Too short passwords are refused (FLAW-09) short := httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", strings.NewReader(`{"email":"me@example.com","password":"secret"}`))