2025-11-14 22:15:43 -05:00
|
|
|
package scanner
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"io/fs"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"testing/fstest"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
|
|
|
"github.com/navidrome/navidrome/conf/configtest"
|
fix(scanner): resolve file symlinks with the production local storage FS (#5755)
* fix(scanner): resolve file symlinks with the production local storage FS
The symlink classification added for GHSA-r5qr-m328-qcf4 relied on
fs.ReadLink, but the local storage FS wraps os.DirFS behind the fs.FS
interface, hiding its ReadLinkFS implementation. Every resolution failed
at the first hop, so the scanner silently skipped ALL file symlinks,
regardless of target or the FollowSymlinks setting. Libraries made of
symlinks (e.g. shared-pool setups) lost all their tracks after
upgrading to 0.63.
The local storage now exposes full OS-level resolution (EvalSymlinks)
through a new optional storage.SymlinkResolverFS interface, which the
scanner prefers over the fs.ReadLink hop loop. This also classifies a
chain by its FINAL target even when it passes through an audio-named
intermediate outside the library, closing a bypass the hop loop had.
Regular (non-symlink) entries keep the same early-return path, so scan
performance is unaffected for normal libraries.
Fixes #5752
* fix(test): keep watcher specs off the real local storage
The watcher specs spawn watchLibrary goroutines that are not joined on
spec teardown. Now that the scanner test binary registers the file://
storage, those leaked goroutines reached newLocalStorage, which reads
conf.Server on construction, racing with the configtest cleanup that
restores the config snapshot (caught by CI's race detector). Point the
mock libraries at a fake storage scheme, which never touches the config
and does not support watching, so the goroutine exits immediately.
* fix(storage): reject invalid fs paths in ResolveSymlink
Defense-in-depth for the SymlinkResolverFS contract: names must be valid
fs.FS paths. A lexical ".." in the name would otherwise escape the
library root via filepath.Join cleaning. No current caller can produce
such a name (they come from ReadDir walks), but the guard enforces the
documented contract at the boundary.
2026-07-10 10:52:05 -04:00
|
|
|
"github.com/navidrome/navidrome/core/storage/storagetest"
|
2025-11-14 22:15:43 -05:00
|
|
|
"github.com/navidrome/navidrome/model"
|
|
|
|
|
"github.com/navidrome/navidrome/tests"
|
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
|
|
|
. "github.com/onsi/gomega"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var _ = Describe("Watcher", func() {
|
|
|
|
|
var ctx context.Context
|
|
|
|
|
var cancel context.CancelFunc
|
|
|
|
|
var mockScanner *tests.MockScanner
|
|
|
|
|
var mockDS *tests.MockDataStore
|
|
|
|
|
var w *watcher
|
|
|
|
|
var lib *model.Library
|
|
|
|
|
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
DeferCleanup(configtest.SetupConfig())
|
fix(cache): write the completion marker before closing the cache writer (#5927)
* fix(cache): write the completion marker before closing the cache writer
Readers of an in-progress cache write see EOF the moment the writer closes,
but the .complete marker was created after the close, on the background
goroutine — so a fully-read stream did not mean the cache was done touching
disk. The new artwork precache spec ends right at EOF, and its
GinkgoT().TempDir() cleanup raced the marker creation, failing the Windows CI
job with 'unlinkat ...: The directory is not empty' (the race also reproduces
on macOS, 2 of 3 runs, with the tightened test).
Writing the marker after a clean copy but before Close makes reader-EOF imply
every on-disk write for the entry is finished. A failed writer Close still
invalidates the entry, which removes both the marker and the data file. The
existing marker test now asserts the marker exists immediately at EOF instead
of Eventually.
* fix(artwork): never dispatch queue items after the drain context is cancelled
The 10x Windows stress run for the previous commit surfaced a second flake in
the same package: 'leaves undispatched items queued when cancelled mid-batch'
lost row alc7 in 4 of 10 runs. In drain, when a semaphore slot is free and the
context is already cancelled, both cases of the blocking select are ready and
Go picks one at random — so a cancelled drain could still dispatch items. A
non-blocking Done check before the select gives cancellation priority.
The race was invisible on Linux/macOS only by accident: the spec seeded the
album repo with a single album (each SetData overwrote the last), so only the
final row (alc7) resolved to absent and got deleted when dispatched; the
others fell on the retry path and survived. Nanosecond enqueue timestamps
made alc0 always first out of the mock dequeue, masking the race, while
Windows' coarse clock ties the timestamps and randomizes the order. The spec
now seeds all eight albums, which made the race reproduce locally on the
first try (row alc0) and now guards the fix on every platform.
* test: give cache-init waits a 10s timeout for loaded CI runners
A 10x parallel Windows stress run timed out one artwork spec in BeforeEach:
the FileCache init goroutine (mkdir + reload walk) took over Gomega's default
1s Eventually timeout under shared-runner disk contention. Bump the three
identical init waits (two artwork suites and the utils/cache helper) to 10s.
* test(scanner): widen watcher debounce margins for loaded CI runners
The watcher debouncing spec asserts 'no scan yet' inside 20ms Consistently
windows while the debounce wait was only 50ms — a 2.5x margin that a loaded
Windows runner blows through by delaying the timer-reset notification, firing
the scan early (failed all three FlakeAttempts in a 10x stress run). Raise the
test debounce wait to 200ms (10x the observation windows) and the scan-fired
Eventually timeouts to 2s to match.
* refactor(artwork): collapse drain cancellation into a single exit path
Replace the non-blocking ctx pre-check plus duplicated select exit with one
select and a ctx.Err() check after it. Besides removing the duplication, this
closes the residual race: a cancellation landing between the two selects could
still let the blocking select randomly pick the free semaphore slot and
dispatch the item. Now a dispatch is only possible when the context was live
after slot acquisition.
* style: trim flaky-test fix comments to single lines
Compress each two-line comment added by this PR to the one line that carries
the invariant; drop the narration around it.
2026-08-10 14:10:05 -04:00
|
|
|
// Must dwarf the 20ms Consistently windows below, or a loaded runner's delayed timer reset flakes the debouncing spec.
|
|
|
|
|
conf.Server.Scanner.WatcherWait = 200 * time.Millisecond
|
2025-11-14 22:15:43 -05:00
|
|
|
|
2026-03-12 17:07:34 -04:00
|
|
|
ctx, cancel = context.WithCancel(GinkgoT().Context())
|
2025-11-14 22:15:43 -05:00
|
|
|
DeferCleanup(cancel)
|
|
|
|
|
|
fix(scanner): resolve file symlinks with the production local storage FS (#5755)
* fix(scanner): resolve file symlinks with the production local storage FS
The symlink classification added for GHSA-r5qr-m328-qcf4 relied on
fs.ReadLink, but the local storage FS wraps os.DirFS behind the fs.FS
interface, hiding its ReadLinkFS implementation. Every resolution failed
at the first hop, so the scanner silently skipped ALL file symlinks,
regardless of target or the FollowSymlinks setting. Libraries made of
symlinks (e.g. shared-pool setups) lost all their tracks after
upgrading to 0.63.
The local storage now exposes full OS-level resolution (EvalSymlinks)
through a new optional storage.SymlinkResolverFS interface, which the
scanner prefers over the fs.ReadLink hop loop. This also classifies a
chain by its FINAL target even when it passes through an audio-named
intermediate outside the library, closing a bypass the hop loop had.
Regular (non-symlink) entries keep the same early-return path, so scan
performance is unaffected for normal libraries.
Fixes #5752
* fix(test): keep watcher specs off the real local storage
The watcher specs spawn watchLibrary goroutines that are not joined on
spec teardown. Now that the scanner test binary registers the file://
storage, those leaked goroutines reached newLocalStorage, which reads
conf.Server on construction, racing with the configtest cleanup that
restores the config snapshot (caught by CI's race detector). Point the
mock libraries at a fake storage scheme, which never touches the config
and does not support watching, so the goroutine exits immediately.
* fix(storage): reject invalid fs paths in ResolveSymlink
Defense-in-depth for the SymlinkResolverFS contract: names must be valid
fs.FS paths. A lexical ".." in the name would otherwise escape the
library root via filepath.Join cleaning. No current caller can produce
such a name (they come from ReadDir walks), but the guard enforces the
documented contract at the boundary.
2026-07-10 10:52:05 -04:00
|
|
|
// Use a fake storage scheme: watchLibrary goroutines spawned by Run/Watch are not
|
|
|
|
|
// joined on spec teardown, and the real file:// storage reads conf.Server on
|
|
|
|
|
// construction, racing with the configtest cleanup that restores the config.
|
|
|
|
|
storagetest.Register("fake-watcher", &storagetest.FakeFS{})
|
2025-11-14 22:15:43 -05:00
|
|
|
lib = &model.Library{
|
|
|
|
|
ID: 1,
|
|
|
|
|
Name: "Test Library",
|
fix(scanner): resolve file symlinks with the production local storage FS (#5755)
* fix(scanner): resolve file symlinks with the production local storage FS
The symlink classification added for GHSA-r5qr-m328-qcf4 relied on
fs.ReadLink, but the local storage FS wraps os.DirFS behind the fs.FS
interface, hiding its ReadLinkFS implementation. Every resolution failed
at the first hop, so the scanner silently skipped ALL file symlinks,
regardless of target or the FollowSymlinks setting. Libraries made of
symlinks (e.g. shared-pool setups) lost all their tracks after
upgrading to 0.63.
The local storage now exposes full OS-level resolution (EvalSymlinks)
through a new optional storage.SymlinkResolverFS interface, which the
scanner prefers over the fs.ReadLink hop loop. This also classifies a
chain by its FINAL target even when it passes through an audio-named
intermediate outside the library, closing a bypass the hop loop had.
Regular (non-symlink) entries keep the same early-return path, so scan
performance is unaffected for normal libraries.
Fixes #5752
* fix(test): keep watcher specs off the real local storage
The watcher specs spawn watchLibrary goroutines that are not joined on
spec teardown. Now that the scanner test binary registers the file://
storage, those leaked goroutines reached newLocalStorage, which reads
conf.Server on construction, racing with the configtest cleanup that
restores the config snapshot (caught by CI's race detector). Point the
mock libraries at a fake storage scheme, which never touches the config
and does not support watching, so the goroutine exits immediately.
* fix(storage): reject invalid fs paths in ResolveSymlink
Defense-in-depth for the SymlinkResolverFS contract: names must be valid
fs.FS paths. A lexical ".." in the name would otherwise escape the
library root via filepath.Join cleaning. No current caller can produce
such a name (they come from ReadDir walks), but the guard enforces the
documented contract at the boundary.
2026-07-10 10:52:05 -04:00
|
|
|
Path: "fake-watcher:///test/library",
|
2025-11-14 22:15:43 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set up mocks
|
|
|
|
|
mockScanner = tests.NewMockScanner()
|
|
|
|
|
mockDS = &tests.MockDataStore{}
|
|
|
|
|
mockLibRepo := &tests.MockLibraryRepo{}
|
|
|
|
|
mockLibRepo.SetData(model.Libraries{*lib})
|
|
|
|
|
mockDS.MockedLibrary = mockLibRepo
|
|
|
|
|
|
|
|
|
|
// Create a new watcher instance (not singleton) for testing
|
|
|
|
|
w = &watcher{
|
|
|
|
|
ds: mockDS,
|
|
|
|
|
scanner: mockScanner,
|
|
|
|
|
triggerWait: conf.Server.Scanner.WatcherWait,
|
|
|
|
|
watcherNotify: make(chan scanNotification, 10),
|
|
|
|
|
libraryWatchers: make(map[int]*libraryWatcherInstance),
|
|
|
|
|
mainCtx: ctx,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-01 10:50:24 -05:00
|
|
|
Describe("Watch before Run", func() {
|
|
|
|
|
It("returns nil and does not panic when mainCtx is nil", func() {
|
|
|
|
|
w.mainCtx = nil
|
|
|
|
|
err := w.Watch(ctx, lib)
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
Expect(w.libraryWatchers).To(BeEmpty())
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2025-11-14 22:15:43 -05:00
|
|
|
Describe("Target Collection and Deduplication", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
// Start watcher in background
|
|
|
|
|
go func() {
|
|
|
|
|
_ = w.Run(ctx)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
// Give watcher time to initialize
|
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-03 11:26:07 -04:00
|
|
|
It("creates separate targets for different folders", FlakeAttempts(3), func() {
|
2025-11-14 22:15:43 -05:00
|
|
|
// Send notifications for different folders
|
|
|
|
|
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
|
|
|
|
|
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist2"}
|
|
|
|
|
|
2026-05-03 11:26:07 -04:00
|
|
|
// Wait for a scan that collected both targets
|
|
|
|
|
Eventually(func() []model.ScanTarget {
|
|
|
|
|
calls := mockScanner.GetScanFoldersCalls()
|
|
|
|
|
if len(calls) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return calls[0].Targets
|
fix(cache): write the completion marker before closing the cache writer (#5927)
* fix(cache): write the completion marker before closing the cache writer
Readers of an in-progress cache write see EOF the moment the writer closes,
but the .complete marker was created after the close, on the background
goroutine — so a fully-read stream did not mean the cache was done touching
disk. The new artwork precache spec ends right at EOF, and its
GinkgoT().TempDir() cleanup raced the marker creation, failing the Windows CI
job with 'unlinkat ...: The directory is not empty' (the race also reproduces
on macOS, 2 of 3 runs, with the tightened test).
Writing the marker after a clean copy but before Close makes reader-EOF imply
every on-disk write for the entry is finished. A failed writer Close still
invalidates the entry, which removes both the marker and the data file. The
existing marker test now asserts the marker exists immediately at EOF instead
of Eventually.
* fix(artwork): never dispatch queue items after the drain context is cancelled
The 10x Windows stress run for the previous commit surfaced a second flake in
the same package: 'leaves undispatched items queued when cancelled mid-batch'
lost row alc7 in 4 of 10 runs. In drain, when a semaphore slot is free and the
context is already cancelled, both cases of the blocking select are ready and
Go picks one at random — so a cancelled drain could still dispatch items. A
non-blocking Done check before the select gives cancellation priority.
The race was invisible on Linux/macOS only by accident: the spec seeded the
album repo with a single album (each SetData overwrote the last), so only the
final row (alc7) resolved to absent and got deleted when dispatched; the
others fell on the retry path and survived. Nanosecond enqueue timestamps
made alc0 always first out of the mock dequeue, masking the race, while
Windows' coarse clock ties the timestamps and randomizes the order. The spec
now seeds all eight albums, which made the race reproduce locally on the
first try (row alc0) and now guards the fix on every platform.
* test: give cache-init waits a 10s timeout for loaded CI runners
A 10x parallel Windows stress run timed out one artwork spec in BeforeEach:
the FileCache init goroutine (mkdir + reload walk) took over Gomega's default
1s Eventually timeout under shared-runner disk contention. Bump the three
identical init waits (two artwork suites and the utils/cache helper) to 10s.
* test(scanner): widen watcher debounce margins for loaded CI runners
The watcher debouncing spec asserts 'no scan yet' inside 20ms Consistently
windows while the debounce wait was only 50ms — a 2.5x margin that a loaded
Windows runner blows through by delaying the timer-reset notification, firing
the scan early (failed all three FlakeAttempts in a 10x stress run). Raise the
test debounce wait to 200ms (10x the observation windows) and the scan-fired
Eventually timeouts to 2s to match.
* refactor(artwork): collapse drain cancellation into a single exit path
Replace the non-blocking ctx pre-check plus duplicated select exit with one
select and a ctx.Err() check after it. Besides removing the duplication, this
closes the residual race: a cancellation landing between the two selects could
still let the blocking select randomly pick the free semaphore slot and
dispatch the item. Now a dispatch is only possible when the context was live
after slot acquisition.
* style: trim flaky-test fix comments to single lines
Compress each two-line comment added by this PR to the one line that carries
the invariant; drop the narration around it.
2026-08-10 14:10:05 -04:00
|
|
|
}, 2*time.Second, 10*time.Millisecond).Should(HaveLen(2))
|
2025-11-14 22:15:43 -05:00
|
|
|
|
2026-05-03 11:26:07 -04:00
|
|
|
// Verify targets
|
2025-11-14 22:15:43 -05:00
|
|
|
calls := mockScanner.GetScanFoldersCalls()
|
|
|
|
|
folderPaths := make(map[string]bool)
|
|
|
|
|
for _, target := range calls[0].Targets {
|
|
|
|
|
Expect(target.LibraryID).To(Equal(1))
|
|
|
|
|
folderPaths[target.FolderPath] = true
|
|
|
|
|
}
|
|
|
|
|
Expect(folderPaths).To(HaveKey("artist1"))
|
|
|
|
|
Expect(folderPaths).To(HaveKey("artist2"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("handles different folder paths correctly", func() {
|
|
|
|
|
// Send notification for nested folder
|
|
|
|
|
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
|
|
|
|
|
|
|
|
|
|
// Wait for watcher to process and trigger scan
|
|
|
|
|
Eventually(func() int {
|
|
|
|
|
return mockScanner.GetScanFoldersCallCount()
|
fix(cache): write the completion marker before closing the cache writer (#5927)
* fix(cache): write the completion marker before closing the cache writer
Readers of an in-progress cache write see EOF the moment the writer closes,
but the .complete marker was created after the close, on the background
goroutine — so a fully-read stream did not mean the cache was done touching
disk. The new artwork precache spec ends right at EOF, and its
GinkgoT().TempDir() cleanup raced the marker creation, failing the Windows CI
job with 'unlinkat ...: The directory is not empty' (the race also reproduces
on macOS, 2 of 3 runs, with the tightened test).
Writing the marker after a clean copy but before Close makes reader-EOF imply
every on-disk write for the entry is finished. A failed writer Close still
invalidates the entry, which removes both the marker and the data file. The
existing marker test now asserts the marker exists immediately at EOF instead
of Eventually.
* fix(artwork): never dispatch queue items after the drain context is cancelled
The 10x Windows stress run for the previous commit surfaced a second flake in
the same package: 'leaves undispatched items queued when cancelled mid-batch'
lost row alc7 in 4 of 10 runs. In drain, when a semaphore slot is free and the
context is already cancelled, both cases of the blocking select are ready and
Go picks one at random — so a cancelled drain could still dispatch items. A
non-blocking Done check before the select gives cancellation priority.
The race was invisible on Linux/macOS only by accident: the spec seeded the
album repo with a single album (each SetData overwrote the last), so only the
final row (alc7) resolved to absent and got deleted when dispatched; the
others fell on the retry path and survived. Nanosecond enqueue timestamps
made alc0 always first out of the mock dequeue, masking the race, while
Windows' coarse clock ties the timestamps and randomizes the order. The spec
now seeds all eight albums, which made the race reproduce locally on the
first try (row alc0) and now guards the fix on every platform.
* test: give cache-init waits a 10s timeout for loaded CI runners
A 10x parallel Windows stress run timed out one artwork spec in BeforeEach:
the FileCache init goroutine (mkdir + reload walk) took over Gomega's default
1s Eventually timeout under shared-runner disk contention. Bump the three
identical init waits (two artwork suites and the utils/cache helper) to 10s.
* test(scanner): widen watcher debounce margins for loaded CI runners
The watcher debouncing spec asserts 'no scan yet' inside 20ms Consistently
windows while the debounce wait was only 50ms — a 2.5x margin that a loaded
Windows runner blows through by delaying the timer-reset notification, firing
the scan early (failed all three FlakeAttempts in a 10x stress run). Raise the
test debounce wait to 200ms (10x the observation windows) and the scan-fired
Eventually timeouts to 2s to match.
* refactor(artwork): collapse drain cancellation into a single exit path
Replace the non-blocking ctx pre-check plus duplicated select exit with one
select and a ctx.Err() check after it. Besides removing the duplication, this
closes the residual race: a cancellation landing between the two selects could
still let the blocking select randomly pick the free semaphore slot and
dispatch the item. Now a dispatch is only possible when the context was live
after slot acquisition.
* style: trim flaky-test fix comments to single lines
Compress each two-line comment added by this PR to the one line that carries
the invariant; drop the narration around it.
2026-08-10 14:10:05 -04:00
|
|
|
}, 2*time.Second, 10*time.Millisecond).Should(Equal(1))
|
2025-11-14 22:15:43 -05:00
|
|
|
|
|
|
|
|
// Verify the target
|
|
|
|
|
calls := mockScanner.GetScanFoldersCalls()
|
|
|
|
|
Expect(calls).To(HaveLen(1))
|
|
|
|
|
Expect(calls[0].Targets).To(HaveLen(1))
|
|
|
|
|
Expect(calls[0].Targets[0].FolderPath).To(Equal("artist1/album1"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("deduplicates folder and file within same folder", func() {
|
2026-05-03 11:26:07 -04:00
|
|
|
// Send multiple notifications for the same folder
|
2025-11-14 22:15:43 -05:00
|
|
|
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
|
|
|
|
|
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
|
|
|
|
|
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"}
|
|
|
|
|
|
|
|
|
|
// Wait for watcher to process and trigger scan
|
|
|
|
|
Eventually(func() int {
|
|
|
|
|
return mockScanner.GetScanFoldersCallCount()
|
fix(cache): write the completion marker before closing the cache writer (#5927)
* fix(cache): write the completion marker before closing the cache writer
Readers of an in-progress cache write see EOF the moment the writer closes,
but the .complete marker was created after the close, on the background
goroutine — so a fully-read stream did not mean the cache was done touching
disk. The new artwork precache spec ends right at EOF, and its
GinkgoT().TempDir() cleanup raced the marker creation, failing the Windows CI
job with 'unlinkat ...: The directory is not empty' (the race also reproduces
on macOS, 2 of 3 runs, with the tightened test).
Writing the marker after a clean copy but before Close makes reader-EOF imply
every on-disk write for the entry is finished. A failed writer Close still
invalidates the entry, which removes both the marker and the data file. The
existing marker test now asserts the marker exists immediately at EOF instead
of Eventually.
* fix(artwork): never dispatch queue items after the drain context is cancelled
The 10x Windows stress run for the previous commit surfaced a second flake in
the same package: 'leaves undispatched items queued when cancelled mid-batch'
lost row alc7 in 4 of 10 runs. In drain, when a semaphore slot is free and the
context is already cancelled, both cases of the blocking select are ready and
Go picks one at random — so a cancelled drain could still dispatch items. A
non-blocking Done check before the select gives cancellation priority.
The race was invisible on Linux/macOS only by accident: the spec seeded the
album repo with a single album (each SetData overwrote the last), so only the
final row (alc7) resolved to absent and got deleted when dispatched; the
others fell on the retry path and survived. Nanosecond enqueue timestamps
made alc0 always first out of the mock dequeue, masking the race, while
Windows' coarse clock ties the timestamps and randomizes the order. The spec
now seeds all eight albums, which made the race reproduce locally on the
first try (row alc0) and now guards the fix on every platform.
* test: give cache-init waits a 10s timeout for loaded CI runners
A 10x parallel Windows stress run timed out one artwork spec in BeforeEach:
the FileCache init goroutine (mkdir + reload walk) took over Gomega's default
1s Eventually timeout under shared-runner disk contention. Bump the three
identical init waits (two artwork suites and the utils/cache helper) to 10s.
* test(scanner): widen watcher debounce margins for loaded CI runners
The watcher debouncing spec asserts 'no scan yet' inside 20ms Consistently
windows while the debounce wait was only 50ms — a 2.5x margin that a loaded
Windows runner blows through by delaying the timer-reset notification, firing
the scan early (failed all three FlakeAttempts in a 10x stress run). Raise the
test debounce wait to 200ms (10x the observation windows) and the scan-fired
Eventually timeouts to 2s to match.
* refactor(artwork): collapse drain cancellation into a single exit path
Replace the non-blocking ctx pre-check plus duplicated select exit with one
select and a ctx.Err() check after it. Besides removing the duplication, this
closes the residual race: a cancellation landing between the two selects could
still let the blocking select randomly pick the free semaphore slot and
dispatch the item. Now a dispatch is only possible when the context was live
after slot acquisition.
* style: trim flaky-test fix comments to single lines
Compress each two-line comment added by this PR to the one line that carries
the invariant; drop the narration around it.
2026-08-10 14:10:05 -04:00
|
|
|
}, 2*time.Second, 10*time.Millisecond).Should(Equal(1))
|
2025-11-14 22:15:43 -05:00
|
|
|
|
|
|
|
|
// Verify only one target despite multiple file/folder changes
|
|
|
|
|
calls := mockScanner.GetScanFoldersCalls()
|
|
|
|
|
Expect(calls).To(HaveLen(1))
|
|
|
|
|
Expect(calls[0].Targets).To(HaveLen(1))
|
|
|
|
|
Expect(calls[0].Targets[0].FolderPath).To(Equal("artist1/album1"))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("Timer Behavior", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
// Start watcher in background
|
|
|
|
|
go func() {
|
|
|
|
|
_ = w.Run(ctx)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
// Give watcher time to initialize
|
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-03 11:26:07 -04:00
|
|
|
It("resets timer on each change (debouncing)", FlakeAttempts(3), func() {
|
2025-11-14 22:15:43 -05:00
|
|
|
// Send first notification
|
|
|
|
|
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
|
|
|
|
|
|
2026-05-03 11:26:07 -04:00
|
|
|
// Verify no scan fires during a window shorter than the debounce wait
|
|
|
|
|
Consistently(func() int {
|
|
|
|
|
return mockScanner.GetScanFoldersCallCount()
|
|
|
|
|
}, 20*time.Millisecond, 5*time.Millisecond).Should(Equal(0))
|
2025-11-14 22:15:43 -05:00
|
|
|
|
|
|
|
|
// Send another notification (resets timer)
|
|
|
|
|
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
|
|
|
|
|
|
2026-05-03 11:26:07 -04:00
|
|
|
// Again, no scan should fire within a short window
|
|
|
|
|
Consistently(func() int {
|
|
|
|
|
return mockScanner.GetScanFoldersCallCount()
|
|
|
|
|
}, 20*time.Millisecond, 5*time.Millisecond).Should(Equal(0))
|
2025-11-14 22:15:43 -05:00
|
|
|
|
2026-05-03 11:26:07 -04:00
|
|
|
// Now wait for the debounce timer to expire and trigger scan
|
2025-11-14 22:15:43 -05:00
|
|
|
Eventually(func() int {
|
|
|
|
|
return mockScanner.GetScanFoldersCallCount()
|
fix(cache): write the completion marker before closing the cache writer (#5927)
* fix(cache): write the completion marker before closing the cache writer
Readers of an in-progress cache write see EOF the moment the writer closes,
but the .complete marker was created after the close, on the background
goroutine — so a fully-read stream did not mean the cache was done touching
disk. The new artwork precache spec ends right at EOF, and its
GinkgoT().TempDir() cleanup raced the marker creation, failing the Windows CI
job with 'unlinkat ...: The directory is not empty' (the race also reproduces
on macOS, 2 of 3 runs, with the tightened test).
Writing the marker after a clean copy but before Close makes reader-EOF imply
every on-disk write for the entry is finished. A failed writer Close still
invalidates the entry, which removes both the marker and the data file. The
existing marker test now asserts the marker exists immediately at EOF instead
of Eventually.
* fix(artwork): never dispatch queue items after the drain context is cancelled
The 10x Windows stress run for the previous commit surfaced a second flake in
the same package: 'leaves undispatched items queued when cancelled mid-batch'
lost row alc7 in 4 of 10 runs. In drain, when a semaphore slot is free and the
context is already cancelled, both cases of the blocking select are ready and
Go picks one at random — so a cancelled drain could still dispatch items. A
non-blocking Done check before the select gives cancellation priority.
The race was invisible on Linux/macOS only by accident: the spec seeded the
album repo with a single album (each SetData overwrote the last), so only the
final row (alc7) resolved to absent and got deleted when dispatched; the
others fell on the retry path and survived. Nanosecond enqueue timestamps
made alc0 always first out of the mock dequeue, masking the race, while
Windows' coarse clock ties the timestamps and randomizes the order. The spec
now seeds all eight albums, which made the race reproduce locally on the
first try (row alc0) and now guards the fix on every platform.
* test: give cache-init waits a 10s timeout for loaded CI runners
A 10x parallel Windows stress run timed out one artwork spec in BeforeEach:
the FileCache init goroutine (mkdir + reload walk) took over Gomega's default
1s Eventually timeout under shared-runner disk contention. Bump the three
identical init waits (two artwork suites and the utils/cache helper) to 10s.
* test(scanner): widen watcher debounce margins for loaded CI runners
The watcher debouncing spec asserts 'no scan yet' inside 20ms Consistently
windows while the debounce wait was only 50ms — a 2.5x margin that a loaded
Windows runner blows through by delaying the timer-reset notification, firing
the scan early (failed all three FlakeAttempts in a 10x stress run). Raise the
test debounce wait to 200ms (10x the observation windows) and the scan-fired
Eventually timeouts to 2s to match.
* refactor(artwork): collapse drain cancellation into a single exit path
Replace the non-blocking ctx pre-check plus duplicated select exit with one
select and a ctx.Err() check after it. Besides removing the duplication, this
closes the residual race: a cancellation landing between the two selects could
still let the blocking select randomly pick the free semaphore slot and
dispatch the item. Now a dispatch is only possible when the context was live
after slot acquisition.
* style: trim flaky-test fix comments to single lines
Compress each two-line comment added by this PR to the one line that carries
the invariant; drop the narration around it.
2026-08-10 14:10:05 -04:00
|
|
|
}, 2*time.Second, 10*time.Millisecond).Should(Equal(1))
|
2025-11-14 22:15:43 -05:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("triggers scan after quiet period", func() {
|
|
|
|
|
// Send notification
|
|
|
|
|
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
|
|
|
|
|
|
|
|
|
|
// No scan immediately
|
|
|
|
|
Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0))
|
|
|
|
|
|
|
|
|
|
// Wait for quiet period
|
|
|
|
|
Eventually(func() int {
|
|
|
|
|
return mockScanner.GetScanFoldersCallCount()
|
fix(cache): write the completion marker before closing the cache writer (#5927)
* fix(cache): write the completion marker before closing the cache writer
Readers of an in-progress cache write see EOF the moment the writer closes,
but the .complete marker was created after the close, on the background
goroutine — so a fully-read stream did not mean the cache was done touching
disk. The new artwork precache spec ends right at EOF, and its
GinkgoT().TempDir() cleanup raced the marker creation, failing the Windows CI
job with 'unlinkat ...: The directory is not empty' (the race also reproduces
on macOS, 2 of 3 runs, with the tightened test).
Writing the marker after a clean copy but before Close makes reader-EOF imply
every on-disk write for the entry is finished. A failed writer Close still
invalidates the entry, which removes both the marker and the data file. The
existing marker test now asserts the marker exists immediately at EOF instead
of Eventually.
* fix(artwork): never dispatch queue items after the drain context is cancelled
The 10x Windows stress run for the previous commit surfaced a second flake in
the same package: 'leaves undispatched items queued when cancelled mid-batch'
lost row alc7 in 4 of 10 runs. In drain, when a semaphore slot is free and the
context is already cancelled, both cases of the blocking select are ready and
Go picks one at random — so a cancelled drain could still dispatch items. A
non-blocking Done check before the select gives cancellation priority.
The race was invisible on Linux/macOS only by accident: the spec seeded the
album repo with a single album (each SetData overwrote the last), so only the
final row (alc7) resolved to absent and got deleted when dispatched; the
others fell on the retry path and survived. Nanosecond enqueue timestamps
made alc0 always first out of the mock dequeue, masking the race, while
Windows' coarse clock ties the timestamps and randomizes the order. The spec
now seeds all eight albums, which made the race reproduce locally on the
first try (row alc0) and now guards the fix on every platform.
* test: give cache-init waits a 10s timeout for loaded CI runners
A 10x parallel Windows stress run timed out one artwork spec in BeforeEach:
the FileCache init goroutine (mkdir + reload walk) took over Gomega's default
1s Eventually timeout under shared-runner disk contention. Bump the three
identical init waits (two artwork suites and the utils/cache helper) to 10s.
* test(scanner): widen watcher debounce margins for loaded CI runners
The watcher debouncing spec asserts 'no scan yet' inside 20ms Consistently
windows while the debounce wait was only 50ms — a 2.5x margin that a loaded
Windows runner blows through by delaying the timer-reset notification, firing
the scan early (failed all three FlakeAttempts in a 10x stress run). Raise the
test debounce wait to 200ms (10x the observation windows) and the scan-fired
Eventually timeouts to 2s to match.
* refactor(artwork): collapse drain cancellation into a single exit path
Replace the non-blocking ctx pre-check plus duplicated select exit with one
select and a ctx.Err() check after it. Besides removing the duplication, this
closes the residual race: a cancellation landing between the two selects could
still let the blocking select randomly pick the free semaphore slot and
dispatch the item. Now a dispatch is only possible when the context was live
after slot acquisition.
* style: trim flaky-test fix comments to single lines
Compress each two-line comment added by this PR to the one line that carries
the invariant; drop the narration around it.
2026-08-10 14:10:05 -04:00
|
|
|
}, 2*time.Second, 10*time.Millisecond).Should(Equal(1))
|
2025-11-14 22:15:43 -05:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("Empty and Root Paths", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
// Start watcher in background
|
|
|
|
|
go func() {
|
|
|
|
|
_ = w.Run(ctx)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
// Give watcher time to initialize
|
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("handles empty folder path (library root)", func() {
|
|
|
|
|
// Send notification with empty folder path
|
|
|
|
|
w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""}
|
|
|
|
|
|
|
|
|
|
// Wait for scan
|
|
|
|
|
Eventually(func() int {
|
|
|
|
|
return mockScanner.GetScanFoldersCallCount()
|
fix(cache): write the completion marker before closing the cache writer (#5927)
* fix(cache): write the completion marker before closing the cache writer
Readers of an in-progress cache write see EOF the moment the writer closes,
but the .complete marker was created after the close, on the background
goroutine — so a fully-read stream did not mean the cache was done touching
disk. The new artwork precache spec ends right at EOF, and its
GinkgoT().TempDir() cleanup raced the marker creation, failing the Windows CI
job with 'unlinkat ...: The directory is not empty' (the race also reproduces
on macOS, 2 of 3 runs, with the tightened test).
Writing the marker after a clean copy but before Close makes reader-EOF imply
every on-disk write for the entry is finished. A failed writer Close still
invalidates the entry, which removes both the marker and the data file. The
existing marker test now asserts the marker exists immediately at EOF instead
of Eventually.
* fix(artwork): never dispatch queue items after the drain context is cancelled
The 10x Windows stress run for the previous commit surfaced a second flake in
the same package: 'leaves undispatched items queued when cancelled mid-batch'
lost row alc7 in 4 of 10 runs. In drain, when a semaphore slot is free and the
context is already cancelled, both cases of the blocking select are ready and
Go picks one at random — so a cancelled drain could still dispatch items. A
non-blocking Done check before the select gives cancellation priority.
The race was invisible on Linux/macOS only by accident: the spec seeded the
album repo with a single album (each SetData overwrote the last), so only the
final row (alc7) resolved to absent and got deleted when dispatched; the
others fell on the retry path and survived. Nanosecond enqueue timestamps
made alc0 always first out of the mock dequeue, masking the race, while
Windows' coarse clock ties the timestamps and randomizes the order. The spec
now seeds all eight albums, which made the race reproduce locally on the
first try (row alc0) and now guards the fix on every platform.
* test: give cache-init waits a 10s timeout for loaded CI runners
A 10x parallel Windows stress run timed out one artwork spec in BeforeEach:
the FileCache init goroutine (mkdir + reload walk) took over Gomega's default
1s Eventually timeout under shared-runner disk contention. Bump the three
identical init waits (two artwork suites and the utils/cache helper) to 10s.
* test(scanner): widen watcher debounce margins for loaded CI runners
The watcher debouncing spec asserts 'no scan yet' inside 20ms Consistently
windows while the debounce wait was only 50ms — a 2.5x margin that a loaded
Windows runner blows through by delaying the timer-reset notification, firing
the scan early (failed all three FlakeAttempts in a 10x stress run). Raise the
test debounce wait to 200ms (10x the observation windows) and the scan-fired
Eventually timeouts to 2s to match.
* refactor(artwork): collapse drain cancellation into a single exit path
Replace the non-blocking ctx pre-check plus duplicated select exit with one
select and a ctx.Err() check after it. Besides removing the duplication, this
closes the residual race: a cancellation landing between the two selects could
still let the blocking select randomly pick the free semaphore slot and
dispatch the item. Now a dispatch is only possible when the context was live
after slot acquisition.
* style: trim flaky-test fix comments to single lines
Compress each two-line comment added by this PR to the one line that carries
the invariant; drop the narration around it.
2026-08-10 14:10:05 -04:00
|
|
|
}, 2*time.Second, 10*time.Millisecond).Should(Equal(1))
|
2025-11-14 22:15:43 -05:00
|
|
|
|
|
|
|
|
// Should scan the library root
|
|
|
|
|
calls := mockScanner.GetScanFoldersCalls()
|
|
|
|
|
Expect(calls).To(HaveLen(1))
|
|
|
|
|
Expect(calls[0].Targets).To(HaveLen(1))
|
|
|
|
|
Expect(calls[0].Targets[0].FolderPath).To(Equal(""))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("deduplicates empty and dot paths", func() {
|
|
|
|
|
// Send notifications with empty and dot paths
|
|
|
|
|
w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""}
|
|
|
|
|
w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""}
|
|
|
|
|
|
|
|
|
|
// Wait for scan
|
|
|
|
|
Eventually(func() int {
|
|
|
|
|
return mockScanner.GetScanFoldersCallCount()
|
fix(cache): write the completion marker before closing the cache writer (#5927)
* fix(cache): write the completion marker before closing the cache writer
Readers of an in-progress cache write see EOF the moment the writer closes,
but the .complete marker was created after the close, on the background
goroutine — so a fully-read stream did not mean the cache was done touching
disk. The new artwork precache spec ends right at EOF, and its
GinkgoT().TempDir() cleanup raced the marker creation, failing the Windows CI
job with 'unlinkat ...: The directory is not empty' (the race also reproduces
on macOS, 2 of 3 runs, with the tightened test).
Writing the marker after a clean copy but before Close makes reader-EOF imply
every on-disk write for the entry is finished. A failed writer Close still
invalidates the entry, which removes both the marker and the data file. The
existing marker test now asserts the marker exists immediately at EOF instead
of Eventually.
* fix(artwork): never dispatch queue items after the drain context is cancelled
The 10x Windows stress run for the previous commit surfaced a second flake in
the same package: 'leaves undispatched items queued when cancelled mid-batch'
lost row alc7 in 4 of 10 runs. In drain, when a semaphore slot is free and the
context is already cancelled, both cases of the blocking select are ready and
Go picks one at random — so a cancelled drain could still dispatch items. A
non-blocking Done check before the select gives cancellation priority.
The race was invisible on Linux/macOS only by accident: the spec seeded the
album repo with a single album (each SetData overwrote the last), so only the
final row (alc7) resolved to absent and got deleted when dispatched; the
others fell on the retry path and survived. Nanosecond enqueue timestamps
made alc0 always first out of the mock dequeue, masking the race, while
Windows' coarse clock ties the timestamps and randomizes the order. The spec
now seeds all eight albums, which made the race reproduce locally on the
first try (row alc0) and now guards the fix on every platform.
* test: give cache-init waits a 10s timeout for loaded CI runners
A 10x parallel Windows stress run timed out one artwork spec in BeforeEach:
the FileCache init goroutine (mkdir + reload walk) took over Gomega's default
1s Eventually timeout under shared-runner disk contention. Bump the three
identical init waits (two artwork suites and the utils/cache helper) to 10s.
* test(scanner): widen watcher debounce margins for loaded CI runners
The watcher debouncing spec asserts 'no scan yet' inside 20ms Consistently
windows while the debounce wait was only 50ms — a 2.5x margin that a loaded
Windows runner blows through by delaying the timer-reset notification, firing
the scan early (failed all three FlakeAttempts in a 10x stress run). Raise the
test debounce wait to 200ms (10x the observation windows) and the scan-fired
Eventually timeouts to 2s to match.
* refactor(artwork): collapse drain cancellation into a single exit path
Replace the non-blocking ctx pre-check plus duplicated select exit with one
select and a ctx.Err() check after it. Besides removing the duplication, this
closes the residual race: a cancellation landing between the two selects could
still let the blocking select randomly pick the free semaphore slot and
dispatch the item. Now a dispatch is only possible when the context was live
after slot acquisition.
* style: trim flaky-test fix comments to single lines
Compress each two-line comment added by this PR to the one line that carries
the invariant; drop the narration around it.
2026-08-10 14:10:05 -04:00
|
|
|
}, 2*time.Second, 10*time.Millisecond).Should(Equal(1))
|
2025-11-14 22:15:43 -05:00
|
|
|
|
|
|
|
|
// Should have only one target
|
|
|
|
|
calls := mockScanner.GetScanFoldersCalls()
|
|
|
|
|
Expect(calls).To(HaveLen(1))
|
|
|
|
|
Expect(calls[0].Targets).To(HaveLen(1))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe("Multiple Libraries", func() {
|
|
|
|
|
var lib2 *model.Library
|
|
|
|
|
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
// Create second library
|
|
|
|
|
lib2 = &model.Library{
|
|
|
|
|
ID: 2,
|
|
|
|
|
Name: "Test Library 2",
|
fix(scanner): resolve file symlinks with the production local storage FS (#5755)
* fix(scanner): resolve file symlinks with the production local storage FS
The symlink classification added for GHSA-r5qr-m328-qcf4 relied on
fs.ReadLink, but the local storage FS wraps os.DirFS behind the fs.FS
interface, hiding its ReadLinkFS implementation. Every resolution failed
at the first hop, so the scanner silently skipped ALL file symlinks,
regardless of target or the FollowSymlinks setting. Libraries made of
symlinks (e.g. shared-pool setups) lost all their tracks after
upgrading to 0.63.
The local storage now exposes full OS-level resolution (EvalSymlinks)
through a new optional storage.SymlinkResolverFS interface, which the
scanner prefers over the fs.ReadLink hop loop. This also classifies a
chain by its FINAL target even when it passes through an audio-named
intermediate outside the library, closing a bypass the hop loop had.
Regular (non-symlink) entries keep the same early-return path, so scan
performance is unaffected for normal libraries.
Fixes #5752
* fix(test): keep watcher specs off the real local storage
The watcher specs spawn watchLibrary goroutines that are not joined on
spec teardown. Now that the scanner test binary registers the file://
storage, those leaked goroutines reached newLocalStorage, which reads
conf.Server on construction, racing with the configtest cleanup that
restores the config snapshot (caught by CI's race detector). Point the
mock libraries at a fake storage scheme, which never touches the config
and does not support watching, so the goroutine exits immediately.
* fix(storage): reject invalid fs paths in ResolveSymlink
Defense-in-depth for the SymlinkResolverFS contract: names must be valid
fs.FS paths. A lexical ".." in the name would otherwise escape the
library root via filepath.Join cleaning. No current caller can produce
such a name (they come from ReadDir walks), but the guard enforces the
documented contract at the boundary.
2026-07-10 10:52:05 -04:00
|
|
|
Path: "fake-watcher:///test/library2",
|
2025-11-14 22:15:43 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mockLibRepo := mockDS.MockedLibrary.(*tests.MockLibraryRepo)
|
|
|
|
|
mockLibRepo.SetData(model.Libraries{*lib, *lib2})
|
|
|
|
|
|
|
|
|
|
// Start watcher in background
|
|
|
|
|
go func() {
|
|
|
|
|
_ = w.Run(ctx)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
// Give watcher time to initialize
|
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("creates separate targets for different libraries", func() {
|
|
|
|
|
// Send notifications for both libraries
|
|
|
|
|
w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
|
|
|
|
|
w.watcherNotify <- scanNotification{Library: lib2, FolderPath: "artist2"}
|
|
|
|
|
|
2026-05-03 11:26:07 -04:00
|
|
|
// Wait for a scan that collected both targets
|
|
|
|
|
Eventually(func() []model.ScanTarget {
|
|
|
|
|
calls := mockScanner.GetScanFoldersCalls()
|
|
|
|
|
if len(calls) == 0 {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return calls[0].Targets
|
fix(cache): write the completion marker before closing the cache writer (#5927)
* fix(cache): write the completion marker before closing the cache writer
Readers of an in-progress cache write see EOF the moment the writer closes,
but the .complete marker was created after the close, on the background
goroutine — so a fully-read stream did not mean the cache was done touching
disk. The new artwork precache spec ends right at EOF, and its
GinkgoT().TempDir() cleanup raced the marker creation, failing the Windows CI
job with 'unlinkat ...: The directory is not empty' (the race also reproduces
on macOS, 2 of 3 runs, with the tightened test).
Writing the marker after a clean copy but before Close makes reader-EOF imply
every on-disk write for the entry is finished. A failed writer Close still
invalidates the entry, which removes both the marker and the data file. The
existing marker test now asserts the marker exists immediately at EOF instead
of Eventually.
* fix(artwork): never dispatch queue items after the drain context is cancelled
The 10x Windows stress run for the previous commit surfaced a second flake in
the same package: 'leaves undispatched items queued when cancelled mid-batch'
lost row alc7 in 4 of 10 runs. In drain, when a semaphore slot is free and the
context is already cancelled, both cases of the blocking select are ready and
Go picks one at random — so a cancelled drain could still dispatch items. A
non-blocking Done check before the select gives cancellation priority.
The race was invisible on Linux/macOS only by accident: the spec seeded the
album repo with a single album (each SetData overwrote the last), so only the
final row (alc7) resolved to absent and got deleted when dispatched; the
others fell on the retry path and survived. Nanosecond enqueue timestamps
made alc0 always first out of the mock dequeue, masking the race, while
Windows' coarse clock ties the timestamps and randomizes the order. The spec
now seeds all eight albums, which made the race reproduce locally on the
first try (row alc0) and now guards the fix on every platform.
* test: give cache-init waits a 10s timeout for loaded CI runners
A 10x parallel Windows stress run timed out one artwork spec in BeforeEach:
the FileCache init goroutine (mkdir + reload walk) took over Gomega's default
1s Eventually timeout under shared-runner disk contention. Bump the three
identical init waits (two artwork suites and the utils/cache helper) to 10s.
* test(scanner): widen watcher debounce margins for loaded CI runners
The watcher debouncing spec asserts 'no scan yet' inside 20ms Consistently
windows while the debounce wait was only 50ms — a 2.5x margin that a loaded
Windows runner blows through by delaying the timer-reset notification, firing
the scan early (failed all three FlakeAttempts in a 10x stress run). Raise the
test debounce wait to 200ms (10x the observation windows) and the scan-fired
Eventually timeouts to 2s to match.
* refactor(artwork): collapse drain cancellation into a single exit path
Replace the non-blocking ctx pre-check plus duplicated select exit with one
select and a ctx.Err() check after it. Besides removing the duplication, this
closes the residual race: a cancellation landing between the two selects could
still let the blocking select randomly pick the free semaphore slot and
dispatch the item. Now a dispatch is only possible when the context was live
after slot acquisition.
* style: trim flaky-test fix comments to single lines
Compress each two-line comment added by this PR to the one line that carries
the invariant; drop the narration around it.
2026-08-10 14:10:05 -04:00
|
|
|
}, 2*time.Second, 10*time.Millisecond).Should(HaveLen(2))
|
2025-11-14 22:15:43 -05:00
|
|
|
|
|
|
|
|
// Verify library IDs are different
|
2026-05-03 11:26:07 -04:00
|
|
|
calls := mockScanner.GetScanFoldersCalls()
|
2025-11-14 22:15:43 -05:00
|
|
|
libraryIDs := make(map[int]bool)
|
|
|
|
|
for _, target := range calls[0].Targets {
|
|
|
|
|
libraryIDs[target.LibraryID] = true
|
|
|
|
|
}
|
|
|
|
|
Expect(libraryIDs).To(HaveKey(1))
|
|
|
|
|
Expect(libraryIDs).To(HaveKey(2))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Describe(".ndignore handling", func() {
|
|
|
|
|
var ctx context.Context
|
|
|
|
|
var cancel context.CancelFunc
|
|
|
|
|
var w *watcher
|
|
|
|
|
var mockFS *mockMusicFS
|
|
|
|
|
var lib *model.Library
|
|
|
|
|
var eventChan chan string
|
|
|
|
|
var absLibPath string
|
|
|
|
|
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
ctx, cancel = context.WithCancel(GinkgoT().Context())
|
|
|
|
|
DeferCleanup(cancel)
|
|
|
|
|
|
|
|
|
|
// Set up library
|
|
|
|
|
var err error
|
|
|
|
|
absLibPath, err = filepath.Abs(".")
|
|
|
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
|
|
|
|
|
|
lib = &model.Library{
|
|
|
|
|
ID: 1,
|
|
|
|
|
Name: "Test Library",
|
|
|
|
|
Path: absLibPath,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create watcher with notification channel
|
|
|
|
|
w = &watcher{
|
|
|
|
|
watcherNotify: make(chan scanNotification, 10),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
eventChan = make(chan string, 10)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Helper to send an event - converts relative path to absolute
|
|
|
|
|
sendEvent := func(relativePath string) {
|
|
|
|
|
path := filepath.Join(absLibPath, relativePath)
|
|
|
|
|
eventChan <- path
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper to start the real event processing loop
|
|
|
|
|
startEventProcessing := func() {
|
|
|
|
|
go func() {
|
|
|
|
|
defer GinkgoRecover()
|
|
|
|
|
// Call the actual processLibraryEvents method - testing the real implementation!
|
|
|
|
|
_ = w.processLibraryEvents(ctx, lib, mockFS, eventChan, absLibPath)
|
|
|
|
|
}()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Context("when a folder matching .ndignore is deleted", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
// Create filesystem with .ndignore containing _TEMP pattern
|
|
|
|
|
// The deleted folder (_TEMP) will NOT exist in the filesystem
|
|
|
|
|
mockFS = &mockMusicFS{
|
|
|
|
|
FS: fstest.MapFS{
|
|
|
|
|
"rock": &fstest.MapFile{Mode: fs.ModeDir},
|
|
|
|
|
"rock/.ndignore": &fstest.MapFile{Data: []byte("_TEMP\n")},
|
|
|
|
|
"rock/valid_album": &fstest.MapFile{Mode: fs.ModeDir},
|
|
|
|
|
"rock/valid_album/track.mp3": &fstest.MapFile{Data: []byte("audio")},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("should NOT send scan notification when deleted folder matches .ndignore", func() {
|
|
|
|
|
startEventProcessing()
|
|
|
|
|
|
|
|
|
|
// Simulate deletion event for rock/_TEMP
|
|
|
|
|
sendEvent("rock/_TEMP")
|
|
|
|
|
|
|
|
|
|
// Wait a bit to ensure event is processed
|
|
|
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
|
|
|
|
|
|
// No notification should have been sent
|
|
|
|
|
Consistently(eventChan, 100*time.Millisecond).Should(BeEmpty())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("should send scan notification for valid folder deletion", func() {
|
|
|
|
|
startEventProcessing()
|
|
|
|
|
|
|
|
|
|
// Simulate deletion event for rock/other_folder (not in .ndignore and doesn't exist)
|
|
|
|
|
// Since it doesn't exist in mockFS, resolveFolderPath will walk up to "rock"
|
|
|
|
|
sendEvent("rock/other_folder")
|
|
|
|
|
|
|
|
|
|
// Should receive notification for parent folder
|
|
|
|
|
Eventually(w.watcherNotify, 200*time.Millisecond).Should(Receive(Equal(scanNotification{
|
|
|
|
|
Library: lib,
|
|
|
|
|
FolderPath: "rock",
|
|
|
|
|
})))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("with nested folder patterns", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
mockFS = &mockMusicFS{
|
|
|
|
|
FS: fstest.MapFS{
|
|
|
|
|
"music": &fstest.MapFile{Mode: fs.ModeDir},
|
|
|
|
|
"music/.ndignore": &fstest.MapFile{Data: []byte("**/temp\n**/cache\n")},
|
|
|
|
|
"music/rock": &fstest.MapFile{Mode: fs.ModeDir},
|
|
|
|
|
"music/rock/artist": &fstest.MapFile{Mode: fs.ModeDir},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("should NOT send notification when nested ignored folder is deleted", func() {
|
2026-04-19 13:16:47 -04:00
|
|
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
2025-11-14 22:15:43 -05:00
|
|
|
startEventProcessing()
|
|
|
|
|
|
|
|
|
|
// Simulate deletion of music/rock/artist/temp (matches **/temp)
|
|
|
|
|
sendEvent("music/rock/artist/temp")
|
|
|
|
|
|
|
|
|
|
// Wait to ensure event is processed
|
|
|
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
|
|
|
|
|
|
// No notification should be sent
|
|
|
|
|
Expect(w.watcherNotify).To(BeEmpty(), "Expected no scan notification for nested ignored folder")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("should send notification for non-ignored nested folder", func() {
|
2026-04-19 13:16:47 -04:00
|
|
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
2025-11-14 22:15:43 -05:00
|
|
|
startEventProcessing()
|
|
|
|
|
|
|
|
|
|
// Simulate change in music/rock/artist (doesn't match any pattern)
|
|
|
|
|
sendEvent("music/rock/artist")
|
|
|
|
|
|
|
|
|
|
// Should receive notification
|
|
|
|
|
Eventually(w.watcherNotify, 200*time.Millisecond).Should(Receive(Equal(scanNotification{
|
|
|
|
|
Library: lib,
|
|
|
|
|
FolderPath: "music/rock/artist",
|
|
|
|
|
})))
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("with file events in ignored folders", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
mockFS = &mockMusicFS{
|
|
|
|
|
FS: fstest.MapFS{
|
|
|
|
|
"rock": &fstest.MapFile{Mode: fs.ModeDir},
|
|
|
|
|
"rock/.ndignore": &fstest.MapFile{Data: []byte("_TEMP\n")},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("should NOT send notification for file changes in ignored folders", func() {
|
2026-04-19 13:16:47 -04:00
|
|
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
2025-11-14 22:15:43 -05:00
|
|
|
startEventProcessing()
|
|
|
|
|
|
|
|
|
|
// Simulate file change in rock/_TEMP/file.mp3
|
|
|
|
|
sendEvent("rock/_TEMP/file.mp3")
|
|
|
|
|
|
|
|
|
|
// Wait to ensure event is processed
|
|
|
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
|
|
|
|
|
|
// No notification should be sent
|
|
|
|
|
Expect(w.watcherNotify).To(BeEmpty(), "Expected no scan notification for file in ignored folder")
|
|
|
|
|
})
|
|
|
|
|
})
|
feat(scanner): add Scanner.IgnoreDotFolders to allow indexing dot-prefixed folders (#5568)
* feat(scanner): add Scanner.IgnoreDotFolders to allow scanning dot folders
Adds a new Scanner.IgnoreDotFolders option (default true, preserving current
behavior) that, when disabled, lets the scanner traverse folders whose names
start with a dot, such as albums like ".Hack Sign Original Soundtrack".
Previously every dot-prefixed entry was skipped unconditionally before the
directory check, so such album folders were never indexed. The walk loop now
determines whether an entry is a directory first, then skips dot-prefixed files
always and dot-prefixed folders only when IgnoreDotFolders is enabled. Special
system directories are still ignored in all cases via the ignoredDirs blocklist,
which now also lists .git explicitly (it was previously caught only by the
generic dot-prefix rule). isDirIgnored is reduced to a pure blocklist check and
the name-only predicate is renamed from isEntryIgnored to isDotEntry.
* refactor(scanner): centralize entry ignore policy in isIgnoredEntry
Consolidates the directory-entry ignore decision into a single isIgnoredEntry
helper so the walk loop reads as pure dispatch (recurse into directories,
classify files) instead of interleaving ignore policy with traversal.
The dot-prefix rule and the ignoredDirs blocklist were previously checked in two
separate places inside loadDir's loop. They are now combined behind one helper
that takes the entry name and whether it is a directory. isDirIgnored remains a
standalone blocklist predicate because the file watcher (isIgnoredPath) calls it
directly. Adds focused unit tests for isIgnoredEntry covering both states of
Scanner.IgnoreDotFolders. No behavior change.
* fix(scanner): stop watcher from scanning ignored dot folders
A filesystem change inside a dot-prefixed folder (e.g. ".Hidden Album/track.mp3")
previously triggered a targeted scan of that folder, because isIgnoredPath let
all media files through and only checked the changed path's parent against the
ignore list (which never matched for nested paths due to the trailing separator).
With Scanner.IgnoreDotFolders enabled this caused the folder to be indexed even
though a full scan would skip it.
The watcher now ignores any change located inside an ignored directory via a new
isUnderIgnoredDir helper that reuses the same isIgnoredEntry policy as the scan
walk, and checks the entry itself with isIgnoredEntry instead of the parent dir.
This keeps the watcher and the scanner in agreement for both dot-folders (gated
by the flag) and the ignoredDirs blocklist. Adds direct table tests for
isIgnoredPath covering both states of the option.
* fix(scanner): exclude '.' from isDotEntry and ignore dot media files in watcher
Addresses code review feedback:
- isDotEntry now excludes the literal "." reference, matching its documentation.
Previously isDotEntry(".") returned true, which could mark a path component as
a dot-entry in the watcher.
- isIgnoredPath now ignores dot-prefixed media files (e.g. ".hidden.mp3") so the
watcher matches the scanner, which always skips dot files. Non-media leaves
still fall through to the directory-assumption check, so dot-folders continue
to follow Scanner.IgnoreDotFolders.
Adds unit tests for isDotEntry and watcher coverage for dot-prefixed media files.
* docs(scanner): clarify isDotEntry multi-dot exclusion and add test
Expand the isDotEntry comment to explain why names with two or more
leading dots (".."/"..foo"/"...Album") are not treated as hidden, which
surprised a reviewer testing dot-folder scanning. Add a "..foo" test case
to make the two-leading-dots behavior explicit.
Claude-Session: https://claude.ai/code/session_012STiDTyhZAdH8JNtdNe8L1
2026-06-19 19:13:16 -04:00
|
|
|
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
var _ = Describe("isIgnoredPath", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
DeferCleanup(configtest.SetupConfig())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("with IgnoreDotFolders enabled (default)", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
conf.Server.Scanner.IgnoreDotFolders = true
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
DescribeTable("returns expected result",
|
|
|
|
|
func(p string, expected bool) {
|
|
|
|
|
Expect(isIgnoredPath(context.Background(), nil, filepath.FromSlash(p))).To(Equal(expected))
|
|
|
|
|
},
|
|
|
|
|
Entry("media file in normal folder", "rock/Album/track.mp3", false),
|
|
|
|
|
Entry("dot-prefixed media file", "rock/Album/.hidden.mp3", true),
|
|
|
|
|
Entry("media file inside a dot-folder", "rock/.Hidden Album/track.mp3", true),
|
|
|
|
|
Entry("media file inside a blocklisted folder", "rock/.streams/stream.mp3", true),
|
|
|
|
|
Entry("media file inside .git", "rock/.git/track.mp3", true),
|
|
|
|
|
Entry("dot-folder itself", "rock/.Hidden Album", true),
|
|
|
|
|
Entry("normal folder itself", "rock/Album", false),
|
|
|
|
|
Entry(".DS_Store file", "rock/Album/.DS_Store", true),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
Context("with IgnoreDotFolders disabled", func() {
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
conf.Server.Scanner.IgnoreDotFolders = false
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
DescribeTable("returns expected result",
|
|
|
|
|
func(p string, expected bool) {
|
|
|
|
|
Expect(isIgnoredPath(context.Background(), nil, filepath.FromSlash(p))).To(Equal(expected))
|
|
|
|
|
},
|
|
|
|
|
Entry("media file inside a dot-folder is allowed", "rock/.Hidden Album/track.mp3", false),
|
|
|
|
|
Entry("dot-prefixed media file is still ignored", "rock/Album/.hidden.mp3", true),
|
|
|
|
|
Entry("dot-folder itself is allowed", "rock/.Hidden Album", false),
|
|
|
|
|
Entry("blocklisted folder still ignored", "rock/.streams/stream.mp3", true),
|
|
|
|
|
Entry(".git still ignored", "rock/.git/config", true),
|
|
|
|
|
)
|
2025-11-14 22:15:43 -05:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
var _ = Describe("resolveFolderPath", func() {
|
|
|
|
|
var mockFS fs.FS
|
|
|
|
|
|
|
|
|
|
BeforeEach(func() {
|
|
|
|
|
// Create a mock filesystem with some directories and files
|
|
|
|
|
mockFS = fstest.MapFS{
|
|
|
|
|
"artist1": &fstest.MapFile{Mode: fs.ModeDir},
|
|
|
|
|
"artist1/album1": &fstest.MapFile{Mode: fs.ModeDir},
|
|
|
|
|
"artist1/album1/track1.mp3": &fstest.MapFile{Data: []byte("audio")},
|
|
|
|
|
"artist1/album1/track2.mp3": &fstest.MapFile{Data: []byte("audio")},
|
|
|
|
|
"artist1/album2": &fstest.MapFile{Mode: fs.ModeDir},
|
|
|
|
|
"artist1/album2/song.flac": &fstest.MapFile{Data: []byte("audio")},
|
|
|
|
|
"artist2": &fstest.MapFile{Mode: fs.ModeDir},
|
|
|
|
|
"artist2/cover.jpg": &fstest.MapFile{Data: []byte("image")},
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("returns directory path when given a directory", func() {
|
|
|
|
|
result := resolveFolderPath(mockFS, "artist1/album1")
|
|
|
|
|
Expect(result).To(Equal("artist1/album1"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("walks up to parent directory when given a file path", func() {
|
2026-04-19 13:16:47 -04:00
|
|
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
2025-11-14 22:15:43 -05:00
|
|
|
result := resolveFolderPath(mockFS, "artist1/album1/track1.mp3")
|
|
|
|
|
Expect(result).To(Equal("artist1/album1"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("walks up multiple levels if needed", func() {
|
2026-04-19 13:16:47 -04:00
|
|
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
2025-11-14 22:15:43 -05:00
|
|
|
result := resolveFolderPath(mockFS, "artist1/album1/nonexistent/file.mp3")
|
|
|
|
|
Expect(result).To(Equal("artist1/album1"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("returns empty string for non-existent paths at root", func() {
|
|
|
|
|
result := resolveFolderPath(mockFS, "nonexistent/path/file.mp3")
|
|
|
|
|
Expect(result).To(Equal(""))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("returns empty string for dot path", func() {
|
|
|
|
|
result := resolveFolderPath(mockFS, ".")
|
|
|
|
|
Expect(result).To(Equal(""))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("returns empty string for empty path", func() {
|
|
|
|
|
result := resolveFolderPath(mockFS, "")
|
|
|
|
|
Expect(result).To(Equal(""))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("handles nested file paths correctly", func() {
|
2026-04-19 13:16:47 -04:00
|
|
|
tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)")
|
2025-11-14 22:15:43 -05:00
|
|
|
result := resolveFolderPath(mockFS, "artist1/album2/song.flac")
|
|
|
|
|
Expect(result).To(Equal("artist1/album2"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("resolves to top-level directory", func() {
|
|
|
|
|
result := resolveFolderPath(mockFS, "artist2/cover.jpg")
|
|
|
|
|
Expect(result).To(Equal("artist2"))
|
|
|
|
|
})
|
|
|
|
|
})
|