A library scan wipes the whole page cache while the reader is serving from it, deleting pages mid-chapter
What happened?
A library scan enqueues a recursive delete of the entire page cache directory. That job runs while the reader is still serving pages out of it, so pages of the chapter you are reading are deleted underneath you. The delete then races with the live reads and throws, which can leave a cache directory partially deleted.
ScannerService.cs enqueues the wipe in two places after a scan finishes:
https://github.com/Kareadita/Kavita/blob/develop/Kavita.Services/Scanner/ScannerService.cs#L332-L333
BackgroundJob.Enqueue(() => cacheService.CleanupChapters(existingChapterIdsToClean));
BackgroundJob.Enqueue(() => directoryService.ClearDirectory(directoryService.CacheDirectory));and again at line 577:
BackgroundJob.Enqueue(() => directoryService.ClearDirectory(directoryService.CacheDirectory));Neither one checks whether a chapter under CacheDirectory is in use.
Steps to reproduce
- Open a long chapter in the reader. A webtoon image-folder chapter of 100+ pages is the easiest case, because the cache directory is large enough that the delete and the read overlap.
- Start reading, so the reader keeps requesting pages from
config/cache/<chapterId>/. - Trigger a library scan on any library while you are still reading. Touching files in a watched folder is enough —
LibraryWatcherfires the scan by itself. - Pages stop loading part-way through the chapter.
Evidence
Reading chapters 57 and 58 of one series while a watched folder in a different library was being rescanned. Chapter id 87289 is chapter 57 and 87573 is chapter 58.
Six cache-delete jobs failed inside one 11 minute reading session:
23:07:26 job 20688 IOException: Directory not empty : '/kavita/config/cache/8679'
23:11:58 job 24131 IOException: Directory not empty : '/kavita/config/cache/87289'
23:11:59 job 24138 IOException: Directory not empty : '/kavita/config/cache/87289'
23:12:39 job 24227 DirectoryNotFoundException: '/kavita/config/cache/87289/079.jpg'
23:14:59 job 24528 IOException: Directory not empty : '/kavita/config/cache/87573'
23:18:03 job 24582 DirectoryNotFoundException: '/kavita/config/cache/87289/'Two of the traces:
[2026-09-05 23:14:59.329] [Error] Hangfire.AutomaticRetryAttribute Failed to process the job '24528': an exception occurred.
System.IO.IOException: Directory not empty : '/kavita/config/cache/87573'
at System.IO.FileSystem.RemoveDirectoryRecursive(String fullPath)
at Kavita.Services.DirectoryService.ClearDirectory(String directoryPath) in /home/runner/work/Kavita/Kavita/Kavita.Services/DirectoryService.cs:line 380
at InvokeStub_DirectoryService.ClearDirectory(Object, Span`1)
at System.Reflection.RuntimeMethodInfo.Invoke(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)[2026-09-05 23:18:03.009] [Error] Hangfire.AutomaticRetryAttribute Failed to process the job '24582': an exception occurred.
System.IO.DirectoryNotFoundException: Could not find a part of the path '/kavita/config/cache/87289/'.
at System.IO.FileSystem.RemoveEmptyDirectory(String fullPath, Boolean topLevel, Boolean throwWhenNotEmpty)
at System.IO.DirectoryInfo.Delete(Boolean recursive)
at System.IO.Abstractions.DirectoryInfoWrapper.Delete(Boolean recursive)
at Kavita.Services.CacheService.CleanupChapters(IEnumerable`1 chapterIds) in /home/runner/work/Kavita/Kavita/Kavita.Services/CacheService.cs:line 271Two details worth calling out.
The delete really is removing pages that are in use, not only failing to remove a directory. Job 24227 got as far as 079.jpg and found it already gone, which means an earlier attempt had removed part of the same directory.
The reader was reading the exact directory being deleted. Job 24528 deleted from cache/87573 at 23:14:59.329, and the next lines in the same log are:
23:15:01.887 HTTP GET /api/reader/image?chapterId=87573&page=99 responded 200
23:15:02.401 HTTP GET /api/reader/image?chapterId=87573&page=100 responded 200
23:15:06.696 HTTP GET /api/reader/image?chapterId=87573&page=101 responded 200Why this is easy to hit
LibraryWatcher can fire many scans in a row, and every completed scan enqueues another full-cache wipe. In the 20 minutes covered above there were 73 Beginning file scan on ... lines, 50 of them for a single series, because a bulk file copy into one watched folder kept re-triggering the watcher. So the whole page cache was being wiped repeatedly while a chapter in an unrelated library was open in the reader.
The cache is shared, so a scan of library A destroys the cached pages of a chapter being read from library B.
Aftermath
I compared everything under the reader afterwards and the library data was never at fault: 111 source files with no gaps, 111 pages in the database, and all 111 pages served MD5-identical to the source files once the cache had been rebuilt. Only the timing was wrong.
What did you expect?
A scan should not delete cached pages of a chapter that is currently being read.
Some options, in rough order of how invasive they are:
- Do not recursively delete
CacheDirectoryas a whole. Delete only the chapter directories whose page counts the scan actually changed.existingChapterIdsToCleanon the line above already carries that set. - Skip any chapter that has an open reading session, or that was read from within the last few minutes, and clean it later.
- Take a per-chapter lock shared with
CacheService.Ensure, so a delete and a populate cannot interleave on the same directory. - At minimum, make
ClearDirectorytolerate the race instead of throwing, and re-verify the directory afterwards. A partially deleted directory still looks populated toCacheService, so the gap survives until something forces a re-extract.
Related: #3753 fixed a permissions cause of a left-behind cache folder in 0.8.7. This is the same visible symptom from a concurrency cause, and it is still present in 0.9.1.
Kavita Version Number
0.9.1 - Stable (reported as 0.9.1.0)
Are you accessing kavita through a reverse proxy?
Yes. The defect is server-side and does not involve the proxy: it is a Hangfire background job deleting files on disk, and every trace above comes from the server log. I also confirmed the same server state over a direct connection to the container port, with the proxy out of the path.
What operating system is Kavita being hosted from?
Docker (Dockerhub Container) — jvmilazz0/kavita:0.9.1, on Linux.
Additional Notes
The affected chapters were image-folder (webtoon) chapters of 111 and 130 pages. A short chapter finishes copying before the delete job runs, which is why this looks intermittent rather than constant.
Source: Kareadita/Kavita