@@ -52,16 +52,21 @@ open class NsdDiscovery(context: Context?) {
53 // Thread-safe: mutated by the resolver coroutine and the (main-thread) lost callback.
53 // Thread-safe: mutated by the resolver coroutine and the (main-thread) lost callback.
54 val found = java.util.concurrent.ConcurrentHashMap<String, DiscoveredServer>()
54 val found = java.util.concurrent.ConcurrentHashMap<String, DiscoveredServer>()
55− // Names reported lost before the serialized resolver reached them. Without this, a
55+ // Per-name epoch, bumped on every onServiceLost. Each queued resolve is stamped with the
56− // service that disappears between onServiceFound and the resolver dequeuing it would
56+ // epoch that was current when it was enqueued; the resolver discards its result if the
57− // be re-inserted into `found` after onServiceLost already fired (and returned null),
57+ // epoch has advanced since. This makes loss ordering authoritative: a resolve queued
58− // leaving a phantom entry that lingers until discovery stops — the lost callback never
58+ // BEFORE a loss — a stale duplicate onServiceFound (stock NsdManager can emit these on
59− // fires twice for the same name. The resolver consults this set and skips the insert.
59+ // multi-homed devices), or a resolve still in flight when the service departs — is
60+ // dropped, while a resolve queued AFTER a loss (a genuine re-announcement of a restarted
61+ // server) still inserts. It fixes the phantom a plain "lost-pending" flag missed:
62+ // onServiceLost removing an already-resolved entry couldn't tell a second, still-queued
63+ // duplicate resolve to skip re-inserting the departed service — yet it also avoids that
64+ // flag's converse hazard of leaking a marker that suppresses a later legitimate re-appearance.
60− val lostPending = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap<String, Boolean>())
65+ val epoch = java.util.concurrent.ConcurrentHashMap<String, Int>()
61− val toResolve = Channel<NsdServiceInfo>(Channel.UNLIMITED)
66+ val toResolve = Channel<Pair<NsdServiceInfo, Int>>(Channel.UNLIMITED)
63 val resolver = launch {
68 val resolver = launch {
64− for (info in toResolve) {
69+ for ((info, stampedEpoch) in toResolve) {
65 // Key the map by the name from discovery: onServiceFound/onServiceLost both use
70 // Key the map by the name from discovery: onServiceFound/onServiceLost both use
66 // NsdServiceInfo.serviceName, whereas the resolved info's serviceName can differ —
71 // NsdServiceInfo.serviceName, whereas the resolved info's serviceName can differ —
67 // keying by the resolved name would leave a departed server that onServiceLost
72 // keying by the resolved name would leave a departed server that onServiceLost
@@ -72,13 +77,9 @@ open class NsdDiscovery(context: Context?) {
72 // so every later-discovered service queued behind it is never processed and the
77 // so every later-discovered service queued behind it is never processed and the
73 // emitted list silently stops updating. Abandon a stuck resolve and keep draining.
78 // emitted list silently stops updating. Abandon a stuck resolve and keep draining.
74 val resolved = withTimeoutOrNull(RESOLVE_TIMEOUT_MS) { resolveService(nsd, info) }
79 val resolved = withTimeoutOrNull(RESOLVE_TIMEOUT_MS) { resolveService(nsd, info) }
75− // Consume any "lost while queued" marker for this name REGARDLESS of the resolve
80+ // Drop the result if the service was reported lost since this resolve was queued
76− // outcome, and skip resurrecting a service reported lost. If the resolve failed or
77− // timed out (e.g. the service really is gone), an early `?: continue` on the line
81+ // (its epoch advanced), or if the resolve failed/timed out (the service is gone).
78− // above would skip this and leak the marker — which would then wrongly suppress the
79− // SAME name's next successful resolution when the server restarts and re-announces
80− // (onServiceLost won't fire again to clear it). Also drop a null resolve outright.
81− if (lostPending.remove(discoveredName) || resolved == null) continue
82+ if ((epoch[discoveredName] ?: 0) != stampedEpoch || resolved == null) continue
82 val name = resolved.serviceName.orEmpty()
83 val name = resolved.serviceName.orEmpty()
83 // Keep only opencode's own advertisements, not every _http._tcp service
84 // Keep only opencode's own advertisements, not every _http._tcp service
84 // (printers, routers, other web servers) on the network.
85 // (printers, routers, other web servers) on the network.
@@ -86,11 +87,11 @@ open class NsdDiscovery(context: Context?) {
86 @Suppress("DEPRECATION")
87 @Suppress("DEPRECATION")
87 val host = resolved.host?.hostAddress ?: continue
88 val host = resolved.host?.hostAddress ?: continue
88 found[discoveredName] = DiscoveredServer(name = name, host = host, port = resolved.port)
89 found[discoveredName] = DiscoveredServer(name = name, host = host, port = resolved.port)
89− // Close the check-then-insert race: an onServiceLost that fired in the non-suspending
90+ // Close the check-then-insert race: an onServiceLost firing in the non-suspending
90− // window since the marker check above couldn't remove this name from `found` (nothing
91+ // window since the check above bumped the epoch but couldn't remove this name from
91− // was inserted yet), so it set a lostPending marker instead. Re-check and evict now,
92+ // `found` (nothing was inserted yet). Re-check and evict now — the epoch is
92− // else the just-departed service lingers as a phantom onServiceLost won't clear.
93+ // monotonic, so any loss that raced the insert is still visible here.
93− if (lostPending.remove(discoveredName)) found.remove(discoveredName)
94+ if ((epoch[discoveredName] ?: 0) != stampedEpoch) found.remove(discoveredName)
94 trySend(found.values.sortedBy { it.name })
95 trySend(found.values.sortedBy { it.name })
@@ -98,20 +99,19 @@ open class NsdDiscovery(context: Context?) {
98 val listener = object : NsdManager.DiscoveryListener {
99 val listener = object : NsdManager.DiscoveryListener {
99 override fun onDiscoveryStarted(serviceType: String?) {}
100 override fun onDiscoveryStarted(serviceType: String?) {}
100 override fun onServiceFound(serviceInfo: NsdServiceInfo) {
101 override fun onServiceFound(serviceInfo: NsdServiceInfo) {
102+ // Stamp the resolve with the name's current epoch so a loss after this point (but
103+ // before the serialized resolver reaches it) invalidates the result.
104+ val name = serviceInfo.serviceName.orEmpty()
101− toResolve.trySend(serviceInfo)
105+ toResolve.trySend(serviceInfo to (epoch[name] ?: 0))
103 override fun onServiceLost(serviceInfo: NsdServiceInfo) {
107 override fun onServiceLost(serviceInfo: NsdServiceInfo) {
104 val name = serviceInfo.serviceName ?: return
108 val name = serviceInfo.serviceName ?: return
105− // If it was already resolved and in `found`, remove and emit now — and do NOT set a
109+ // Advance the epoch so any resolve queued before now (including a duplicate
106− // lostPending marker: the resolver already processed this name, so the marker would
110+ // onServiceFound whose result hasn't landed yet) is discarded and can't resurrect
107− // never be consumed and would instead suppress the service's NEXT re-appearance (a
111+ // this service, then drop it from the emitted list if it was already resolved.
108− // restarted server re-discovered later would hit `lostPending.remove` and be
112+ epoch.merge(name, 1) { old, _ -> old + 1 }
109− // dropped). Only mark names lost *before* the serialized resolver reached them —
110− // those aren't in `found` yet, and the marker tells the resolver to skip the insert.
111 if (found.remove(name) != null) {
113 if (found.remove(name) != null) {
112 trySend(found.values.sortedBy { it.name })
114 trySend(found.values.sortedBy { it.name })
114− lostPending.add(name)
117 override fun onDiscoveryStopped(serviceType: String?) {}
117 override fun onDiscoveryStopped(serviceType: String?) {}