diff --git a/CHANGELOG.md b/CHANGELOG.md index 914b6a5..3051048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +### Fixes + +* Fixed a partial cache file deleted mid-download (by the device's cache management, say) failing every request for what it held until the download ended: the download wrote on into the deleted file. A request now resets the cache, and waits for its range to download again. + ## 0.3.1 ### Fixes diff --git a/lib/src/cache_stream/http_cache_stream.dart b/lib/src/cache_stream/http_cache_stream.dart index 02ce5fc..36f4d8a 100644 --- a/lib/src/cache_stream/http_cache_stream.dart +++ b/lib/src/cache_stream/http_cache_stream.dart @@ -118,6 +118,20 @@ class HttpCacheStream { } } + //Deleted while it downloads (by the device's cache management, say), the + //partial cache file takes the download's writes into a file no response can + //open, until the download ends. Reset, the download starts again, and the + //request waits for its range. + final activeDownloader = _cacheDownloader; + final cacheDeleted = activeDownloader != null && + !activeDownloader.isClosed && + activeDownloader.filePosition > 0 && + !await files.activeCacheFile().exists(); + if (cacheDeleted) { + await _resetCache(InvalidCacheSizeException( + sourceUrl, -1, responseHeaders?.sourceLength ?? 0)); + } + final rangeThreshold = config.rangeRequestSplitThreshold; if (rangeThreshold != null && range.start >= rangeThreshold && @@ -132,7 +146,9 @@ class HttpCacheStream { final streamRequest = StreamRequest.construct(range); final downloader = _cacheDownloader; - if (downloader != null && downloader.processRequest(streamRequest)) { + if (!cacheDeleted && + downloader != null && + downloader.processRequest(streamRequest)) { return streamRequest.response; //Request was processed immediately } else { _queuedRequests diff --git a/test/e2e/partial_deleted_test.dart b/test/e2e/partial_deleted_test.dart new file mode 100644 index 0000000..d5b9c74 --- /dev/null +++ b/test/e2e/partial_deleted_test.dart @@ -0,0 +1,70 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http_cache_stream/http_cache_stream.dart'; +import 'package:http_cache_stream/src/etc/extensions/file_extensions.dart'; + +import '../support/harness.dart'; +import '../support/payload.dart'; + +/// The partial cache file deleted while it downloads (by the device's cache +/// management, say): the download writes on into the deleted file, where no +/// response can open what it holds. A request resets the cache, and its range +/// downloads again. +void main() { + late CacheTestHarness h; + + setUp(() async { + h = CacheTestHarness(); + await h.setUp(); + }); + + tearDown(() => h.tearDown()); + + Future read(HttpCacheStream stream, int start, int end) async { + final response = await stream.request(start: start, end: end); + final builder = BytesBuilder(copy: false); + await for (final chunk in response.stream) { + builder.add(chunk); + } + return builder.takeBytes(); + } + + test('a range it had downloaded is read again after the file was deleted', + () async { + final half = h.origin.payload.length ~/ 2; + final bodyGate = Completer(); + h.origin + ..responseBodyGate = bodyGate + ..responseBodyGateAfterBytes = half; + final stream = h.manager.createStream(h.origin.url('/deleted.mp3')); + final reachedHalf = Completer(); + final errors = []; + final subscription = stream.cacheStateStream.listen((state) { + if (state.position >= half && !reachedHalf.isCompleted) { + reachedHalf.complete(); + } + }, onError: errors.add); + final download = stream.download(); + await reachedHalf.future.timeout(const Duration(seconds: 5)); + final partial = stream.files.partial; + for (var i = 0; i < 100 && (partial.lengthSyncOrNull() ?? 0) < half; i++) { + await Future.delayed(const Duration(milliseconds: 10)); + } + await partial.delete(); + + final bytes = + await read(stream, 1000, 9000).timeout(const Duration(seconds: 10)); + expect(bytes, Uint8List.sublistView(h.origin.payload, 1000, 9000)); + expect(errors.whereType().map((e) => e.size), + contains(-1), + reason: 'the deletion is reported'); + + bodyGate.complete(); + final file = await download.timeout(const Duration(seconds: 15)); + expect(Payload.hash(await file.readAsBytes()), h.payloadHash); + await subscription.cancel(); + await stream.dispose(force: true); + }); +}