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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
## 0.2.1

### New Features

* Added `HttpCacheManager.ensureActive()`. Ensures the local cache server is accepting connections, restarting it on the same port if needed. Call it before retrying a cache URL request that failed to connect (e.g. after an iOS app resumes from background suspension).

### Fixes

* Fixed the iOS local cache server staying unreachable after the app resumes from background suspension. Recovery previously re-attached to the same dead listening socket, so connections kept failing with "could not connect to server" (`-1004`). The server now releases the dead socket and binds a new one on the same port.

## 0.2.0

This release is designed to preserve existing behavior while making caching and streaming faster and more robust.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ Add the following to your projects `Info.plist` file:
</dict>
```

iOS can reclaim the local server's socket while the app is suspended in the background. The server recovers automatically, but a player that connects immediately on resume may fail with a connection error (`-1004`). To handle this, call `HttpCacheManager.instance.ensureActive()` before retrying the request.

### Android:

Create `android/app/src/main/res/xml/network_security_config.xml`:
Expand Down
18 changes: 9 additions & 9 deletions example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ packages:
path: ".."
relative: true
source: path
version: "0.1.0"
version: "0.2.1"
http_parser:
dependency: transitive
description:
Expand Down Expand Up @@ -315,10 +315,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
Expand All @@ -331,10 +331,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.19.0"
objective_c:
dependency: transitive
description:
Expand Down Expand Up @@ -504,10 +504,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.12"
typed_data:
dependency: transitive
description:
Expand All @@ -528,10 +528,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.4.3"
video_player:
dependency: "direct main"
description:
Expand Down
10 changes: 10 additions & 0 deletions lib/src/cache_manager/http_cache_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ class HttpCacheManager {
return _server.encodeSourceUrl(sourceUrl);
}

/// Ensures the local cache server is accepting connections, restarting it on the same port if it is not.
///
/// On iOS, the server's socket can be reclaimed while the app is suspended in the background. The server is checked periodically
/// and restarted automatically, but a player may connect before the next check. Call this before retrying a request to a cache URL
/// that failed to connect.
Future<void> ensureActive() {
_checkDisposed();
return _server.ensureActive();
}

/// Create a [HttpCacheStream] instance for the given URL. If an instance already exists, the existing instance will be returned.
/// Use [file] to specify the output file to save the downloaded content to. If not provided, a file will be created in the cache directory.
/// Prefer [getCacheUrl] unless if you need access to the `HttpCacheStream` instance.
Expand Down
36 changes: 28 additions & 8 deletions lib/src/cache_server/keep_alive_server.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:io';

import 'package:flutter/foundation.dart' show visibleForTesting;

/// A wrapper around [HttpServer] that keeps the server alive by periodically checking its health and restarting it if necessary.
/// Workaround for https://github.com/dart-lang/sdk/issues/63168
class KeepAliveServer {
Expand Down Expand Up @@ -64,20 +66,38 @@ class KeepAliveServer {
return _ensureActiveFuture ??= () async {
try {
if (await isAlive()) return;
if (_closed) return;

final prevServer = _server;

_server = await HttpServer.bind(address, port, shared: true);
_forwardEvents(_server);

await prevServer.close(force: true);
await rebind();
} finally {
_ensureActiveFuture = null;
}
}();
}

/// Replaces the listening socket with a new one on the same address and port.
///
/// The current server must be closed before binding: within one process, a
/// `shared` bind to an (address, port) that is still open reuses the existing
/// listening socket rather than creating a new one, so binding first would
/// re-attach to the same dead socket. Closing without `force` leaves requests
/// already in progress untouched.
@visibleForTesting
Future<void> rebind() async {
if (_closed) return;
final prevSubscription = _serverSubscription;
_serverSubscription = null;
await prevSubscription?.cancel();
await _server.close();
if (_closed) return;

final server = await HttpServer.bind(address, port, shared: true);
if (_closed) {
await server.close(force: true);
return;
}
_server = server;
_forwardEvents(server);
}

StreamSubscription<HttpRequest> listen(
void Function(HttpRequest event)? onData,
{Function? onError,
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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.2.0
version: 0.2.1
homepage: https://github.com/Colton127/http_cache_stream
repository: https://github.com/Colton127/http_cache_stream
topics:
Expand Down
16 changes: 16 additions & 0 deletions test/e2e/lifecycle_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -252,4 +252,20 @@ void main() {
await h.manager.deleteCache();
expect(files.complete.existsSync(), isFalse);
});

test('ensureActive keeps cache urls reachable', () async {
final source = h.origin.url('/ensure-active.mp3');
final cacheUrl = h.manager.getCacheUrl(source);

await h.manager.ensureActive();
final result = await h.fetch(cacheUrl);
expect(result.statusCode, 200);
expect(result.body, h.origin.payload);
});

test('ensureActive throws after the manager is disposed', () async {
final manager = h.manager;
await manager.dispose();
expect(manager.ensureActive, throwsA(isA<CacheManagerDisposedException>()));
});
}
80 changes: 80 additions & 0 deletions test/io/keep_alive_server_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import 'dart:io';

import 'package:flutter_test/flutter_test.dart';
import 'package:http_cache_stream/src/cache_server/keep_alive_server.dart';

/// Identifies the kernel socket listening on [port] in this process, or null
/// when lsof is unavailable or reports no identity for the socket.
Future<String?> _listeningSocketId(int port) async {
try {
final result = await Process.run('lsof', [
'-a',
'-p',
'$pid',
'-iTCP:$port',
'-sTCP:LISTEN',
'-Fdi',
]);
if (result.exitCode != 0) return null;
final ids = (result.stdout as String)
.split('\n')
.where((line) => line.startsWith('d') || line.startsWith('i'))
.join(',');
return ids.isEmpty ? null : ids;
} on ProcessException {
return null;
}
}

Future<String> _get(KeepAliveServer server) async {
final client = HttpClient();
try {
final request = await client.get(server.address.host, server.port, '/');
final response = await request.close();
return await response.transform(const SystemEncoding().decoder).join();
} finally {
client.close(force: true);
}
}

void main() {
late KeepAliveServer server;

setUp(() async {
server = await KeepAliveServer.bind(InternetAddress.loopbackIPv4, 0);
server.listen((request) {
request.response
..write('ok')
..close();
});
});

tearDown(() => server.close(force: true));

test('rebind keeps serving requests on the same port', () async {
expect(await _get(server), 'ok');
await server.rebind();
expect(await server.isAlive(), isTrue);
expect(await _get(server), 'ok');
});

test('rebind replaces the listening socket instead of reusing it', () async {
final before = await _listeningSocketId(server.port);
if (before == null) {
markTestSkipped('lsof is unavailable');
return;
}
await server.rebind();
final after = await _listeningSocketId(server.port);
expect(after, isNotNull);
expect(after, isNot(before),
reason: 'a dead listening socket must not survive a rebind');
});

test('ensureActive leaves a healthy server untouched', () async {
final before = await _listeningSocketId(server.port);
await server.ensureActive();
expect(await _listeningSocketId(server.port), before);
expect(await _get(server), 'ok');
});
}
Loading