fix(scanner): add nil guards to cursor wrapping (#5139)

* fix(persistence): add nil guards to cursor wrapping in folder and mediafile repos

Prevent SIGSEGV panic when queryWithStableResults yields a zero-value
struct on the rows.Err() path (e.g., "database is locked" during
concurrent scanning). Extract cursor wrapping into wrapFolderCursor and
wrapMediaFileCursor with nil checks matching the existing pattern in
album_repository.go.

Fixes #5138

* fix(persistence): wrap original cursor error in nil guard messages

Use %w to preserve the underlying error (e.g., "database is locked")
so callers can use errors.Is/As for root cause analysis. Tests now
verify the original error is accessible via errors.Is.

* fix(persistence): add nil guards and error wrapping in album, folder, and mediafile cursor functions

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2026-03-03 07:58:14 -05:00
committed by GitHub
parent c885766854
commit ed4c0ef432
6 changed files with 152 additions and 15 deletions
+42
View File
@@ -2,6 +2,8 @@ package persistence
import (
"context"
"errors"
"fmt"
"time"
"github.com/Masterminds/squirrel"
@@ -711,4 +713,44 @@ var _ = Describe("MediaRepository", func() {
Expect(results).To(BeEmpty())
})
})
Describe("wrapMediaFileCursor", func() {
It("does not panic when the cursor yields a dbMediaFile with nil MediaFile", func() {
// Simulate what queryWithStableResults does on the rows.Err() path:
// it yields a zero-value dbMediaFile (where MediaFile is nil) with an error.
dbErr := fmt.Errorf("database is locked")
cursor := func(yield func(dbMediaFile, error) bool) {
var empty dbMediaFile // MediaFile pointer is nil
yield(empty, dbErr)
}
// wrapMediaFileCursor should handle the nil MediaFile without panicking
wrappedCursor := wrapMediaFileCursor(cursor)
var gotErr error
Expect(func() {
for _, err := range wrappedCursor {
gotErr = err
}
}).ToNot(Panic())
Expect(gotErr).To(HaveOccurred())
Expect(gotErr.Error()).To(ContainSubstring("unexpected nil mediafile"))
Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error")
})
It("yields mediafiles from a valid cursor", func() {
mf := &model.MediaFile{ID: "mf1", Title: "Test"}
cursor := func(yield func(dbMediaFile, error) bool) {
yield(dbMediaFile{MediaFile: mf}, nil)
}
wrappedCursor := wrapMediaFileCursor(cursor)
var mediafiles []model.MediaFile
for m, err := range wrappedCursor {
Expect(err).ToNot(HaveOccurred())
mediafiles = append(mediafiles, m)
}
Expect(mediafiles).To(HaveLen(1))
Expect(mediafiles[0].ID).To(Equal("mf1"))
})
})
})