From 55882a709137817f530f0efb6587c340418f1079 Mon Sep 17 00:00:00 2001 From: Mike Allen Date: Fri, 2 Oct 2026 09:06:16 -0700 Subject: [PATCH] fix: retry the cache info rename while Windows reports the file in use JsonCacheInfoRepository writes through a temp file and renames it over the cache info file. Windows refuses that rename while another process holds either file open without delete sharing, which antivirus and the search indexer routinely do just after a file is written, so the write failed with ERROR_SHARING_VIOLATION and stayed pending until the next change. The rename is now retried a few times, about 750ms in all, when it fails with ERROR_SHARING_VIOLATION (32) or ERROR_ACCESS_DENIED (5). --- flutter_cache_manager/CHANGELOG.md | 4 + .../json_cache_info_repository.dart | 35 ++++- flutter_cache_manager/pubspec.yaml | 2 +- .../json_file_repository_test.dart | 123 ++++++++++++++++++ 4 files changed, 162 insertions(+), 2 deletions(-) diff --git a/flutter_cache_manager/CHANGELOG.md b/flutter_cache_manager/CHANGELOG.md index 9513fb80..f3d0370f 100644 --- a/flutter_cache_manager/CHANGELOG.md +++ b/flutter_cache_manager/CHANGELOG.md @@ -1,3 +1,7 @@ +## [3.4.6] - 2026-10-02 + +* Retries the atomic rename in `JsonCacheInfoRepository` briefly when Windows reports that another process has the cache info file or its temp file open (`ERROR_SHARING_VIOLATION` / `ERROR_ACCESS_DENIED`), as antivirus and the search indexer often do just after a write. The write previously failed and stayed pending until the next change. + ## [3.4.5] - 2026-09-18 * Prevents image resize decoding from completing more than once and propagates decoding errors ([#400](https://github.com/Baseflow/flutter_cache_manager/issues/400)). diff --git a/flutter_cache_manager/lib/src/storage/cache_info_repositories/json_cache_info_repository.dart b/flutter_cache_manager/lib/src/storage/cache_info_repositories/json_cache_info_repository.dart index 5d24b24a..3ad209fb 100644 --- a/flutter_cache_manager/lib/src/storage/cache_info_repositories/json_cache_info_repository.dart +++ b/flutter_cache_manager/lib/src/storage/cache_info_repositories/json_cache_info_repository.dart @@ -233,7 +233,40 @@ class JsonCacheInfoRepository extends CacheInfoRepository final content = jsonEncode(_jsonCache.values.toList()); final tempFile = _createSiblingFile('${file.path}.tmp'); await tempFile.writeAsString(content, flush: true); - await tempFile.rename(file.path); + await _renameOver(tempFile, file.path); + } + + /// The pauses between attempts of [_renameOver], about 750ms in all. + static const _renameRetryDelays = [ + Duration(milliseconds: 50), + Duration(milliseconds: 100), + Duration(milliseconds: 200), + Duration(milliseconds: 400), + ]; + + /// Renames [tempFile] to [path], retrying briefly while another process has + /// one of the two files open. + /// + /// Windows refuses to rename over a file another process holds open without + /// delete sharing, failing with ERROR_SHARING_VIOLATION (32) or + /// ERROR_ACCESS_DENIED (5). Antivirus and the search indexer routinely open a + /// file just after it is written, so the freshly written temp file is often + /// held for a moment. Elsewhere a rename never fails with 32 (EPIPE), and 5 + /// (EIO) is worth the same few retries. + Future _renameOver(File tempFile, String path) async { + for (var attempt = 0; ; attempt++) { + try { + await tempFile.rename(path); + return; + } on FileSystemException catch (e) { + final errorCode = e.osError?.errorCode; + if (attempt == _renameRetryDelays.length || + (errorCode != 32 && errorCode != 5)) { + rethrow; + } + await Future.delayed(_renameRetryDelays[attempt]); + } + } } /// Creates a file next to [_file] on the same file system. diff --git a/flutter_cache_manager/pubspec.yaml b/flutter_cache_manager/pubspec.yaml index 3f0c9ce3..8456c4b5 100644 --- a/flutter_cache_manager/pubspec.yaml +++ b/flutter_cache_manager/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_cache_manager description: Generic cache manager for flutter. Saves web files on the storages of the device and saves the cache info using sqflite. -version: 3.4.5 +version: 3.4.6 homepage: https://github.com/Baseflow/flutter_cache_manager topics: - cache diff --git a/flutter_cache_manager/test/repositories/json_file_repository_test.dart b/flutter_cache_manager/test/repositories/json_file_repository_test.dart index 1daf59b6..42c9ace5 100644 --- a/flutter_cache_manager/test/repositories/json_file_repository_test.dart +++ b/flutter_cache_manager/test/repositories/json_file_repository_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:collection/collection.dart'; +import 'package:file/file.dart' as pf; import 'package:flutter/foundation.dart'; import 'package:flutter_cache_manager/src/storage/cache_info_repositories/json_cache_info_repository.dart'; import 'package:flutter_cache_manager/src/storage/cache_object.dart'; @@ -282,7 +283,129 @@ void main() { final allObjects = await repo2.getAllObjects(); expect(allObjects.length, JsonRepoHelpers.startCacheObjects.length + 1); }); + + group('A rename refused by another process', () { + Future<(JsonCacheInfoRepository, pf.File, _FlakyRenameFileSystem)> + createRepository({required int failures, required int errorCode}) async { + final original = await JsonRepoHelpers.createDatabaseFile(); + final fileSystem = _FlakyRenameFileSystem( + original.fileSystem, + failures: failures, + errorCode: errorCode, + ); + final file = fileSystem.file(original.path); + final repo = JsonCacheInfoRepository.withFile(file); + await repo.open(); + return (repo, file, fileSystem); + } + + Future> insertCollectingErrors( + JsonCacheInfoRepository repo, + ) async { + final errors = []; + final originalOnError = FlutterError.onError; + FlutterError.onError = errors.add; + try { + await repo.insert(JsonRepoHelpers.extraCacheObject); + } finally { + FlutterError.onError = originalOnError; + } + return errors; + } + + test('is retried until it succeeds', () async { + final (repo, file, fileSystem) = await createRepository( + failures: 2, + errorCode: 32, + ); + + final errors = await insertCollectingErrors(repo); + + expect(errors, isEmpty); + expect(fileSystem.renameAttempts, 3); + final repo2 = JsonCacheInfoRepository.withFile(file); + await repo2.open(); + final allObjects = await repo2.getAllObjects(); + expect(allObjects.length, JsonRepoHelpers.startCacheObjects.length + 1); + }); + + test('is reported once the retries run out', () async { + final (repo, _, fileSystem) = await createRepository( + failures: 100, + errorCode: 5, + ); + + final errors = await insertCollectingErrors(repo); + + expect(errors, hasLength(1)); + expect(fileSystem.renameAttempts, 5); + }); + + test('is not retried for other errors', () async { + final (repo, _, fileSystem) = await createRepository( + failures: 1, + errorCode: 2, + ); + + final errors = await insertCollectingErrors(repo); + + expect(errors, hasLength(1)); + expect(fileSystem.renameAttempts, 1); + }); + }); + }); +} + +/// A file system whose renames fail [failures] times, with [errorCode], before +/// working again. +class _FlakyRenameFileSystem extends pf.ForwardingFileSystem { + _FlakyRenameFileSystem( + super.delegate, { + required this.failures, + required this.errorCode, }); + + int failures; + final int errorCode; + int renameAttempts = 0; + + @override + pf.File file(dynamic path) => _FlakyRenameFile(this, delegate.file(path)); +} + +class _FlakyRenameFile extends pf.ForwardingFileSystemEntity + with pf.ForwardingFile { + _FlakyRenameFile(this.fileSystem, this.delegate); + + @override + final _FlakyRenameFileSystem fileSystem; + + @override + final pf.File delegate; + + @override + Future rename(String newPath) async { + fileSystem.renameAttempts++; + if (fileSystem.failures > 0) { + fileSystem.failures--; + throw FileSystemException( + 'Cannot rename file to \'$newPath\'', + path, + OSError('Refused by the test file system', fileSystem.errorCode), + ); + } + return wrap(await delegate.rename(newPath)); + } + + @override + pf.File wrapFile(File delegate) => + _FlakyRenameFile(fileSystem, delegate as pf.File); + + @override + pf.Directory wrapDirectory(Directory delegate) => delegate as pf.Directory; + + @override + pf.Link wrapLink(Link delegate) => delegate as pf.Link; } void expectIdInList(List cacheObjects, int id) {