Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/mobile/e2e/flows/add-host.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ appId: com.arcboxlabs.linkcode.mobile
- openLink: linkcode://connect
- waitForAnimationToEnd
- assertVisible: 'Manage hosts'
- assertVisible: 'Add a host by URL'
- assertVisible: 'Other…'

- tapOn: 'Add a host by URL'
- tapOn: 'Other…'
- waitForAnimationToEnd
- assertVisible: 'Name'
- assertVisible: 'Host URL'
Expand Down
21 changes: 21 additions & 0 deletions apps/mobile/modules/linkcode-daemon-discovery/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
The MIT License (MIT)

Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
15 changes: 15 additions & 0 deletions apps/mobile/modules/linkcode-daemon-discovery/android/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}

group = 'expo.modules.linkcodedaemondiscovery'
version = '0.1.0'

android {
namespace "expo.modules.linkcodedaemondiscovery"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<manifest>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,326 @@
package expo.modules.linkcodedaemondiscovery

import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.net.wifi.WifiManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.util.ArrayDeque

private const val EVENT_NAME = "onHostsChanged"
private const val SERVICE_TYPE = "_linkcode._tcp."
private const val MULTICAST_LOCK_TAG = "LinkCodeDaemonDiscovery"

private data class DiscoveredDaemon(
val id: String,
val name: String,
val host: String,
val port: Int
)

class LinkCodeDaemonDiscoveryModule : Module() {
private val handler = Handler(Looper.getMainLooper())
private val hosts = mutableMapOf<String, DiscoveredDaemon>()
private val activeServiceNames = mutableSetOf<String>()
private val pendingServices = ArrayDeque<NsdServiceInfo>()

private var manager: NsdManager? = null
private var discoveryListener: NsdManager.DiscoveryListener? = null
private var multicastLock: WifiManager.MulticastLock? = null
private var generation = 0
private var isObserved = false
private var isInForeground = true
private var isResolving = false
private var status = "searching"
private var errorCode: String? = null

override fun definition() = ModuleDefinition {
Name("LinkCodeDaemonDiscovery")

Events(EVENT_NAME)

OnStartObserving(EVENT_NAME) {
handler.post {
isObserved = true
startDiscovery()
}
}

OnStopObserving(EVENT_NAME) {
handler.post {
isObserved = false
stopDiscovery()
}
}

OnActivityEntersBackground {
handler.post {
isInForeground = false
stopDiscovery()
}
}

OnActivityEntersForeground {
handler.post {
isInForeground = true
if (isObserved) {
startDiscovery()
}
}
}

OnDestroy {
handler.post {
isObserved = false
isInForeground = false
stopDiscovery()
}
}
}

private fun startDiscovery() {
if (discoveryListener != null) {
emitSnapshot()
return
}

val context = appContext.reactContext?.applicationContext
val nsdManager = context?.getSystemService(Context.NSD_SERVICE) as? NsdManager
if (context == null || nsdManager == null) {
status = "error"
errorCode = "unavailable"
emitSnapshot()
return
}

generation += 1
val currentGeneration = generation
manager = nsdManager
hosts.clear()
activeServiceNames.clear()
pendingServices.clear()
isResolving = false
status = "searching"
errorCode = null
emitSnapshot()
acquireMulticastLock(context)

val listener = object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(serviceType: String) {
handler.post {
if (currentGeneration != generation) return@post
status = "ready"
errorCode = null
emitSnapshot()
}
}

override fun onServiceFound(serviceInfo: NsdServiceInfo) {
handler.post {
if (currentGeneration != generation) return@post
val serviceName = serviceInfo.serviceName ?: return@post
if (activeServiceNames.add(serviceName)) {
pendingServices.addLast(serviceInfo)
resolveNext(currentGeneration)
}
}
}

override fun onServiceLost(serviceInfo: NsdServiceInfo) {
handler.post {
if (currentGeneration != generation) return@post
val serviceName = serviceInfo.serviceName ?: return@post
activeServiceNames.remove(serviceName)
if (hosts.remove(serviceName) != null) {
emitSnapshot()
}
}
}

override fun onStartDiscoveryFailed(serviceType: String, error: Int) {
handler.post {
if (currentGeneration != generation) return@post
discoveryListener = null
status = "error"
errorCode = "failed"
releaseMulticastLock()
emitSnapshot()
}
}

override fun onStopDiscoveryFailed(serviceType: String, error: Int) {
handler.post { retryStop(this) }
}

override fun onDiscoveryStopped(serviceType: String) {
handler.post { completeStop(this) }
}
}

discoveryListener = listener
try {
nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener)
} catch (_: SecurityException) {
discoveryListener = null
status = "error"
errorCode = "permissionDenied"
releaseMulticastLock()
emitSnapshot()
} catch (_: RuntimeException) {
discoveryListener = null
status = "error"
errorCode = "failed"
releaseMulticastLock()
emitSnapshot()
}
}

private fun stopDiscovery() {
generation += 1
val listener = discoveryListener
isResolving = false
pendingServices.clear()
activeServiceNames.clear()
hosts.clear()
if (listener == null) {
releaseMulticastLock()
} else {
requestStop(listener)
}
}

private fun requestStop(listener: NsdManager.DiscoveryListener) {
if (discoveryListener !== listener) return

try {
manager?.stopServiceDiscovery(listener)
} catch (_: IllegalArgumentException) {
completeStop(listener)
} catch (_: SecurityException) {
completeStop(listener)
} catch (_: RuntimeException) {
retryStop(listener)
}
}

private fun retryStop(listener: NsdManager.DiscoveryListener) {
if (discoveryListener !== listener) return
handler.postDelayed({ requestStop(listener) }, 500)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This retry loop has no attempt counter and no deadline, and the wedged state it can reach is unrecoverable within the process.

The cycle is onStopDiscoveryFailed (line 156) → retryStoppostDelayed(requestStop, 500) → and requestStop's RuntimeException catch routes straight back to retryStop (line 206). Nothing bounds it.

What makes that costly is that completeStop (line 215) is the only nominal path that does both of these:

    discoveryListener = null
    releaseMulticastLock()

So while the loop spins: discoveryListener stays non-null, which means startDiscovery's discoveryListener != null guard makes every subsequent start a no-op that only re-emits the stale snapshot — discovery is off for good. And the MulticastLock is held the whole time, which is a battery cost and can interfere with other apps' multicast reception.

This needs a genuinely broken NSD stack to trigger, which does happen on some OEM builds, but the failure mode is silent and permanent rather than degraded. Capping the attempts (or a bounded overall deadline) and calling completeStop on exhaustion would bound the damage to "discovery stopped" instead of "discovery stopped and the lock is still held."

}

private fun completeStop(listener: NsdManager.DiscoveryListener) {
if (discoveryListener !== listener) return
discoveryListener = null
releaseMulticastLock()
if (isObserved && isInForeground) {
startDiscovery()
}
}

// The executor-based resolver starts at API 34; this callback is required by the API 24 floor.
@Suppress("DEPRECATION")
private fun resolveNext(currentGeneration: Int) {
if (isResolving || currentGeneration != generation) return

val serviceInfo = pendingServices.pollFirst() ?: return
val serviceName = serviceInfo.serviceName ?: run {
resolveNext(currentGeneration)
return
}
if (!activeServiceNames.contains(serviceName)) {
resolveNext(currentGeneration)
return
}

val nsdManager = manager ?: return
isResolving = true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isResolving is set here but has no timeout backstop — it's cleared only inside onResolveFailed (248), onServiceResolved (256), and the two exception catches (279, 284).

The deprecated callback form of resolveService is documented to occasionally drop calls without invoking either callback. If that happens, isResolving stays true and resolveNext's guard at line 227 makes every later call return immediately, so the serial queue stalls and newly-found services enqueue but never resolve. The user sees a host list that just stops updating.

To be fair to the current code, this does self-heal: startDiscovery resets pendingServices and isResolving at lines 105-106, so a background/foreground cycle recovers it. That caps the severity at "stale until the app is backgrounded," not a permanent wedge. Still, a bounded postDelayed that clears isResolving and calls resolveNext if neither callback has fired would keep discovery live without the user having to do anything.

try {
nsdManager.resolveService(
serviceInfo,
object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, error: Int) {
handler.post {
if (currentGeneration != generation) return@post
isResolving = false
resolveNext(currentGeneration)
}
}

override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
handler.post {
if (currentGeneration != generation) return@post
isResolving = false
val resolvedName = serviceInfo.serviceName ?: serviceName
val host = serviceInfo.host?.hostAddress
val port = serviceInfo.port
if (
activeServiceNames.contains(resolvedName) &&
!host.isNullOrBlank() &&
port in 1..65_535
) {
hosts[resolvedName] = DiscoveredDaemon(
id = resolvedName,
name = resolvedName,
host = host,
port = port
)
emitSnapshot()
}
resolveNext(currentGeneration)
}
}
}
)
} catch (_: SecurityException) {
isResolving = false
status = "error"
errorCode = "permissionDenied"
emitSnapshot()
} catch (_: RuntimeException) {
isResolving = false
resolveNext(currentGeneration)
}
}

private fun acquireMulticastLock(context: Context) {
val wifiManager = context.getSystemService(Context.WIFI_SERVICE) as? WifiManager ?: return
multicastLock = wifiManager.createMulticastLock(MULTICAST_LOCK_TAG).apply {
setReferenceCounted(false)
acquire()
}
}

private fun releaseMulticastLock() {
multicastLock?.let { lock ->
if (lock.isHeld) {
lock.release()
}
}
multicastLock = null
}

private fun emitSnapshot() {
val serializedHosts = ArrayList(
hosts.values
.sortedBy { it.name.lowercase() }
.map { host ->
Bundle().apply {
putString("id", host.id)
putString("name", host.name)
putString("host", host.host)
putInt("port", host.port)
}
}
)
val payload = Bundle().apply {
putString("status", status)
putParcelableArrayList("hosts", serializedHosts)
errorCode?.let { putString("error", it) }
}
sendEvent(EVENT_NAME, payload)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"platforms": ["apple", "android"],
"apple": {
"modules": ["LinkCodeDaemonDiscoveryModule"]
},
"android": {
"modules": ["expo.modules.linkcodedaemondiscovery.LinkCodeDaemonDiscoveryModule"]
}
}
8 changes: 8 additions & 0 deletions apps/mobile/modules/linkcode-daemon-discovery/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export type {
DaemonDiscoveryError,
DaemonDiscoverySnapshot,
DaemonDiscoveryStatus,
LinkCodeDaemonDiscoveryModuleEvents,
NativeDiscoveredDaemon,
} from './src/LinkCodeDaemonDiscovery.types';
export { default } from './src/LinkCodeDaemonDiscoveryModule';
Loading
Loading