From 53335214d7a42ede78a3907d72a594c744d0e534 Mon Sep 17 00:00:00 2001 From: Colton Date: Thu, 24 Sep 2026 16:44:19 -0400 Subject: [PATCH 1/2] Fix keep-alive server rebinding onto its dead socket A shared HttpServer.bind to an (address, port) that is still open in the same process reuses the existing listening socket. ensureActive() bound the replacement before closing the old server, so after iOS reclaimed the socket during suspension every recovery attempt re-attached to the same dead socket and the server stayed unreachable. Close the old server first, then bind a fresh socket. Closing without force leaves in-flight requests untouched. Bump to 0.2.1. --- CHANGELOG.md | 6 ++ example/pubspec.lock | 18 ++--- lib/src/cache_server/keep_alive_server.dart | 36 +++++++--- pubspec.yaml | 2 +- test/io/keep_alive_server_test.dart | 80 +++++++++++++++++++++ 5 files changed, 124 insertions(+), 18 deletions(-) create mode 100644 test/io/keep_alive_server_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 678bf66..518fa24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.2.1 + +### 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/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_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/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'); + }); +} From 9c70505b8ee9a64688eedf433531502228572630 Mon Sep 17 00:00:00 2001 From: Colton Date: Thu, 24 Sep 2026 16:53:32 -0400 Subject: [PATCH 2/2] Add HttpCacheManager.ensureActive() Exposes the local server's health check so apps can make sure the server is accepting connections before retrying a cache URL request that failed to connect. The periodic iOS health check cannot close the gap when a player connects immediately after the app resumes from background suspension. --- CHANGELOG.md | 4 ++++ README.md | 2 ++ lib/src/cache_manager/http_cache_manager.dart | 10 ++++++++++ test/e2e/lifecycle_test.dart | 16 ++++++++++++++++ 4 files changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 518fa24..3994e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## 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. 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/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/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())); + }); }