From 151ae3ca5baf89de76e8264efde39a6ebe395daf Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 18:34:08 -0400 Subject: [PATCH] fix local cache loopback port --- .beads/issues.jsonl | 2 + Dreamio/ProgressiveHTTPRangeCache.swift | 78 +++++++++++++++---- Dreamio/VLCNativePlaybackBackend.swift | 2 +- .../2026-05-25-vlc-local-range-cache.html | 10 ++- 4 files changed, 77 insertions(+), 15 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 279eb8b..eaee3c3 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,6 @@ +{"_type":"issue","id":"dreamio-mun","title":"fix vlc cache loopback port startup","description":"Device logs showed local-cache playback opening http://127.0.0.1:0, because the NWListener ephemeral port was read before the listener reached ready. Wait for the real assigned port before returning the local cache URL.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T22:32:41Z","created_by":"dirtydishes","updated_at":"2026-05-25T22:33:15Z","started_at":"2026-05-25T22:33:14Z","closed_at":"2026-05-25T22:33:15Z","close_reason":"Wait for NWListener ready state before returning the local cache URL; verified tests and simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-11s","title":"add progressive vlc range cache","description":"Implement Dreamio-owned progressive HTTP range cache for native VLC playback, including local loopback playback, diagnostics, and range cache tests.","status":"closed","priority":1,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-25T22:20:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T22:33:59Z","closed_at":"2026-05-25T22:33:59Z","close_reason":"Implemented progressive HTTP range cache, VLC loopback playback selection, diagnostics, and cache tests.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-6bv","title":"fix native stream proxy buffering after seek","description":"Investigate and fix VLC staying in buffering after native proxy-backed jump seeks. Logs show time remains pinned after jump while state repeatedly reports buffering.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T21:53:57Z","created_by":"dirtydishes","updated_at":"2026-05-25T21:55:32Z","started_at":"2026-05-25T21:54:01Z","closed_at":"2026-05-25T21:55:32Z","close_reason":"handled VLC buffering follow-up by supporting HEAD probes, moving fetch work off listener queue, reducing foreground range size, and locking cache access","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-dsp","title":"add local seek buffer proxy for native playback","description":"Implement a local HTTP range proxy/cache between VLC and direct-file streams so nearby seeks can use retained bytes, while preserving stream headers and subtitle behavior.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T20:18:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T20:22:41Z","started_at":"2026-05-25T20:18:47Z","closed_at":"2026-05-25T20:22:41Z","close_reason":"implemented local native stream cache proxy with range cache tests and successful simulator build","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-ejh","title":"Preserve external subtitle language names in VLC captions menu","description":"VLC can surface externally attached subtitle slaves as generic Track N labels even though Dreamio already knows the OpenSubtitles language metadata. Preserve and apply that metadata when building the native captions menu so users can distinguish subtitle languages.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T17:46:38Z","created_by":"dirtydishes","updated_at":"2026-05-25T17:48:09Z","started_at":"2026-05-25T17:46:43Z","closed_at":"2026-05-25T17:48:09Z","close_reason":"Fixed by preserving known external subtitle display names for generic VLC subtitle tracks and expanding language-code aliases.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/ProgressiveHTTPRangeCache.swift b/Dreamio/ProgressiveHTTPRangeCache.swift index baa6ba5..aa8df4a 100644 --- a/Dreamio/ProgressiveHTTPRangeCache.swift +++ b/Dreamio/ProgressiveHTTPRangeCache.swift @@ -335,29 +335,79 @@ final class ProgressiveHTTPRangeCacheServer { private var listener: NWListener? private var port: UInt16? private var sessions: [String: ProgressiveHTTPRangeCacheSession] = [:] + private var startupContinuations: [CheckedContinuation] = [] - func localURL(for session: ProgressiveHTTPRangeCacheSession) throws -> URL { - try startIfNeeded() + func localURL(for session: ProgressiveHTTPRangeCacheSession) async throws -> URL { + let assignedPort = try await startIfNeeded() sessions[session.id] = session - guard let port, - let url = URL(string: "http://127.0.0.1:\(port)/stream/\(session.id)") else { + guard let url = URL(string: "http://127.0.0.1:\(assignedPort)/stream/\(session.id)") else { throw HTTPRangeCacheError.serverUnavailable } return url } - private func startIfNeeded() throws { - guard listener == nil else { - return + private func startIfNeeded() async throws -> UInt16 { + if let port, port > 0 { + return port } - let listener = try NWListener(using: .tcp, on: .any) - listener.newConnectionHandler = { [weak self] connection in - self?.handle(connection) + return try await withCheckedThrowingContinuation { continuation in + queue.async { [weak self] in + guard let self else { + continuation.resume(throwing: HTTPRangeCacheError.serverUnavailable) + return + } + + if let port = self.port, port > 0 { + continuation.resume(returning: port) + return + } + + self.startupContinuations.append(continuation) + guard self.listener == nil else { + return + } + + do { + let listener = try NWListener(using: .tcp, on: .any) + listener.newConnectionHandler = { [weak self] connection in + self?.handle(connection) + } + listener.stateUpdateHandler = { [weak self] state in + self?.handleListenerState(state) + } + self.listener = listener + listener.start(queue: self.queue) + } catch { + self.finishStartup(with: .failure(error)) + } + } + } + } + + private func handleListenerState(_ state: NWListener.State) { + switch state { + case .ready: + guard let rawPort = listener?.port?.rawValue, rawPort > 0 else { + finishStartup(with: .failure(HTTPRangeCacheError.serverUnavailable)) + return + } + let assignedPort = UInt16(rawPort) + port = assignedPort + finishStartup(with: .success(assignedPort)) + case .failed(let error): + finishStartup(with: .failure(error)) + default: + break + } + } + + private func finishStartup(with result: Result) { + let continuations = startupContinuations + startupContinuations.removeAll() + continuations.forEach { continuation in + continuation.resume(with: result) } - listener.start(queue: queue) - self.listener = listener - self.port = listener.port.map { UInt16($0.rawValue) } } private func handle(_ connection: NWConnection) { @@ -449,6 +499,8 @@ final class ProgressiveHTTPRangeCacheServer { } } +extension ProgressiveHTTPRangeCacheServer: @unchecked Sendable {} + private extension NSLock { func withLock(_ body: () -> T) -> T { lock() diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index 3741b70..1760bb8 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -84,7 +84,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { contentLength: contentLength, durationProvider: { [weak self] in self?.duration ?? 0 } ) - let localURL = try ProgressiveHTTPRangeCacheServer.shared.localURL(for: session) + let localURL = try await ProgressiveHTTPRangeCacheServer.shared.localURL(for: session) await MainActor.run { self.rangeCacheSession = session session.prefetch(aroundByteOffset: 0) diff --git a/docs/turns/2026-05-25-vlc-local-range-cache.html b/docs/turns/2026-05-25-vlc-local-range-cache.html index 9edee5d..29c5136 100644 --- a/docs/turns/2026-05-25-vlc-local-range-cache.html +++ b/docs/turns/2026-05-25-vlc-local-range-cache.html @@ -291,6 +291,14 @@

Validation

  • xcrun swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Dreamio/ProgressiveHTTPRangeCache.swift Tests/StreamResolverTests.swift -o /tmp/StreamResolverTests && /tmp/StreamResolverTests passed.
  • pod install restored missing CocoaPods support files for the worktree.
  • xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'generic/platform=iOS Simulator' build passed.

Issues, Limitations, and Mitigations

  • Manual device validation against the original problematic stream is still needed because local playback quality depends on real server range behavior.
  • The first version intentionally does not cache HLS/live streams; those remain on direct VLC playback because their model is playlist/segment based.
  • The loopback server is intentionally small and per-process. If future playback needs multiple concurrent videos, session lifecycle cleanup should be tightened further.

Follow-up Work

  • Run the manual 15-second seek validation on device with the stream that produced the buffering logs.
  • Add an end-to-end local server integration test that opens the loopback URL and verifies repeated range reuse through the full server path.
  • Consider exposing cache counters in a debug overlay if native playback diagnostics continue to be a focus.
+

New Changes as of 2026-05-25 18:32 EDT

Summary of changes

Fixed the loopback cache server startup so Dreamio waits for Network.framework to report the real assigned port before giving VLC the local playback URL.

Why this change was made

Device logs showed VLC opening http://127.0.0.1:0/stream/.... Port 0 is only the ephemeral-port request placeholder, not a usable listening port, so VLC immediately failed playback even though the upstream range cache had started fetching data.

Code diffs

ProgressiveHTTPRangeCacheServer.localURL(for:) is now async.
+ let assignedPort = try await startIfNeeded()
+ URL(string: "http://127.0.0.1:\(assignedPort)/stream/\(session.id)")
+
+ listener.stateUpdateHandler waits for .ready
+ port = UInt16(listener.port.rawValue)
+
+ startup continuations resume only after the real port is available.

Related issues or PRs

Related to Beads issue dreamio-11s and branch lavender/vlc-local-range-cache.

- \ No newline at end of file +