diff --git a/CHANGELOG.md b/CHANGELOG.md index f43a8e5..914b6a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,10 @@ -## 0.3.0 (unreleased) +## 0.3.1 + +### Fixes + +* Fixed a download whose write to the cache file failed (a full disk, say) requesting the source again at once, again and again, reporting an error each time. The next attempt now waits 5 s, doubling to a minute while attempts keep failing. + +## 0.3.0 ### New Features diff --git a/lib/src/cache_stream/http_cache_stream.dart b/lib/src/cache_stream/http_cache_stream.dart index 66fb263..02ce5fc 100644 --- a/lib/src/cache_stream/http_cache_stream.dart +++ b/lib/src/cache_stream/http_cache_stream.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math' show min; import 'package:http_cache_stream/src/cache_stream/cache_downloader/cache_downloader.dart'; import 'package:http_cache_stream/src/etc/extensions/future_extensions.dart'; @@ -206,6 +207,8 @@ class HttpCacheStream { await _ensureInit(); _checkDisposed(); + ///Attempts in a row that ended on an error, see [_retryDelay]. + int failedAttempts = 0; while (true) { final state = await refreshCacheState(); if (state.isComplete) { @@ -223,11 +226,14 @@ class HttpCacheStream { continue; } + ///The error this attempt ended on: one reported after the download last moved on. + Object? endError; try { final downloader = _cacheDownloader = CacheDownloader.construct(metadata, config); await downloader.download( onPosition: (position) { + endError = null; //It moved on, past any error before _updateCacheState( CacheState.incomplete(position, downloader.sourceLength)); while (_queuedRequests.isNotEmpty && @@ -251,6 +257,7 @@ class HttpCacheStream { }, onError: (e) { assert(e is! InvalidCacheException); + endError = e; _addError(e, closeRequests: true); }, ); @@ -263,13 +270,28 @@ class HttpCacheStream { } if (!isRetained) rethrow; await Future.delayed(const Duration(seconds: 5)); + continue; } finally { _cacheDownloader = null; } + + if (endError == null) { + failedAttempts = 0; + } else if (isRetained) { + await Future.delayed(_retryDelay(++failedAttempts)); + } } }); } + ///How long [download] waits after its [attempts]th attempt in a row that ended on an error: 5 s, + ///doubling to a minute. The downloader retries what the network does itself, after 5 s; an + ///attempt ends on an error when it gives up, as a write to the cache file that fails (a full + ///disk) gives it up, and without the wait the next attempt would request the source again at + ///once, and fail again. + static Duration _retryDelay(final int attempts) => + Duration(seconds: min(5 << min(attempts - 1, 4), 60)); + Future downloadHeaders({bool save = true}) { return _downloadHeadersFuture.run(() async { final latestHeaders = await CachedResponseHeaders.fromUrl( diff --git a/pubspec.yaml b/pubspec.yaml index 44a713c..f881abe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: http_cache_stream description: "Simultaneously download, cache, and stream remote content. Perfect for media players and any plugin that streams web content." -version: 0.3.0 +version: 0.3.1 homepage: https://github.com/Colton127/http_cache_stream repository: https://github.com/Colton127/http_cache_stream topics: diff --git a/test/e2e/write_failure_test.dart b/test/e2e/write_failure_test.dart new file mode 100644 index 0000000..c6a44a6 --- /dev/null +++ b/test/e2e/write_failure_test.dart @@ -0,0 +1,58 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +import '../support/harness.dart'; + +/// A download whose writes to the cache file fail (a full disk, here a +/// directory where the file goes) gives up its attempt. The next one waits, +/// rather than requesting the source again at once, again and again. +void main() { + late CacheTestHarness h; + + setUp(() async { + h = CacheTestHarness(); + await h.setUp(); + }); + + tearDown(() => h.tearDown()); + + test( + 'an attempt whose cache writes fail is retried after a wait, not at once', + () async { + final stream = h.manager.createStream(h.origin.url('/a.mp3')); + await Directory(stream.files.partial.path).create(recursive: true); + final errors = []; + final subscription = + stream.cacheStateStream.listen(null, onError: errors.add); + + stream.download().ignore(); + await Future.delayed(const Duration(seconds: 3)); + + expect(errors, isNotEmpty, reason: 'the failed write is reported'); + expect(h.origin.requestCount, 1, reason: 'the next attempt waits 5 s'); + await subscription.cancel(); + await stream.dispose(force: true); + }); + + test('the attempt after the wait downloads once the cache can be written', + () async { + final stream = h.manager.createStream(h.origin.url('/a.mp3')); + final blocker = Directory(stream.files.partial.path); + await blocker.create(recursive: true); + final failed = Completer(); + final subscription = stream.cacheStateStream.listen(null, onError: (_) { + if (!failed.isCompleted) failed.complete(); + }); + final download = stream.download(); + await failed.future.timeout(const Duration(seconds: 5)); + await blocker.delete(); + + final file = await download.timeout(const Duration(seconds: 15)); + expect(await file.length(), h.origin.payload.length); + expect(h.origin.requestCount, 2); + await subscription.cancel(); + await stream.dispose(force: true); + }); +}