mirror of
https://github.com/navidrome/navidrome.git
synced 2026-06-19 07:37:15 +00:00
fc9cdf39c8
Dir embedded sync.Once directly and exposed a value-receiver GoString so
that pretty.Sprintf("%# v", Server) could render the path. That meant
every pretty-print copied the entire Dir along with its Once, and a
goroutine concurrently using the original (or any copy) for Path() could
hit a "sync: unlock of unlocked mutex" runtime fatal error. The failure
was reproduced deterministically on Windows CI when test-suite shuffle
ordering raced cache initialization (utils/cache/file_caches.go's
NewFileCache.func1 -> conf.CacheFolder.MustPath) against the
configuration-dump pretty.Sprintf in Load().
Drop the sync.Once entirely. Dir is now a plain {path, perm} value type,
and Path() calls os.MkdirAll on every invocation. MkdirAll is
idempotent, so repeated calls on an existing directory cost one stat
syscall — negligible for the few config paths read at startup and during
cache init.
This removes the entire class of bug:
- No Mutex, so copies (via reflection, pretty-print, etc.) are safe.
- No state pointer, so no nil-state defensive checks scattered across
methods, and no risk of two copies seeing different lifecycle state.
- go vet is happy with the value receivers — the //nolint:govet
suppression on GoString is gone.
Adds two regression tests in conf/dir_test.go:
- GoString renders Dir as a quoted path under pretty.Sprintf (and
does not leak the internal struct fields).
- Concurrent copy + Path() stress test, locking in the copy-safety
property in case the type ever grows non-trivial state again.
165 lines
4.5 KiB
Go
165 lines
4.5 KiB
Go
package conf_test
|
|
|
|
import (
|
|
"os"
|
|
"sync"
|
|
|
|
"github.com/kr/pretty"
|
|
"github.com/navidrome/navidrome/conf"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("Dir", func() {
|
|
Describe("NewDir", func() {
|
|
It("creates a Dir with the given path without side effects", func() {
|
|
d := conf.NewDir("/some/path")
|
|
Expect(d.String()).To(Equal("/some/path"))
|
|
})
|
|
})
|
|
|
|
Describe("String", func() {
|
|
It("returns the raw path without creating the directory", func() {
|
|
d := conf.NewDir("/nonexistent/path/that/should/not/be/created")
|
|
Expect(d.String()).To(Equal("/nonexistent/path/that/should/not/be/created"))
|
|
})
|
|
})
|
|
|
|
Describe("Path", func() {
|
|
It("creates the directory and returns the path on first call", func() {
|
|
dir := GinkgoT().TempDir()
|
|
target := dir + "/subdir/nested"
|
|
d := conf.NewDir(target)
|
|
|
|
path, err := d.Path()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(path).To(Equal(target))
|
|
Expect(target).To(BeADirectory())
|
|
})
|
|
|
|
It("is idempotent on subsequent calls", func() {
|
|
dir := GinkgoT().TempDir()
|
|
target := dir + "/idempotent"
|
|
d := conf.NewDir(target)
|
|
|
|
path1, err1 := d.Path()
|
|
path2, err2 := d.Path()
|
|
Expect(err1).ToNot(HaveOccurred())
|
|
Expect(err2).ToNot(HaveOccurred())
|
|
Expect(path1).To(Equal(path2))
|
|
Expect(target).To(BeADirectory())
|
|
})
|
|
|
|
It("returns an error when directory cannot be created", func() {
|
|
f := GinkgoT().TempDir()
|
|
blocker := f + "/blocker"
|
|
By("creating a file that blocks directory creation")
|
|
Expect(os.WriteFile(blocker, []byte("x"), 0600)).To(Succeed())
|
|
invalid := blocker + "/subdir"
|
|
|
|
d := conf.NewDir(invalid)
|
|
_, pathErr := d.Path()
|
|
Expect(pathErr).To(HaveOccurred())
|
|
})
|
|
|
|
It("returns empty path and no error for empty path", func() {
|
|
d := conf.NewDir("")
|
|
path, err := d.Path()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(path).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Describe("MustPath", func() {
|
|
It("returns the path when directory is created successfully", func() {
|
|
dir := GinkgoT().TempDir()
|
|
target := dir + "/mustpath"
|
|
d := conf.NewDir(target)
|
|
|
|
path := d.MustPath()
|
|
Expect(path).To(Equal(target))
|
|
Expect(target).To(BeADirectory())
|
|
})
|
|
|
|
It("calls logFatal on error", func() {
|
|
var fatalMsg []any
|
|
restore := conf.SetLogFatal(func(args ...any) {
|
|
fatalMsg = args
|
|
panic("logFatal called")
|
|
})
|
|
DeferCleanup(restore)
|
|
|
|
f := GinkgoT().TempDir() + "/blocker"
|
|
Expect(os.WriteFile(f, []byte("x"), 0600)).To(Succeed())
|
|
invalid := f + "/subdir"
|
|
|
|
d := conf.NewDir(invalid)
|
|
Expect(func() { d.MustPath() }).To(Panic())
|
|
Expect(fatalMsg).ToNot(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Describe("MarshalText", func() {
|
|
It("returns the raw path bytes without side effects", func() {
|
|
d := conf.NewDir("/marshal/path")
|
|
b, err := d.MarshalText()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(string(b)).To(Equal("/marshal/path"))
|
|
})
|
|
})
|
|
|
|
Describe("UnmarshalText", func() {
|
|
It("sets the path from bytes without side effects", func() {
|
|
d := conf.NewDir("")
|
|
err := d.UnmarshalText([]byte("/unmarshal/path"))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(d.String()).To(Equal("/unmarshal/path"))
|
|
})
|
|
|
|
It("allows round-trip marshal/unmarshal", func() {
|
|
d1 := conf.NewDir("/round/trip")
|
|
b, err := d1.MarshalText()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
var d2 conf.Dir
|
|
err = d2.UnmarshalText(b)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(d2.String()).To(Equal(d1.String()))
|
|
})
|
|
})
|
|
|
|
Describe("GoString", func() {
|
|
// Regression: pretty.Sprintf("%# v", ...) is used by the
|
|
// configuration dump. It must render Dir as a quoted path via
|
|
// GoString, not dump the internal struct fields.
|
|
It("renders Dir as a quoted path under pretty.Sprintf", func() {
|
|
type host struct {
|
|
DataFolder conf.Dir
|
|
}
|
|
h := host{DataFolder: conf.NewDir("./data")}
|
|
out := pretty.Sprintf("%# v", h)
|
|
Expect(out).To(ContainSubstring(`DataFolder: "./data"`))
|
|
Expect(out).ToNot(ContainSubstring("perm:"))
|
|
Expect(out).ToNot(ContainSubstring("path:"))
|
|
})
|
|
|
|
It("is safe to copy and use concurrently", func() {
|
|
// Regression for the Windows "sync: unlock of unlocked mutex"
|
|
// crash that was caused by copying a Dir embedding sync.Once.
|
|
// Dir is a plain value type now, but keep the concurrent stress
|
|
// test to lock in the property.
|
|
dir := GinkgoT().TempDir()
|
|
d := conf.NewDir(dir + "/race")
|
|
var wg sync.WaitGroup
|
|
for range 10 {
|
|
wg.Go(func() {
|
|
copy1 := d
|
|
_ = pretty.Sprintf("%# v", copy1)
|
|
_, _ = copy1.Path()
|
|
})
|
|
}
|
|
wg.Wait()
|
|
})
|
|
})
|
|
})
|