fix(plugins): stop the cache janitor when a plugin cache is dropped

newCacheService started a ttlcache janitor goroutine that only stopped via the
explicit Close() path, so a cache service that was discarded without being closed
leaked its janitor for the process lifetime. It now registers the same
runtime.AddCleanup safety net that utils/cache.simpleCache already uses.
This commit is contained in:
Deluan 2026-08-19 23:40:16 -04:00
parent 881073c183
commit 3f396c5666
2 changed files with 47 additions and 1 deletions

View File

@ -2,6 +2,7 @@ package plugins
import (
"context"
"runtime"
"time"
"github.com/jellydator/ttlcache/v3"
@ -29,11 +30,18 @@ func newCacheService(pluginName string) *cacheServiceImpl {
// Start the janitor goroutine to clean up expired entries
go cache.Start()
return &cacheServiceImpl{
svc := &cacheServiceImpl{
pluginName: pluginName,
cache: cache,
defaultTTL: defaultCacheTTL,
}
// Automatic cleanup to prevent goroutine leak when the service is garbage collected
runtime.AddCleanup(svc, func(ttlCache *ttlcache.Cache[string, any]) {
ttlCache.Stop()
}, cache)
return svc
}
// getTTL converts seconds to a duration, using default if 0 or negative

View File

@ -8,9 +8,11 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"runtime"
"time"
"github.com/navidrome/navidrome/conf"
@ -599,3 +601,39 @@ var _ = Describe("CacheService Integration", Ordered, func() {
})
})
})
var _ = Describe("newCacheService", func() {
// Background goroutines wind down late, so poll for two consecutive equal samples.
settleGoroutines := func() int {
prev := -1
for range 100 {
runtime.GC()
n := runtime.NumGoroutine()
if n == prev {
return n
}
prev = n
time.Sleep(10 * time.Millisecond)
}
return prev
}
It("stops the janitor goroutine once the service is unreachable", func() {
const numServices = 20
baseline := settleGoroutines()
services := make([]*cacheServiceImpl, 0, numServices)
for i := range numServices {
services = append(services, newCacheService(fmt.Sprintf("plugin_%d", i)))
}
Expect(runtime.NumGoroutine()).To(BeNumerically(">=", baseline+numServices),
"expected one janitor goroutine per cache service")
services = nil
Expect(services).To(BeNil())
Expect(settleGoroutines()).To(BeNumerically("<", baseline+numServices),
"janitor goroutines leaked after the services became unreachable")
})
})