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) {