Skip to content

Commit 0c3e5a0

Browse files
author
Jacek Nowosielski
committed
fix: prevent cache corruption from incomplete files and handle DB async errors
- Atomic file writes: Modified putFile, putFileStream (in cache_manager.dart) and _saveFileAndPostUpdates (in web_helper.dart) to write data into a .tmp file first. The file is only atomically renamed to its final path upon successful completion. This prevents partially written or 0-byte files from lingering if the app is killed, crashes, or runs out of disk space during a write. - Zero-byte cache protection: Updated _manageResponse (in web_helper.dart) to detect successful downloads that yield 0 bytes (e.g. backend error returning empty body with 200 OK) and delete the file without registering it in the SQLite cache DB. - FileSystemIO directory optimization: Fixed createFile in ile_system_io.dart to reuse the existing Directory reference instead of creating a new Directory object which was immediately discarded. - Integrated PR Baseflow#499: Added a catchError block to _getCacheDataFromDatabase(key).then(...) in cache_store.dart. This ensures the Completer resolves properly (with an error) and cleans up _futureCache, preventing a memory leak and a hung Future when DB or FileSystem exceptions occur during data retrieval. - Integrated PR Baseflow#487: Added an empty file check (length > 0) in json_cache_info_repository.dart before decoding JSON to prevent FormatException crashes when the JSON metadata file is empty (e.g. after a disk full interruption). - Added comprehensive tests: Covered atomic file operation errors and 0-byte download handling in cache_manager_test.dart and web_helper_test.dart.
1 parent 54904e4 commit 0c3e5a0

7 files changed

Lines changed: 270 additions & 277 deletions

File tree

flutter_cache_manager/lib/src/cache_manager.dart

Lines changed: 30 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,7 @@ class CacheManager implements BaseCacheManager {
8787
/// cached file is too old the newly downloaded file is returned afterwards.
8888
@override
8989
@Deprecated('Prefer to use the new getFileStream method')
90-
Stream<FileInfo> getFile(String url,
91-
{String? key, Map<String, String>? headers}) {
90+
Stream<FileInfo> getFile(String url, {String? key, Map<String, String>? headers}) {
9291
return getFileStream(
9392
url,
9493
key: key,
@@ -108,8 +107,7 @@ class CacheManager implements BaseCacheManager {
108107
/// returned from the cache there will be no progress given, although the file
109108
/// might be outdated and a new file is being downloaded in the background.
110109
@override
111-
Stream<FileResponse> getFileStream(String url,
112-
{String? key, Map<String, String>? headers, bool withProgress = false}) {
110+
Stream<FileResponse> getFileStream(String url, {String? key, Map<String, String>? headers, bool withProgress = false}) {
113111
key ??= url;
114112
final streamController = StreamController<FileResponse>();
115113
_pushFileToStream(streamController, url, key, headers, withProgress);
@@ -132,14 +130,11 @@ class CacheManager implements BaseCacheManager {
132130
withProgress = false;
133131
}
134132
} on Object catch (e) {
135-
cacheLogger.log(
136-
'CacheManager: Failed to load cached file for $url with error:\n$e',
137-
CacheManagerLogLevel.debug);
133+
cacheLogger.log('CacheManager: Failed to load cached file for $url with error:\n$e', CacheManagerLogLevel.debug);
138134
}
139135
if (cacheFile == null || cacheFile.validTill.isBefore(DateTime.now())) {
140136
try {
141-
await for (final response
142-
in _webHelper.downloadFile(url, key: key, authHeaders: headers)) {
137+
await for (final response in _webHelper.downloadFile(url, key: key, authHeaders: headers)) {
143138
if (response is DownloadProgress && withProgress) {
144139
streamController.add(response);
145140
}
@@ -148,16 +143,12 @@ class CacheManager implements BaseCacheManager {
148143
}
149144
}
150145
} on Object catch (e) {
151-
cacheLogger.log(
152-
'CacheManager: Failed to download file from $url with error:\n$e',
153-
CacheManagerLogLevel.debug);
146+
cacheLogger.log('CacheManager: Failed to download file from $url with error:\n$e', CacheManagerLogLevel.debug);
154147
if (cacheFile == null && streamController.hasListener) {
155148
streamController.addError(e);
156149
}
157150

158-
if (cacheFile != null &&
159-
e is HttpExceptionWithStatus &&
160-
e.statusCode == 404) {
151+
if (cacheFile != null && e is HttpExceptionWithStatus && e.statusCode == 404) {
161152
if (streamController.hasListener) {
162153
streamController.addError(e);
163154
}
@@ -170,10 +161,7 @@ class CacheManager implements BaseCacheManager {
170161

171162
///Download the file and add to cache
172163
@override
173-
Future<FileInfo> downloadFile(String url,
174-
{String? key,
175-
Map<String, String>? authHeaders,
176-
bool force = false}) async {
164+
Future<FileInfo> downloadFile(String url, {String? key, Map<String, String>? authHeaders, bool force = false}) async {
177165
key ??= url;
178166
final fileResponse = await _webHelper
179167
.downloadFile(
@@ -189,14 +177,11 @@ class CacheManager implements BaseCacheManager {
189177
/// Get the file from the cache.
190178
/// Specify [ignoreMemCache] to force a re-read from the database
191179
@override
192-
Future<FileInfo?> getFileFromCache(String key,
193-
{bool ignoreMemCache = false}) =>
194-
_store.getFile(key, ignoreMemCache: ignoreMemCache);
180+
Future<FileInfo?> getFileFromCache(String key, {bool ignoreMemCache = false}) => _store.getFile(key, ignoreMemCache: ignoreMemCache);
195181

196182
///Returns the file from memory if it has already been fetched
197183
@override
198-
Future<FileInfo?> getFileFromMemory(String key) =>
199-
_store.getFileFromMemory(key);
184+
Future<FileInfo?> getFileFromMemory(String key) => _store.getFileFromMemory(key);
200185

201186
/// Put a file in the cache. It is recommended to specify the [eTag] and the
202187
/// [maxAge]. When [maxAge] is passed and the eTag is not set the file will
@@ -228,7 +213,16 @@ class CacheManager implements BaseCacheManager {
228213
);
229214

230215
final file = await _config.fileSystem.createFile(cacheObject.relativePath);
231-
await file.writeAsBytes(fileBytes);
216+
final tempFile = file.parent.childFile('${file.basename}.tmp');
217+
try {
218+
await tempFile.writeAsBytes(fileBytes);
219+
await tempFile.rename(file.path);
220+
} on Object {
221+
try {
222+
if (await tempFile.exists()) await tempFile.delete();
223+
} catch (_) {}
224+
rethrow;
225+
}
232226
_store.putFile(cacheObject);
233227
return file;
234228
}
@@ -263,13 +257,17 @@ class CacheManager implements BaseCacheManager {
263257
);
264258

265259
final file = await _config.fileSystem.createFile(cacheObject.relativePath);
266-
267-
// Always copy file
268-
final sink = file.openWrite();
269-
await source
270-
// this map is need to map UInt8List to List<int>
271-
.map((event) => event)
272-
.pipe(sink);
260+
final tempFile = file.parent.childFile('${file.basename}.tmp');
261+
try {
262+
final sink = tempFile.openWrite();
263+
await source.map((event) => event).pipe(sink);
264+
await tempFile.rename(file.path);
265+
} on Object {
266+
try {
267+
if (await tempFile.exists()) await tempFile.delete();
268+
} catch (_) {}
269+
rethrow;
270+
}
273271

274272
_store.putFile(cacheObject);
275273
return file;

flutter_cache_manager/lib/src/cache_store.dart

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,12 @@ class CacheStore {
3333
_cacheInfoRepository = config.repo.open().then((value) => config.repo);
3434

3535
Future<FileInfo?> getFile(String key, {bool ignoreMemCache = false}) async {
36-
final cacheObject =
37-
await retrieveCacheData(key, ignoreMemCache: ignoreMemCache);
36+
final cacheObject = await retrieveCacheData(key, ignoreMemCache: ignoreMemCache);
3837
if (cacheObject == null) {
3938
return null;
4039
}
4140
final file = await fileSystem.createFile(cacheObject.relativePath);
42-
cacheLogger.log(
43-
'CacheManager: Loaded $key from cache', CacheManagerLogLevel.verbose);
41+
cacheLogger.log('CacheManager: Loaded $key from cache', CacheManagerLogLevel.verbose);
4442

4543
return FileInfo(
4644
file,
@@ -60,8 +58,7 @@ class CacheStore {
6058
}
6159
}
6260

63-
Future<CacheObject?> retrieveCacheData(String key,
64-
{bool ignoreMemCache = false}) async {
61+
Future<CacheObject?> retrieveCacheData(String key, {bool ignoreMemCache = false}) async {
6562
if (!ignoreMemCache && _memCache.containsKey(key)) {
6663
if (await _fileExists(_memCache[key])) {
6764
return _memCache[key];
@@ -83,6 +80,9 @@ class CacheStore {
8380
}
8481
completer.complete(cacheObject);
8582
_futureCache.remove(key);
83+
}).catchError((Object err) {
84+
completer.completeError(err);
85+
_futureCache.remove(key);
8686
});
8787
_futureCache[key] = completer.future;
8888
}
@@ -95,16 +95,23 @@ class CacheStore {
9595
return null;
9696
}
9797
final file = await fileSystem.createFile(cacheObject.relativePath);
98-
return FileInfo(
99-
file, FileSource.Cache, cacheObject.validTill, cacheObject.url);
98+
return FileInfo(file, FileSource.Cache, cacheObject.validTill, cacheObject.url);
10099
}
101100

102101
Future<bool> _fileExists(CacheObject? cacheObject) async {
103102
if (cacheObject == null) {
104103
return false;
105104
}
106-
final file = await fileSystem.createFile(cacheObject.relativePath);
107-
return file.exists();
105+
try {
106+
final file = await fileSystem.createFile(cacheObject.relativePath);
107+
if (!await file.exists()) return false;
108+
// Treat empty files (e.g. from interrupted writes) as missing so
109+
// they get re-downloaded instead of failing at decode time.
110+
final length = await file.length();
111+
return length > 0;
112+
} on FileSystemException {
113+
return false;
114+
}
108115
}
109116

110117
Future<CacheObject?> _getCacheDataFromDatabase(String key) async {
@@ -172,8 +179,7 @@ class CacheStore {
172179
await provider.deleteAll(toRemove);
173180
}
174181

175-
Future<void> _removeCachedFile(
176-
CacheObject cacheObject, List<int> toRemove) async {
182+
Future<void> _removeCachedFile(CacheObject cacheObject, List<int> toRemove) async {
177183
if (toRemove.contains(cacheObject.id)) return;
178184

179185
toRemove.add(cacheObject.id!);

flutter_cache_manager/lib/src/storage/cache_info_repositories/json_cache_info_repository.dart

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ class JsonCacheInfoRepository extends CacheInfoRepository
2020
/// Either the path or the database name should be provided.
2121
/// If the path is provider it should end with '{databaseName}.json',
2222
/// for example: /data/user/0/com.example.example/databases/imageCache.json
23-
JsonCacheInfoRepository({this.path, this.databaseName})
24-
: assert(path == null || databaseName == null);
23+
JsonCacheInfoRepository({this.path, this.databaseName}) : assert(path == null || databaseName == null);
2524

2625
/// The directory and the databaseName should both the provided. The database
2726
/// is stored as {databaseName}.json in the directory,
@@ -137,7 +136,7 @@ class JsonCacheInfoRepository extends CacheInfoRepository
137136
Future<void> _readFile(File file) async {
138137
_cacheObjects.clear();
139138
_jsonCache.clear();
140-
if (await file.exists()) {
139+
if (await file.exists() && await file.length() > 0) {
141140
try {
142141
final jsonString = await file.readAsString();
143142
final json = jsonDecode(jsonString) as List<dynamic>;

flutter_cache_manager/lib/src/storage/file_system/file_system_io.dart

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,8 @@ import 'package:path_provider/path_provider.dart';
66

77
class IOFileSystem implements FileSystem {
88
final Future<Directory> _fileDir;
9-
final String _cacheKey;
109

11-
IOFileSystem(this._cacheKey) : _fileDir = createDirectory(_cacheKey);
10+
IOFileSystem(String cacheKey) : _fileDir = createDirectory(cacheKey);
1211

1312
static Future<Directory> createDirectory(String key) async {
1413
final baseDir = await getTemporaryDirectory();
@@ -24,7 +23,7 @@ class IOFileSystem implements FileSystem {
2423
Future<File> createFile(String name) async {
2524
final directory = await _fileDir;
2625
if (!(await directory.exists())) {
27-
await createDirectory(_cacheKey);
26+
await directory.create(recursive: true);
2827
}
2928
return directory.childFile(name);
3029
}

0 commit comments

Comments
 (0)