Skip to content

Commit f9b5378

Browse files
committed
Race server connections concurrently instead of probing serially
Cold start took 138s before artwork appeared. discoverServers() walked every server sequentially and, within each, every connection candidate sequentially, with a 15s OkHttp timeout on each probe — so the total was (every unreachable endpoint across every server on the account) x 15s. Unreachable candidates are other servers' own LAN/Docker addresses, which are unroutable from this network by definition. - Probe all servers concurrently (async/awaitAll) and race each server's candidates (raceFirstSuccess): first success wins, losers cancelled. Probes go through a suspendCancellableCoroutine-wrapped enqueue() so cancellation genuinely aborts the in-flight call. - Separate 3s probe client via client.newBuilder() (shares the dispatcher and connection pool); the data client keeps its 15s timeouts so artwork and library fetches are unaffected. - Cache the winning connection per server (ServerConnectionCache, cleared on sign-out); a cache hit needs one short probe instead of a full race. - Prefer same-subnet candidates. The old ".plex.direct avoidance" was a no-op in practice: plex.tv returns .plex.direct hostnames for LAN connections too, since they need a valid wildcard cert. - 29 unit tests (first new test source set in this repo). Measured on the Shield: 138s -> 16s cold, 14s warm.
1 parent b1689e9 commit f9b5378

11 files changed

Lines changed: 794 additions & 91 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- Critical: Cold-start server discovery took 45-90s because `discoverServers()` tested every connection candidate for every server one at a time, each with a 15s timeout - on a 3-server account this meant every unreachable LAN/Docker address on every server on the account cost a full serial 15s
13+
- Server discovery now races all servers, and all connection candidates within each server, concurrently (structured concurrency, losers cancelled on first success) using a short 3s probe timeout on a dedicated probe client, while the main data client keeps its generous 15s timeout for real artwork/library requests
14+
- The last-known-good connection per server is now cached and probed first on the next launch; a hit skips discovery for that server entirely - the big win for a screensaver process that restarts constantly
15+
- Corrected the ".plex.direct avoidance" comment/logic: plex.tv issues .plex.direct hostnames for nearly all connections including LAN ones (they carry the wildcard TLS cert), so hostname shape was never a reliable signal for "non-local" - local-first ordering is now driven by Plex's own `local` flag plus a cheap, DNS-free same-subnet heuristic instead
16+
17+
### Added
18+
19+
- Minimal JVM unit test setup (`app/src/test`, JUnit4 + kotlinx-coroutines-test) covering connection ordering, subnet matching, dashed-IP parsing, and the first-success-wins race helper
20+
1021
## [0.1.4-alpha] - 2026-02-01
1122

1223
### Fixed

app/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,5 +139,9 @@ dependencies {
139139

140140
// QR Code generation
141141
implementation("com.google.zxing:core:3.5.2")
142+
143+
// Unit tests (pure JVM - no device/emulator needed)
144+
testImplementation("junit:junit:4.13.2")
145+
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
142146
}
143147

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package com.willbeeching.flix.plex
2+
3+
import kotlinx.coroutines.CancellationException
4+
import kotlinx.coroutines.channels.Channel
5+
import kotlinx.coroutines.coroutineScope
6+
import kotlinx.coroutines.launch
7+
import java.net.URI
8+
9+
/**
10+
* Cheap, non-blocking description of the device's active local network,
11+
* used only to bias connection-candidate ordering toward the same subnet.
12+
* Built from [java.net.NetworkInterface] enumeration only - never from a
13+
* real DNS lookup or network call.
14+
*/
15+
internal data class LocalNetworkInfo(val ip: String, val prefixLength: Int)
16+
17+
/**
18+
* Parses the dashed-IP form Plex embeds in .plex.direct hostnames, e.g.
19+
* "10-0-0-8.abcd1234.plex.direct" -> "10.0.0.8". Returns null for anything
20+
* that isn't a well-formed dashed IPv4 address in the first DNS label.
21+
*
22+
* This is a string-only heuristic - it never performs DNS resolution.
23+
*/
24+
internal fun parseDashedPlexDirectIp(host: String): String? {
25+
if (!host.endsWith(".plex.direct", ignoreCase = true)) return null
26+
val firstLabel = host.substringBefore('.')
27+
val octets = firstLabel.split("-")
28+
if (octets.size != 4) return null
29+
val values = octets.map { it.toIntOrNull() ?: return null }
30+
if (values.any { it !in 0..255 }) return null
31+
return values.joinToString(".")
32+
}
33+
34+
/**
35+
* Extracts the host portion of a connection URI, e.g.
36+
* "https://10.0.0.8:32400" -> "10.0.0.8". Returns null if [uriString] isn't
37+
* a parseable URI. This does not perform DNS resolution - [URI.getHost]
38+
* only parses the authority component of the string.
39+
*/
40+
internal fun hostOf(uriString: String): String? {
41+
return try {
42+
URI(uriString).host
43+
} catch (e: Exception) {
44+
null
45+
}
46+
}
47+
48+
/**
49+
* Converts a dotted-quad IPv4 string to its 32-bit integer representation,
50+
* or null if [ip] isn't a valid IPv4 literal.
51+
*/
52+
internal fun ipv4ToInt(ip: String): Int? {
53+
val parts = ip.split(".")
54+
if (parts.size != 4) return null
55+
var result = 0
56+
for (part in parts) {
57+
val octet = part.toIntOrNull() ?: return null
58+
if (octet !in 0..255) return null
59+
result = (result shl 8) or octet
60+
}
61+
return result
62+
}
63+
64+
/**
65+
* True if [ipA] and [ipB] fall in the same network under a [prefixLength]-bit
66+
* CIDR mask. Returns false if either string isn't a valid IPv4 literal.
67+
*/
68+
internal fun isSameSubnet(ipA: String, ipB: String, prefixLength: Int): Boolean {
69+
if (prefixLength <= 0) return true
70+
if (prefixLength >= 32) return ipA == ipB
71+
val a = ipv4ToInt(ipA) ?: return false
72+
val b = ipv4ToInt(ipB) ?: return false
73+
val mask = -1 shl (32 - prefixLength)
74+
return (a and mask) == (b and mask)
75+
}
76+
77+
/**
78+
* Best-effort IPv4 literal for a connection, resolved WITHOUT any DNS:
79+
* either the connection's host is already a dotted-quad, or it's a
80+
* .plex.direct hostname whose first label encodes the IP (see
81+
* [parseDashedPlexDirectIp]). Returns null when the host can only be
82+
* resolved via real DNS - in that case subnet matching is simply skipped
83+
* for this candidate (it still gets probed, just not prioritized for it).
84+
*/
85+
internal fun candidateIpLiteral(connection: PlexApiClient.Connection): String? {
86+
val host = hostOf(connection.uri) ?: connection.address
87+
ipv4ToInt(host)?.let { return host }
88+
return parseDashedPlexDirectIp(host)
89+
}
90+
91+
/**
92+
* Whether [connection] can cheaply be shown to sit on the same subnet as
93+
* [localNetwork]. Always false (never "unknown") when it can't be
94+
* determined cheaply - this is a sort-order bias, not a filter.
95+
*/
96+
internal fun isCandidateOnLocalSubnet(
97+
connection: PlexApiClient.Connection,
98+
localNetwork: LocalNetworkInfo?
99+
): Boolean {
100+
if (localNetwork == null) return false
101+
val candidateIp = candidateIpLiteral(connection) ?: return false
102+
return isSameSubnet(candidateIp, localNetwork.ip, localNetwork.prefixLength)
103+
}
104+
105+
/**
106+
* Orders connection candidates for probing. Preference order:
107+
* 1. Plex's own "local" flag (its opinion of whether this is a LAN address)
108+
* 2. Our own same-subnet heuristic (cheap, DNS-free - see [isCandidateOnLocalSubnet])
109+
* 3. Non-relay over relay
110+
* 4. https over http
111+
*
112+
* NOTE: .plex.direct hostnames are deliberately NOT down-ranked here. They
113+
* used to be treated as "avoid if possible" because DNS resolution for them
114+
* was assumed to indicate a non-local/remote connection, but that premise is
115+
* wrong: plex.tv issues .plex.direct hostnames (which carry a valid wildcard
116+
* TLS cert) for nearly every connection, including LAN ones. Local-first
117+
* ordering is preserved above via the `local` flag and the subnet heuristic,
118+
* not via hostname shape.
119+
*/
120+
internal fun sortConnections(
121+
connections: List<PlexApiClient.Connection>,
122+
localNetwork: LocalNetworkInfo?
123+
): List<PlexApiClient.Connection> {
124+
return connections.sortedWith(
125+
compareByDescending<PlexApiClient.Connection> { it.local }
126+
.thenByDescending { isCandidateOnLocalSubnet(it, localNetwork) }
127+
.thenByDescending { !it.relay }
128+
.thenByDescending { it.protocol == "https" }
129+
)
130+
}
131+
132+
/**
133+
* Races [probe] across [items] concurrently using structured concurrency
134+
* ([coroutineScope] + [launch]) and returns the first non-null result.
135+
* Every other in-flight probe is cancelled as soon as a winner is found (or
136+
* once every candidate has been tried and none succeeded) - no losing call
137+
* is left running past this function returning.
138+
*
139+
* A probe that throws is treated as a failure (null), not as a fatal error
140+
* for the whole race - one bad candidate must not sink the others.
141+
*/
142+
internal suspend fun <T, R : Any> raceFirstSuccess(
143+
items: List<T>,
144+
probe: suspend (T) -> R?
145+
): R? = coroutineScope {
146+
if (items.isEmpty()) return@coroutineScope null
147+
148+
// Buffered so a cancelled/losing job's send never suspends.
149+
val results = Channel<R?>(capacity = items.size)
150+
val jobs = items.map { item ->
151+
launch {
152+
val outcome = try {
153+
probe(item)
154+
} catch (e: CancellationException) {
155+
throw e
156+
} catch (e: Exception) {
157+
null
158+
}
159+
results.send(outcome)
160+
}
161+
}
162+
163+
var winner: R? = null
164+
var received = 0
165+
while (received < items.size && winner == null) {
166+
winner = results.receive()
167+
received++
168+
}
169+
jobs.forEach { it.cancel() }
170+
winner
171+
}

0 commit comments

Comments
 (0)