fix(server): return correct scanType in startScan response (#5159)

* fix(api): return correct scanType in startScan response

The startScan endpoint launches the scan in a goroutine and immediately
calls GetScanStatus to build the response. Because the scanner hasn't
had time to initialize and write its state to the database, the response
contained stale data from the previous scan (e.g., scanType "quick"
when fullScan=true was requested).

Add a polling loop that waits briefly (up to 3s, polling every 50ms) for
the scanner to report Scanning=true before returning the status. If the
timeout expires, it falls back to the current behavior (no regression).

Fixes #5158

* fix(api): use ticker/timer with context cancellation for scan polling

Replace time.Sleep loop with proper ticker, timer, and ctx.Done()
handling so the poll exits cleanly on timeout or client disconnect.

* fix(api): handle fast scan completion in poll loop

Add a channel to detect when the scan goroutine finishes before the
poll loop observes Scanning=true, avoiding a 3s timeout on very fast
scans. Use defer close to handle both success and error paths.
This commit is contained in:
Deluan Quintão 2026-03-09 14:19:53 -04:00 committed by GitHub
parent e08d4bef16
commit d4b2499e1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 115 additions and 0 deletions

View file

@ -80,7 +80,9 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) {
}
}
fastScanCompleted := make(chan struct{})
go func() {
defer close(fastScanCompleted)
start := time.Now()
var err error
@ -99,5 +101,35 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) {
log.Info(ctx, "On-demand scan complete", "user", loggedUser.UserName, "elapsed", time.Since(start))
}()
// Wait briefly for the scanner to start and update its status, so the response
// reflects the current scan (not stale data from a previous scan).
const (
pollInterval = 50 * time.Millisecond
pollTimeout = 3 * time.Second
)
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
timer := time.NewTimer(pollTimeout)
defer timer.Stop()
loop:
for {
status, err := api.scanner.Status(ctx)
if err == nil && status.Scanning {
break
}
select {
case <-fastScanCompleted:
log.Info(ctx, "Fast scan completed", "user", loggedUser.UserName)
break loop
case <-timer.C:
log.Warn(ctx, "Timed out waiting for scanner to start; response may be stale")
break loop
case <-ctx.Done():
return nil, newError(responses.ErrorGeneric, "Request cancelled while waiting for scanner to start")
case <-ticker.C:
}
}
return api.GetScanStatus(r)
}

View file

@ -365,6 +365,66 @@ var _ = Describe("LibraryScanning", func() {
Expect(targets[0].LibraryID).To(Equal(1))
Expect(targets[0].FolderPath).To(Equal(""))
})
It("returns correct scanType in response when fullScan=false", func() {
// Setup mock to update status when scan starts (simulating the real scanner)
ms.SetScanStatusFunc(func(fullScan bool, targets []model.ScanTarget) *model.ScannerStatus {
scanType := "quick"
if fullScan {
scanType = "full"
}
return &model.ScannerStatus{
Scanning: true,
ScanType: scanType,
}
})
ctx := request.WithUser(context.Background(), model.User{
ID: "admin-id",
IsAdmin: true,
})
r := httptest.NewRequest("GET", "/rest/startScan", nil)
r = r.WithContext(ctx)
response, err := api.StartScan(r)
Expect(err).ToNot(HaveOccurred())
Expect(response).ToNot(BeNil())
Expect(response.ScanStatus).ToNot(BeNil())
Expect(response.ScanStatus.Scanning).To(BeTrue())
Expect(response.ScanStatus.ScanType).To(Equal("quick"))
})
It("returns correct scanType in response when fullScan=true", func() {
// Setup mock to update status when scan starts (simulating the real scanner)
ms.SetScanStatusFunc(func(fullScan bool, targets []model.ScanTarget) *model.ScannerStatus {
scanType := "quick"
if fullScan {
scanType = "full"
}
return &model.ScannerStatus{
Scanning: true,
ScanType: scanType,
}
})
ctx := request.WithUser(context.Background(), model.User{
ID: "admin-id",
IsAdmin: true,
})
r := httptest.NewRequest("GET", "/rest/startScan?fullScan=true", nil)
r = r.WithContext(ctx)
response, err := api.StartScan(r)
Expect(err).ToNot(HaveOccurred())
Expect(response).ToNot(BeNil())
Expect(response.ScanStatus).ToNot(BeNil())
Expect(response.ScanStatus.Scanning).To(BeTrue())
Expect(response.ScanStatus.ScanType).To(Equal("full"))
})
})
Describe("GetScanStatus", func() {

View file

@ -14,6 +14,7 @@ type MockScanner struct {
scanFoldersCalls []ScanFoldersCall
scanningStatus bool
statusResponse *model.ScannerStatus
scanStatusFunc func(fullScan bool, targets []model.ScanTarget) *model.ScannerStatus
}
type ScanAllCall struct {
@ -38,6 +39,13 @@ func (m *MockScanner) ScanAll(_ context.Context, fullScan bool) ([]string, error
m.scanAllCalls = append(m.scanAllCalls, ScanAllCall{FullScan: fullScan})
// Simulate the scanner updating its status when the scan starts
if m.scanStatusFunc != nil {
m.statusResponse = m.scanStatusFunc(fullScan, nil)
} else {
m.scanningStatus = true
}
return nil, nil
}
@ -54,6 +62,13 @@ func (m *MockScanner) ScanFolders(_ context.Context, fullScan bool, targets []mo
Targets: targetsCopy,
})
// Simulate the scanner updating its status when the scan starts
if m.scanStatusFunc != nil {
m.statusResponse = m.scanStatusFunc(fullScan, targetsCopy)
} else {
m.scanningStatus = true
}
return nil, nil
}
@ -118,3 +133,11 @@ func (m *MockScanner) SetStatusResponse(status *model.ScannerStatus) {
defer m.mu.Unlock()
m.statusResponse = status
}
// SetScanStatusFunc sets a function that will be called when ScanAll/ScanFolders is invoked,
// simulating the scanner updating its status when the scan starts.
func (m *MockScanner) SetScanStatusFunc(fn func(fullScan bool, targets []model.ScanTarget) *model.ScannerStatus) {
m.mu.Lock()
defer m.mu.Unlock()
m.scanStatusFunc = fn
}