Skip to content

Commit 0ecd70c

Browse files
authored
Merge pull request #248 from Optic00/codex/server-cache-shutdown
fix: stop server-owned cache workers during shutdown
2 parents 7523bda + b6f2574 commit 0ecd70c

6 files changed

Lines changed: 123 additions & 30 deletions

File tree

internal/auth/scim_tokens.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"errors"
99
"fmt"
1010
"log/slog"
11+
"sync"
1112
"time"
1213

1314
"windshift/internal/cacheutil"
@@ -30,8 +31,21 @@ type scimTokenCacheEntry struct {
3031

3132
// SCIMTokenManager handles SCIM token operations
3233
type SCIMTokenManager struct {
33-
db database.Database
34-
cache *bigcache.BigCache
34+
db database.Database
35+
cache *bigcache.BigCache
36+
closeOnce sync.Once
37+
closeErr error
38+
}
39+
40+
// Close stops the validation cache's background worker without closing the
41+
// shared database. It may be called more than once, including with no cache.
42+
func (tm *SCIMTokenManager) Close() error {
43+
tm.closeOnce.Do(func() {
44+
if tm.cache != nil {
45+
tm.closeErr = tm.cache.Close()
46+
}
47+
})
48+
return tm.closeErr
3549
}
3650

3751
// NewSCIMTokenManager creates a new SCIM token manager

internal/auth/session.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"log/slog"
1111
"net"
1212
"net/http"
13+
"sync"
1314
"time"
1415

1516
"windshift/internal/database"
@@ -54,13 +55,26 @@ type SessionManager struct {
5455
db database.Database
5556
opaqueKey []byte
5657
sessionValidation *sessionValidator
58+
closeOnce sync.Once
59+
closeErr error
5760
// ipBinding is the resolved SESSION_IP_BINDING mode (config.SessionIPBinding*)
5861
// that session validation applies to a client-IP change. An unknown or
5962
// zero value is treated as strict so managers built without config.Load
6063
// fail closed.
6164
ipBinding string
6265
}
6366

67+
// Close stops the session validation cache's background worker without closing
68+
// the shared database. It is safe with caching disabled and on repeated calls.
69+
func (sm *SessionManager) Close() error {
70+
sm.closeOnce.Do(func() {
71+
if sm.sessionValidation != nil && sm.sessionValidation.cache != nil {
72+
sm.closeErr = sm.sessionValidation.cache.Close()
73+
}
74+
})
75+
return sm.closeErr
76+
}
77+
6478
// Session represents an active user session
6579
type Session struct {
6680
ID int `json:"id"`

internal/auth/tokens.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"errors"
1010
"fmt"
1111
"log/slog"
12+
"sync"
1213
"time"
1314

1415
"windshift/internal/cacheutil"
@@ -57,6 +58,19 @@ type TokenManager struct {
5758
db database.Database
5859
tokenTracker TokenUsageRecorder
5960
cache *bigcache.BigCache
61+
closeOnce sync.Once
62+
closeErr error
63+
}
64+
65+
// Close stops the validation cache's background worker. It does not close the
66+
// shared database or token tracker, and may be called more than once.
67+
func (tm *TokenManager) Close() error {
68+
tm.closeOnce.Do(func() {
69+
if tm.cache != nil {
70+
tm.closeErr = tm.cache.Close()
71+
}
72+
})
73+
return tm.closeErr
6074
}
6175

6276
// NewTokenManager creates a new token manager

internal/server/server.go

Lines changed: 55 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"os"
1919
"strconv"
2020
"strings"
21+
"sync"
2122
"time"
2223

2324
"windshift/internal/aitools"
@@ -100,6 +101,12 @@ type Server struct {
100101
db database.Database
101102
listener net.Listener
102103

104+
permissionService *services.PermissionService
105+
sessionManager *auth.SessionManager
106+
tokenManager *auth.TokenManager
107+
scimTokenManager *auth.SCIMTokenManager
108+
itemCache *services.ItemCacheService
109+
103110
ldapHandler *handlers.LDAPHandler
104111
notificationManager *handlers.NotificationManager
105112
notificationService *services.NotificationService
@@ -156,9 +163,10 @@ type Server struct {
156163
publicBoardLimiter *middleware.RateLimiter
157164
userConcurrency *middleware.UserConcurrencyLimiter
158165

159-
actualPort int
160-
started bool
161-
shuttingDown bool
166+
actualPort int
167+
started bool
168+
shuttingDown bool
169+
backgroundStopOnce sync.Once
162170
}
163171

164172
// New creates a new Server instance with the given configuration.
@@ -285,6 +293,7 @@ func (s *Server) initialize() error {
285293
if err != nil {
286294
return fmt.Errorf("failed to initialize permission service: %w", err)
287295
}
296+
s.permissionService = permService
288297

289298
// Shared channel service used by ChannelHandler, WebhookHandler,
290299
// FormHandler, RequestTypeHandler, and AssetReportHandler for the
@@ -322,6 +331,7 @@ func (s *Server) initialize() error {
322331
cfg.Auth.SessionValidationCacheTTL,
323332
primarySessionCacheMB,
324333
)
334+
s.sessionManager = sessionManager
325335

326336
effectivePort := cfg.Port
327337
if cfg.AllowedPort != "" {
@@ -379,6 +389,7 @@ func (s *Server) initialize() error {
379389

380390
apiTokenCacheMB, _ := config.SplitSSHCacheBudget(s.memoryBudget.APITokenCacheMB, cfg.SSH.Enabled)
381391
tokenManager := auth.NewTokenManager(s.db, s.tokenTracker, apiTokenCacheMB)
392+
s.tokenManager = tokenManager
382393
if cleaned, cleanupErr := tokenManager.CleanupExpiredTokens(); cleanupErr != nil {
383394
slog.Warn("failed to cleanup expired api tokens on startup", "error", cleanupErr)
384395
} else if cleaned > 0 {
@@ -520,6 +531,7 @@ func (s *Server) initialize() error {
520531
transitionMatrixService := services.NewTransitionMatrixService(s.db)
521532
bulkOperationMetrics := services.NewBulkOperationMetrics()
522533
itemHandler := handlers.NewItemHandler(s.db, permService, s.activityTracker, s.notificationService, s.memoryBudget.ItemCacheMB)
534+
s.itemCache = itemHandler.ItemCacheService()
523535
itemHandler.SetDBRequestTimeout(s.config.DB.RequestTimeout)
524536
customFieldHandler := handlers.NewCustomFieldHandler(s.db)
525537
workspaceHandler := handlers.NewWorkspaceHandler(s.db, permService, s.activityTracker, workspaceKeyCache, authorizationCacheInvalidator)
@@ -592,6 +604,7 @@ func (s *Server) initialize() error {
592604
agentHandler := handlers.NewAgentHandler(s.db, permService)
593605

594606
scimTokenManager := auth.NewSCIMTokenManager(s.db, s.memoryBudget.SCIMTokenCacheMB)
607+
s.scimTokenManager = scimTokenManager
595608
scimAuthMiddleware := middleware.NewSCIMAuthMiddleware(scimTokenManager)
596609
scimHandler := handlers.NewSCIMHandler(
597610
repository.NewSCIMRepository(s.db),
@@ -1980,29 +1993,7 @@ func (s *Server) Shutdown(ctx context.Context) error {
19801993
s.databasePoolMonitor.Stop()
19811994
}
19821995

1983-
// Stop schedulers first - use safeClose helper to avoid panics on already-closed channels
1984-
safeClose := func(ch chan struct{}) {
1985-
if ch != nil {
1986-
defer func() { recover() }() //nolint:errcheck // Intentionally ignoring recover() return; used to suppress panics from closing already-closed channels
1987-
close(ch)
1988-
}
1989-
}
1990-
1991-
// Close, but do NOT nil, the stop channels: background schedulers select
1992-
// on these fields in a loop, so the nil-write races with their reads (and
1993-
// a select on a nil channel blocks forever, leaking the goroutine).
1994-
// Double-close safety comes from safeClose's recover, not from nil-ing.
1995-
safeClose(s.scmSyncStopChan)
1996-
safeClose(s.issueSyncStopChan)
1997-
safeClose(s.magicLinkStopChan)
1998-
1999-
if s.cleanupTicker != nil {
2000-
// Stop, but do NOT nil: runActivityCleanup selects on cleanupTicker.C
2001-
// in a loop and the nil-write races with that read.
2002-
s.cleanupTicker.Stop()
2003-
}
2004-
safeClose(s.cleanupStopChan)
2005-
safeClose(s.jiraHostStopChan)
1996+
s.stopBackgroundLoops()
20061997

20071998
if s.notificationScheduler != nil {
20081999
slog.Info("stopping notification scheduler")
@@ -2142,6 +2133,9 @@ func isAPIPath(p string) bool {
21422133

21432134
// cleanup releases all resources.
21442135
func (s *Server) cleanup() {
2136+
// New also calls cleanup after a partially completed initialize. Signal any
2137+
// loops already started there, even when Shutdown was never reachable.
2138+
s.stopBackgroundLoops()
21452139
if s.databasePoolMonitor != nil {
21462140
s.databasePoolMonitor.Stop()
21472141
}
@@ -2229,12 +2223,47 @@ func (s *Server) cleanup() {
22292223
_ = s.tokenTracker.Close()
22302224
}
22312225

2226+
// These caches are owned by this HTTP server, including on partial startup.
2227+
// Close them after consumers have stopped, before releasing the shared DB.
2228+
if s.permissionService != nil {
2229+
_ = s.permissionService.Close()
2230+
}
2231+
if s.sessionManager != nil {
2232+
_ = s.sessionManager.Close()
2233+
}
2234+
if s.tokenManager != nil {
2235+
_ = s.tokenManager.Close()
2236+
}
2237+
if s.scimTokenManager != nil {
2238+
_ = s.scimTokenManager.Close()
2239+
}
2240+
if s.itemCache != nil {
2241+
_ = s.itemCache.Close()
2242+
}
2243+
22322244
// Close database
22332245
if s.db != nil {
22342246
_ = s.db.Close()
22352247
}
22362248
}
22372249

2250+
func (s *Server) stopBackgroundLoops() {
2251+
s.backgroundStopOnce.Do(func() {
2252+
// Do not nil these fields: workers read them concurrently.
2253+
if s.cleanupTicker != nil {
2254+
s.cleanupTicker.Stop()
2255+
}
2256+
for _, ch := range []chan struct{}{
2257+
s.scmSyncStopChan, s.issueSyncStopChan, s.magicLinkStopChan,
2258+
s.cleanupStopChan, s.jiraHostStopChan,
2259+
} {
2260+
if ch != nil {
2261+
close(ch)
2262+
}
2263+
}
2264+
})
2265+
}
2266+
22382267
// RegisterDatabasePool makes a process-local auxiliary SQL pool visible to
22392268
// admin diagnostics and threshold monitoring.
22402269
func (s *Server) RegisterDatabasePool(name string, db database.Database) error {

internal/services/item_cache.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"fmt"
66
"log/slog"
7+
"sync"
78
"sync/atomic"
89
"time"
910

@@ -28,6 +29,8 @@ type ItemHierarchyCache struct {
2829
type ItemCacheService struct {
2930
hierarchyCache *bigcache.BigCache
3031
db database.Database
32+
closeOnce sync.Once
33+
closeErr error
3134

3235
// Cache statistics
3336
hierarchyHits int64
@@ -38,6 +41,17 @@ type ItemCacheService struct {
3841
config ItemCacheConfig
3942
}
4043

44+
// Close stops the hierarchy cache's background worker without closing the
45+
// shared database. It may be called more than once.
46+
func (ics *ItemCacheService) Close() error {
47+
ics.closeOnce.Do(func() {
48+
if ics.hierarchyCache != nil {
49+
ics.closeErr = ics.hierarchyCache.Close()
50+
}
51+
})
52+
return ics.closeErr
53+
}
54+
4155
// ItemCacheConfig represents configuration for the item cache
4256
type ItemCacheConfig struct {
4357
HierarchyTTL time.Duration `json:"hierarchy_ttl"` // Default: 30min

internal/services/permission_cache.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ type PermissionService struct {
2626
cacheCommitMu sync.RWMutex
2727
cacheGeneration atomic.Uint64
2828
workspaceAccess *workspaceAccessCache
29+
closeOnce sync.Once
30+
closeErr error
2931

3032
hits int64
3133
misses int64
@@ -968,7 +970,13 @@ func (ps *PermissionService) getRecentlyActiveUsers(duration time.Duration) ([]i
968970
return scanIntColumn(rows)
969971
}
970972

971-
// Close gracefully shuts down the permission service
973+
// Close stops the cache worker without closing the shared database.
974+
// Repeated calls are safe, including when no cache was initialized.
972975
func (ps *PermissionService) Close() error {
973-
return ps.cache.Close()
976+
ps.closeOnce.Do(func() {
977+
if ps.cache != nil {
978+
ps.closeErr = ps.cache.Close()
979+
}
980+
})
981+
return ps.closeErr
974982
}

0 commit comments

Comments
 (0)