diff --git a/CHANGELOG.md b/CHANGELOG.md index 678bf66..3994e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 09bc55a..66c5491 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ Add the following to your projects `Info.plist` file: ``` +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`: diff --git a/example/pubspec.lock b/example/pubspec.lock index 2b8b58b..0476e07 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -222,7 +222,7 @@ packages: path: ".." relative: true source: path - version: "0.1.0" + version: "0.2.1" http_parser: dependency: transitive description: @@ -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: @@ -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: @@ -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: @@ -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: diff --git a/lib/src/cache_manager/http_cache_manager.dart b/lib/src/cache_manager/http_cache_manager.dart index ac17630..ece72f9 100644 --- a/lib/src/cache_manager/http_cache_manager.dart +++ b/lib/src/cache_manager/http_cache_manager.dart @@ -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 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. diff --git a/lib/src/cache_server/keep_alive_server.dart b/lib/src/cache_server/keep_alive_server.dart index e7d7c6f..003431f 100644 --- a/lib/src/cache_server/keep_alive_server.dart +++ b/lib/src/cache_server/keep_alive_server.dart @@ -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 { @@ -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 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 listen( void Function(HttpRequest event)? onData, {Function? onError, diff --git a/pubspec.yaml b/pubspec.yaml index 4d4a1f5..ceb4e97 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.2.0 +version: 0.2.1 homepage: https://github.com/Colton127/http_cache_stream repository: https://github.com/Colton127/http_cache_stream topics: diff --git a/test/e2e/lifecycle_test.dart b/test/e2e/lifecycle_test.dart index 4c856a7..44860ce 100644 --- a/test/e2e/lifecycle_test.dart +++ b/test/e2e/lifecycle_test.dart @@ -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())); + }); } diff --git a/test/io/keep_alive_server_test.dart b/test/io/keep_alive_server_test.dart new file mode 100644 index 0000000..e1418b7 --- /dev/null +++ b/test/io/keep_alive_server_test.dart @@ -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 _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 _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'); + }); +}