Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c864b90
init
Colton127 Aug 3, 2026
2339130
enforce max write size to avoid stalled waiters
Colton127 Aug 4, 2026
8c0d485
retry on filesystem exception
Colton127 Aug 4, 2026
02ab593
update partial cache stream
Colton127 Aug 4, 2026
10d898b
minor rev
Colton127 Aug 5, 2026
99eaa57
attempt rename during partial cache file lock
Colton127 Aug 5, 2026
7ce24de
handle EOF
Colton127 Aug 5, 2026
46670c0
buffered io tests
Colton127 Aug 5, 2026
821a12d
CacheState handling
Colton127 Aug 6, 2026
ea49ff5
commentary
Colton127 Aug 6, 2026
079e18b
remove partial cache fix
Colton127 Aug 6, 2026
1302d8a
fix EOF exception of unknown request ends
Colton127 Aug 6, 2026
1c2cb0e
clamp request ends
Colton127 Aug 6, 2026
9bf0ee4
verify sream identify
Colton127 Aug 6, 2026
132136b
minor
Colton127 Aug 7, 2026
cfa77ab
cancel range download on error
Colton127 Aug 7, 2026
197f92c
reset headers on invalid cache exc
Colton127 Aug 7, 2026
3fecaba
clean-up stream response constructors
Colton127 Aug 7, 2026
da39eb0
rev
Colton127 Aug 7, 2026
26a0675
minor
Colton127 Aug 7, 2026
8edd5e3
continue on InvalidCacheException
Colton127 Aug 7, 2026
8652733
fix same-host requests; add regression tests
Colton127 Aug 7, 2026
77ade87
restart download in next event loop
Colton127 Aug 7, 2026
ac84a3c
add resuming partial download tests
Colton127 Aug 7, 2026
3bf2924
report known source length in cache state
Colton127 Aug 7, 2026
d03e7b9
fix: allow 127.0.0.1 source uris
Colton127 Aug 7, 2026
f6ca76e
validate partial cache
Colton127 Aug 8, 2026
22d75e0
sourceLength
Colton127 Aug 8, 2026
1417d2f
CompletedPartialCacheFeed
Colton127 Aug 8, 2026
54a8c72
PartialCacheFileStream perf
Colton127 Aug 8, 2026
6bf1ace
changelog and formatting
Colton127 Aug 8, 2026
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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
## 0.2.0

This release is designed to preserve existing behavior while making caching and streaming faster and more robust.

### Improvements

* Improved streaming performance and efficiency when serving cached content.
* Improved reliability when resuming partially downloaded files.
* Improved handling of interrupted downloads, cache validation, and source changes.
* Improved range request handling and cache integrity checks.
* Improved cache lifecycle and cleanup behavior across platforms.

### Fixes

* Fixed edge cases that could cause incomplete or outdated cached data to be served.
* Fixed several issues involving partial downloads, interrupted connections, and cache file transitions.
* Fixed assorted range request and local cache server edge cases.

## 0.1.0

This release significantly simplifies cache management. A new `getCacheUrl` API automates the full lifecycle of cache streams, eliminating the need to create or manage `HttpCacheStream` instances for most integrations.
Expand Down
6 changes: 1 addition & 5 deletions benchmarker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,4 @@ flutter test

The suite covers request distribution and statistics, drives real worker
isolates against a local origin server, and runs all three benchmark types
end-to-end through a real `HttpCacheManager`.

> When testing against a **loopback** origin, address it as `localhost` rather
> than `127.0.0.1`: `http_cache_stream` rejects source URLs whose host matches
> the cache server's own host.
end-to-end through a real `HttpCacheManager`.
8 changes: 5 additions & 3 deletions benchmarker/lib/src/benchmark/benchmark_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,7 @@ class RangePlan {
);

@override
String toString() =>
'RangePlan(bytes $start-$end, window $windowSize, '
String toString() => 'RangePlan(bytes $start-$end, window $windowSize, '
'sequential: $isSequential)';
}

Expand Down Expand Up @@ -248,7 +247,10 @@ class BenchmarkConfig {
required int? totalRequests,
}) {
final uri = Uri.tryParse(url.trim());
if (url.trim().isEmpty || uri == null || !uri.hasScheme || uri.host.isEmpty) {
if (url.trim().isEmpty ||
uri == null ||
!uri.hasScheme ||
uri.host.isEmpty) {
return 'Enter a valid absolute source URL.';
}
if (uri.scheme != 'http' && uri.scheme != 'https') {
Expand Down
3 changes: 2 additions & 1 deletion benchmarker/lib/src/benchmark/source_probe.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ Future<SourceInfo> probeSource(
try {
try {
final response = await httpClient.head(url).timeout(timeout);
if (_isSuccess(response.statusCode) && (response.contentLength ?? 0) > 0) {
if (_isSuccess(response.statusCode) &&
(response.contentLength ?? 0) > 0) {
return SourceInfo(
contentLength: response.contentLength,
acceptsRanges: _advertisesRanges(response.headers),
Expand Down
5 changes: 3 additions & 2 deletions benchmarker/lib/src/ui/widgets/config_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,9 @@ class _RangeSelector extends StatelessWidget {
],
selected: {form.rangeMode},
showSelectedIcon: false,
onSelectionChanged:
isBusy ? null : (selection) => form.rangeMode = selection.first,
onSelectionChanged: isBusy
? null
: (selection) => form.rangeMode = selection.first,
),
),
),
Expand Down
10 changes: 4 additions & 6 deletions benchmarker/lib/src/ui/widgets/stats_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -351,8 +351,7 @@ class _TileGrid extends StatelessWidget {
spacing: spacing,
runSpacing: spacing,
children: [
for (final tile in tiles)
SizedBox(width: tileWidth, child: tile),
for (final tile in tiles) SizedBox(width: tileWidth, child: tile),
],
);
},
Expand Down Expand Up @@ -426,10 +425,9 @@ class _TimingTable extends StatelessWidget {
child: Text(
text,
textAlign: leading ? TextAlign.left : TextAlign.right,
style: (header
? theme.textTheme.labelSmall
: theme.textTheme.bodySmall)
?.copyWith(
style:
(header ? theme.textTheme.labelSmall : theme.textTheme.bodySmall)
?.copyWith(
color: header ? theme.colorScheme.onSurfaceVariant : null,
fontFeatures: const [FontFeature.tabularFigures()],
),
Expand Down
3 changes: 1 addition & 2 deletions benchmarker/lib/src/util/formatting.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,4 @@ String formatTimestamp(DateTime time) {
'${formatClockTime(local)}';
}

String _pad(int value, [int width = 2]) =>
value.toString().padLeft(width, '0');
String _pad(int value, [int width = 2]) => value.toString().padLeft(width, '0');
10 changes: 6 additions & 4 deletions benchmarker/test/benchmark_config_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ void main() {
test('always sums to the total request count', () {
for (var concurrency = 1; concurrency <= 16; concurrency++) {
for (var total = concurrency; total <= 200; total += 7) {
final distribution =
_config(concurrency: concurrency, total: total).requestDistribution();
final distribution = _config(concurrency: concurrency, total: total)
.requestDistribution();
expect(distribution, hasLength(concurrency));
expect(distribution.reduce((a, b) => a + b), total);
expect(distribution.every((count) => count > 0), isTrue);
Expand Down Expand Up @@ -100,7 +100,8 @@ void main() {

test('windows start where the previous one ended', () {
const requestCount = 7;
final plan = RangePlan.sequential(const ByteRange(4096, 20479), requestCount);
final plan =
RangePlan.sequential(const ByteRange(4096, 20479), requestCount);

// Every window but the last starts directly after its predecessor; the
// last slides back to end on the final byte, so it may overlap.
Expand Down Expand Up @@ -138,7 +139,8 @@ void main() {

test('the windows cover the whole selected range', () {
for (final requestCount in [1, 2, 3, 7, 16, 100]) {
final plan = RangePlan.sequential(const ByteRange(500, 1499), requestCount);
final plan =
RangePlan.sequential(const ByteRange(500, 1499), requestCount);
expect(plan.windowFor(0).start, 500);
expect(plan.windowFor(requestCount - 1).end, 1499);
}
Expand Down
15 changes: 7 additions & 8 deletions benchmarker/test/benchmark_controller_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,7 @@ void main() {
await request.response.close();
}
}());
// Addressed as `localhost` rather than the bound `127.0.0.1` so the source
// host differs from the cache server's host; otherwise http_cache_stream
// treats the source URL as an already-encoded cache URL.
sourceUrl = Uri.parse('http://localhost:${origin.port}/payload.bin');
sourceUrl = Uri.parse('http://127.0.0.1:${origin.port}/payload.bin');

cacheDir = await Directory.systemTemp.createTemp('benchmarker_test');
await HttpCacheManager.init(
Expand Down Expand Up @@ -213,10 +210,12 @@ void main() {
// Every window is the same size and together they cover the payload once.
expect(stats.totalBytes, payload.length);
expect(stats.avgBytesPerRequest, plan.windowSize.toDouble());
expect(receivedRanges..sort(), [
for (var sequence = 0; sequence < 8; sequence++)
plan.windowFor(sequence).header,
]..sort());
expect(
receivedRanges..sort(),
[
for (var sequence = 0; sequence < 8; sequence++)
plan.windowFor(sequence).header,
]..sort());
expect(
controller.logs.map((entry) => entry.message),
contains(contains('Sequential windows: 8 ×')),
Expand Down
10 changes: 6 additions & 4 deletions benchmarker/test/benchmark_report_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,16 @@ void main() {

expect(json['run_id'], 3);
expect(json['source_url'], 'https://example.com/file.bin');
expect(json['target_url'], 'http://127.0.0.1:4612/https/example.com/f.bin');
expect(
json['target_url'], 'http://127.0.0.1:4612/https/example.com/f.bin');
expect(json['cache_type'], 'preCached');
expect(json['cache_type_label'], 'Pre-cached');
expect(json['status'], 'Finished');
expect(json['build_mode'], 'release');
expect(json['started_at'], _startedAt.toIso8601String());
expect(json['ended_at'], _endedAt.toIso8601String());
expect(json['wall_duration_us'], const Duration(seconds: 5).inMicroseconds);
expect(
json['wall_duration_us'], const Duration(seconds: 5).inMicroseconds);
expect(json['concurrency'], 2);
expect(json['http_client'], kHttpClientOptions.first.label);

Expand Down Expand Up @@ -155,8 +157,8 @@ void main() {
});

test('renders without a config', () {
final json = jsonDecode(buildJsonReport(_result()))
as Map<String, Object?>;
final json =
jsonDecode(buildJsonReport(_result())) as Map<String, Object?>;

expect(json['source_url'], isNull);
expect((json['requests']! as Map<String, Object?>)['completed'], 4);
Expand Down
3 changes: 2 additions & 1 deletion benchmarker/test/config_panel_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ void main() {
(tester) async {
await pumpPanel(tester);

RangeSlider slider() => tester.widget<RangeSlider>(find.byType(RangeSlider));
RangeSlider slider() =>
tester.widget<RangeSlider>(find.byType(RangeSlider));
ButtonSegment<RangeMode> segment(RangeMode mode) => tester
.widget<SegmentedButton<RangeMode>>(
find.byType(SegmentedButton<RangeMode>),
Expand Down
1 change: 1 addition & 0 deletions lib/http_cache_stream.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export 'src/models/cache_files/cache_files.dart';
export 'src/models/cache_state/cache_state.dart';
export 'src/models/exceptions/http_exceptions.dart';
export 'src/models/exceptions/invalid_cache_exceptions.dart';
export 'src/models/exceptions/partial_cache_feed_exceptions.dart';
export 'src/models/exceptions/state_errors.dart';
export 'src/models/exceptions/stream_response_exceptions.dart';
export 'src/models/http_range/http_range.dart';
Expand Down
4 changes: 3 additions & 1 deletion lib/src/cache_manager/http_cache_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ class HttpCacheManager {

///Remove when stream is disposed
cacheStream.future.onComplete(() {
_streams.remove(requestKey);
if (identical(_streams[requestKey], cacheStream)) {
_streams.remove(requestKey);
}
});

if (_onStreamCreated case final streamCreatedCallback?) {
Expand Down
1 change: 0 additions & 1 deletion lib/src/cache_server/keep_alive_server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ class KeepAliveServer {
if (_closed) return;

final prevServer = _server;
_serverSubscription?.cancel();

_server = await HttpServer.bind(address, port, shared: true);
_forwardEvents(_server);
Expand Down
14 changes: 10 additions & 4 deletions lib/src/cache_server/local_cache_server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,17 @@ class LocalCacheServer {

Uri encodeSourceUrl(Uri sourceUrl) {
if (sourceUrl.host == serverUri.host) {
if (!validateCacheUrl(sourceUrl)) {
throw ArgumentError(
'Invalid source URL: $sourceUrl. The host matches the cache server host but the URL is not a valid cache URL.');
if (validateCacheUrl(sourceUrl)) {
return sourceUrl; // Already encoded for this server.
}
// A cache server may be assigned a different port between runs. Decode a
// URL produced by an earlier instance before encoding it for this one.
// Requiring the cache server's scheme and a different port lets regular
// source URLs hosted by another local server pass through unchanged.
if (sourceUrl.scheme == serverUri.scheme &&
sourceUrl.port != serverUri.port) {
sourceUrl = decodeSourceUrl(sourceUrl) ?? sourceUrl;
}
return sourceUrl; //Already encoded
}

final defaultPort = switch (sourceUrl.scheme) {
Expand Down
93 changes: 47 additions & 46 deletions lib/src/cache_stream/cache_downloader/buffered_io_sink.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,28 @@ import 'dart:async';
import 'dart:io';
import 'dart:typed_data';

import '../../models/exceptions/partial_cache_feed_exceptions.dart';
import '../response_streams/partial_cache_feed.dart';

part 'buffered_io_sink_feed.dart';

/// 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.
static const int _maxWriteSize = 256 * 1024; // 256 KB

final File file;
BufferedIOSink(this.file, int initialPosition)
: _flushedBytes = initialPosition;
: _flushedBytes = initialPosition {
_feed = BufferedIOSinkFeed._(this);
}

int _flushedBytes;
final _buffer = BytesBuilder(copy: false);
RandomAccessFile? _openedRAF;
bool _isClosed = false;
Future<void>? _flushFuture;
final List<({int position, Completer<void> completer})> _positionWaiters = [];
late final BufferedIOSinkFeed _feed;

void add(List<int> data) {
if (_isClosed) {
Expand Down Expand Up @@ -44,56 +55,40 @@ class BufferedIOSink {

while (_buffer.isNotEmpty) {
final bytes = _buffer.takeBytes();
await raf.writeFrom(bytes, 0, bytes.length);
_flushedBytes += bytes.length;
_notifyPositionWaiters();
for (int start = 0; start < bytes.length; start += _maxWriteSize) {
final int uncappedEnd = start + _maxWriteSize;
final int end =
uncappedEnd < bytes.length ? uncappedEnd : bytes.length;
await raf.writeFrom(bytes, start, end);
_flushedBytes += end - start;
_feed._notifyPositionWaiters();
}
}
_flushFuture = null;
} catch (e) {
_failPositionWaiters(e);
_feed._failPositionWaiters(e);
rethrow;
}
}();
}

/// Returns a [Future] that completes once [flushedBytes] reaches or exceeds [minFlushedBytes].
/// Returns a [PositionWaiter] that completes once [flushedBytes] reaches or exceeds [minFlushedBytes].
/// Completes immediately if the position is already reached.
/// Fails if the sink is closed or a flush error occurs before the position is reached.
Future<void> waitForPosition(int minFlushedBytes,
[Duration timeout = const Duration(seconds: 30)]) {
if (_flushedBytes >= minFlushedBytes) return Future.value();
if (_isClosed) {
return Future.error(StateError(
'BufferedIOSink closed before reaching position $minFlushedBytes'));
}
final completer = Completer<void>();
_positionWaiters.add((position: minFlushedBytes, completer: completer));
return completer.future.timeout(timeout, onTimeout: () {
_positionWaiters.removeWhere((w) => w.completer == completer);
throw TimeoutException(
'Timeout while waiting for flushedBytes to reach $minFlushedBytes',
timeout);
});
}
/// Fails if the sink is closed, a flush error occurs before the position is reached, or the waiter is cancelled.
PositionWaiter waitForPosition(int minFlushedBytes) =>
_feed.waitForPosition(minFlushedBytes);

void _notifyPositionWaiters() {
if (_positionWaiters.isEmpty) return;
for (int i = _positionWaiters.length - 1; i >= 0; i--) {
if (_flushedBytes >= _positionWaiters[i].position) {
_positionWaiters.removeAt(i).completer.complete();
}
}
}

void _failPositionWaiters(Object error) {
if (_positionWaiters.isEmpty) return;
for (final w in _positionWaiters) {
w.completer.completeError(error);
}
_positionWaiters.clear();
}

Future<void> close({final bool flushBuffer = true}) async {
/// Closes the sink, resolving any waiters that can no longer be satisfied.
///
/// Set [isDone] when the producer reached the end of its content. The feed is
/// then left unfailed, so readers treat [flushedBytes] as the true end of the
/// content. When [isDone] is false the download was aborted, and the feed
/// fails with [PartialCacheAbortedException] so readers do not mistake the
/// truncated content for an end of stream.
Future<void> close({
final bool flushBuffer = true,
final bool isDone = false,
}) async {
if (_isClosed) return;
_isClosed = true;

Expand All @@ -103,17 +98,23 @@ class BufferedIOSink {
}
await flush(); //Even if !flushBuffer, ongoing flush must complete before RAF can be closed
} finally {
_failPositionWaiters(StateError('BufferedIOSink closed'));
_buffer.clear();
if (_openedRAF case final RandomAccessFile raf) {
_openedRAF = null;
await raf.close();
try {
if (_openedRAF case final RandomAccessFile raf) {
_openedRAF = null;
await raf.close();
}
} finally {
_feed._close(
failure: isDone ? null : PartialCacheAbortedException(_flushedBytes),
);
}
}
}

int get bufferSize => _buffer.length;
int get flushedBytes => _flushedBytes;
PartialCacheFeed get feed => _feed;
bool get flushed => _buffer.isEmpty && !isFlushing;
bool get isFlushing => _flushFuture != null;
bool get isClosed => _isClosed;
Expand Down
Loading
Loading