Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
## Unreleased
## 0.3.2

### New Features

* Added `CacheState.filePosition`, the bytes saved in the cache file. While a download runs it can be behind `position`, which counts the bytes downloaded. `CacheState.incomplete` takes it as an optional `filePosition`, which defaults to `position`, and incomplete states compare by it too.

### 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.
* Every request now revalidates the cache files against the running download, and resets a cache they no longer hold (deleted or changed underneath, by the device's cache management, say). The request waits for its range to download again.
* Fixed a partial cache file deleted mid-download failing every request for what it held until the download ended: the download wrote on into the deleted file.
* Fixed a resumed download saving its bytes at the wrong offset when its partial cache file was deleted, truncated or written to before the download first wrote to it, which could leave a complete cache file of the right size with the wrong content. The download now starts over.
* Fixed `resetCache()`, and the reset of an invalid cache, being ignored while the download waited for a response or to retry: it resumed the old partial cache.
* Fixed `refreshCacheState()` during a download of unknown length (a chunked response) dropping the position to 0. While a download runs, the state takes its position.

## 0.3.1

Expand Down
31 changes: 30 additions & 1 deletion lib/src/cache_stream/cache_downloader/buffered_io_sink.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ import '../response_streams/partial_cache_feed.dart';

part 'buffered_io_sink_feed.dart';

/// Thrown when the file a [BufferedIOSink] resumes does not end where the sink resumes it: it was deleted,
/// truncated or written to since the sink's earlier bytes were saved.
class SinkFileLengthException implements Exception {
final String path;

/// The length of the file.
final int length;

/// Where the sink resumes the file.
final int expected;

const SinkFileLengthException(this.path, this.length, this.expected);

@override
String toString() =>
'SinkFileLengthException: $path is $length bytes long, but the sink resumes it at $expected';
}

/// An IO sink that supports adding data while flushing to disk asynchronously.
class BufferedIOSink {
//Maximum number of bytes to write in a single write operation. This prevents long writes from stalling position waiters.
Expand Down Expand Up @@ -50,7 +68,18 @@ class BufferedIOSink {
fileMode = FileMode.write; //Overwrite existing
}

raf = _openedRAF = await file.open(mode: fileMode);
raf = await file.open(mode: fileMode);
if (fileMode == FileMode.append) {
//Appended at its end, the file must end where the sink resumes it.
//Deleted since (append creates it again), truncated or written to,
//it would take the sink's bytes at the wrong offset.
final length = await raf.length();
if (length != flushedBytes) {
await raf.close();
throw SinkFileLengthException(file.path, length, flushedBytes);
}
}
_openedRAF = raf;
}

while (_buffer.isNotEmpty) {
Expand Down
63 changes: 54 additions & 9 deletions lib/src/cache_stream/cache_downloader/cache_downloader.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ class CacheDownloader {

///Headers received and validated
CachedResponseHeaders? _validatedHeaders;

///The invalid cache the download stopped for, if any: [cancel]led for it, or found by a save. The download
///ends on it, and serves no more requests.
InvalidCacheException? _invalidCache;

///If the download may still write to the partial cache file, until its sink is closed.
bool _writing = true;
CacheDownloader._(
final CacheMetadata cacheMetadata,
final int startPosition,
Expand Down Expand Up @@ -98,18 +105,15 @@ class CacheDownloader {
(_) {
_downloader.resume();
},
onError: (e) {
cancel(e);
},
onError: _cancelOnFlushError,
);
} else if (!_sink.isFlushing) {
_sink.flush().catchError((e) {
cancel(e);
});
_sink.flush().catchError(_cancelOnFlushError);
}
},
);
} on InvalidCacheException {
} on InvalidCacheException catch (e) {
_invalidCache ??= e;
_validatedHeaders = null;
rethrow;
} catch (e) {
Expand All @@ -119,12 +123,28 @@ class CacheDownloader {
// Post-download — flush remaining data and verify cache integrity
try {
await _sink.close(
flushBuffer: true,
flushBuffer: !isInvalidated, //An invalid cache keeps no more bytes
isDone: _downloader
.isDone, //If the source did not end, the feed is marked as aborted so readers do not treat it as an end of stream
); //Flushes all buffered data and closes the sink
} catch (e) {
onError(e);
final error = _saveError(e);
if (error is InvalidCacheException) {
_invalidCache ??= error;
} else {
onError(error);
}
} finally {
_writing = false;
}

//Cancelled for an invalid cache between responses (waiting for one, or
//to retry) or while saving its last bytes, or found invalid by a save that
//failed after the response ended: the download returned as if stopped,
//and ends on it, as when a response is cancelled for it.
if (_invalidCache case final InvalidCacheException invalidCache) {
_validatedHeaders = null;
throw invalidCache;
}

final sourceLength = _validatedHeaders?.sourceLength ??
Expand All @@ -137,14 +157,31 @@ class CacheDownloader {
///The sink is not closed on invalid cache exception, so we need to close it here
await _sink.close(flushBuffer: false).ignoreResult();
}
_writing = false;
if (!_completer.isCompleted) {
_completer.complete();
}
}
}

///Cancels the download on a failed save.
void _cancelOnFlushError(final Object e) {
//A block body: a flush future's error handler must return null.
cancel(_saveError(e));
}

///A partial cache file that does not end where the download resumes it (deleted, truncated or written
///to since) is an invalid cache: the download starts over.
Object _saveError(final Object e) => e is SinkFileLengthException
? InvalidCacheSizeException(sourceUrl, e.length, e.expected)
: e;

/// Cancels the download and closes the stream. An optional [error] can be provided to indicate the reason for cancellation.
/// An [InvalidCacheException] ends [download] on it, and the download serves no more requests.
Future<void> cancel([Object? exception]) async {
if (exception is InvalidCacheException) {
_invalidCache ??= exception;
}
_downloader.close(exception);
_paused = false;
return _completer.future;
Expand All @@ -164,6 +201,7 @@ class CacheDownloader {

bool processRequest(final StreamRequest request) {
assert(!_paused);
if (isInvalidated) return false;
if (request.start > downloadPosition) return false;
final headers = _validatedHeaders;
if (headers == null) return false;
Expand Down Expand Up @@ -191,7 +229,14 @@ class CacheDownloader {
_validatedHeaders?.sourceLength ?? _resumeHeaders?.sourceLength;
int get downloadPosition => _position;
int get filePosition => _sink.flushedBytes;

///If the download may still write to the partial cache file: until its sink is closed, and the file
///released.
bool get isWriting => _writing;
Uri get sourceUrl => _downloader.sourceUrl;
bool get isClosed => _completer.isCompleted;

///If the download stopped for an invalid cache: [cancel]led for it, or found by a save.
bool get isInvalidated => _invalidCache != null;
bool get isPaused => _paused;
}
Loading
Loading