@@ -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.
21442135func (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.
22402269func (s * Server ) RegisterDatabasePool (name string , db database.Database ) error {
0 commit comments