Skip to content

Commit 052beeb

Browse files
author
Hussein Habibi Juybari
committed
Implement process locking and port binding logic in LogTap to prevent multiple instances
1 parent d315320 commit 052beeb

1 file changed

Lines changed: 169 additions & 67 deletions

File tree

  • LogTap/src/main/java/com/github/husseinhj/logtap

LogTap/src/main/java/com/github/husseinhj/logtap/LogTap.kt

Lines changed: 169 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@ import android.annotation.SuppressLint
44
import android.util.Log
55
import java.time.Duration
66
import kotlinx.coroutines.*
7+
import kotlinx.coroutines.sync.Mutex
8+
import kotlinx.coroutines.sync.withLock
9+
import kotlinx.coroutines.CoroutineExceptionHandler
710
import java.net.InetAddress
11+
import java.net.ServerSocket
812
import io.ktor.server.cio.CIO
913
import android.content.Context
1014
import android.net.ConnectivityManager
1115
import android.net.NetworkCapabilities
1216
import io.ktor.server.engine.*
17+
import io.ktor.server.engine.applicationEngineEnvironment
1318
import io.ktor.websocket.Frame
1419
import io.ktor.http.ContentType
1520
import io.ktor.server.routing.*
@@ -23,6 +28,10 @@ import io.ktor.server.plugins.contentnegotiation.*
2328
import java.net.Inet4Address
2429
import kotlin.time.toKotlinDuration
2530

31+
import java.io.RandomAccessFile
32+
import java.nio.channels.FileLock
33+
import java.nio.channels.FileChannel
34+
2635
private const val TAG = "LogTap"
2736

2837
object LogTap {
@@ -36,6 +45,12 @@ object LogTap {
3645

3746
@Volatile private var server: ApplicationEngine? = null
3847
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
48+
private val bgErrorHandler = CoroutineExceptionHandler { _, t -> Log.e(TAG, "LogTap background error", t) }
49+
private val engineParentCtx = SupervisorJob() + Dispatchers.IO + bgErrorHandler
50+
private val startMutex = Mutex()
51+
@Volatile private var resolvedPort: Int? = null
52+
53+
@Volatile private var processLock: FileLock? = null
3954

4055
internal lateinit var store: LogStore
4156
private set
@@ -44,93 +59,175 @@ object LogTap {
4459
ignoreUnknownKeys = true
4560
}
4661

62+
private fun canBind(port: Int): Boolean {
63+
if (port == 0) return true
64+
return try {
65+
ServerSocket().use { sock ->
66+
sock.reuseAddress = true
67+
sock.bind(java.net.InetSocketAddress("0.0.0.0", port))
68+
}
69+
true
70+
} catch (_: Throwable) {
71+
false
72+
}
73+
}
74+
75+
private fun tryAcquireProcessLock(context: Context): Boolean {
76+
return try {
77+
val dir = context.filesDir
78+
val lockFile = java.io.File(dir, "logtap.lock")
79+
if (!lockFile.exists()) lockFile.createNewFile()
80+
val channel: FileChannel = RandomAccessFile(lockFile, "rw").channel
81+
val lock = channel.tryLock()
82+
if (lock != null) {
83+
processLock = lock
84+
true
85+
} else {
86+
false
87+
}
88+
} catch (t: Throwable) {
89+
// If locking fails for any reason, assume another instance and do not start
90+
Log.w(TAG, "Could not acquire LogTap process lock; skipping start", t)
91+
false
92+
}
93+
}
94+
4795
@Synchronized
4896
fun start(context: Context, config: Config = Config()) {
4997
// Debug-only guard
5098
if (!config.enableOnRelease && !isDebuggable(context)) {
5199
Log.i(TAG, "Not debuggable; LogTap disabled.")
52100
return
53101
}
54-
if (server != null) return
102+
103+
// Ensure only one LogTap server instance per process
104+
if (!tryAcquireProcessLock(context)) {
105+
Log.i(TAG, "Another LogTap instance appears to be running; skipping start.")
106+
return
107+
}
55108

56109
store = LogStore(config.capacity)
57110

58111
// Start engine off the main thread and catch startup errors
59-
appScope.launch {
112+
appScope.launch(bgErrorHandler) {
113+
startMutex.withLock {
114+
if (server != null) return@withLock
115+
try {
116+
val engine = startServerWithFallback(config) // returns a STARTED engine
117+
server = engine
118+
119+
val port = engine.resolvedConnectors().first().port
120+
resolvedPort = port
121+
122+
val ip = getDeviceIp(context)
123+
LogTapLogger.i("LogTap server ready at http://$ip:$port/")
124+
} catch (ce: CancellationException) {
125+
// engine/coroutine cancelled ⇒ do not crash app
126+
Log.w(TAG, "LogTap start cancelled", ce)
127+
} catch (t: Throwable) {
128+
// bind failures / CIO init errors, etc.
129+
Log.e(TAG, "Failed to start LogTap", t)
130+
}
131+
}
132+
}
133+
}
134+
135+
private fun startServerWithFallback(config: Config): ApplicationEngine {
136+
val candidates = mutableListOf<Int>()
137+
if (config.port != 0) {
138+
candidates += config.port
139+
candidates += (config.port + 1)..(config.port + 20)
140+
}
141+
candidates += 0 // OS-assigned as last resort
142+
143+
var lastError: Throwable? = null
144+
for (p in candidates) {
145+
if (!canBind(p)) continue
60146
try {
61-
val engine = embeddedServer(
62-
CIO,
63-
port = config.port
64-
) {
65-
install(ContentNegotiation) { json(LogTap.json) }
66-
install(WebSockets) { pingPeriod = Duration.ofSeconds(30); masking = false }
67-
68-
routing {
69-
get("/") { call.respondText(Resources.indexHtml, ContentType.Text.Html) }
70-
get("/app.css") { call.respondText(Resources.appCss, ContentType.Text.CSS) }
71-
get("/app.js") { call.respondText(Resources.appJs, ContentType.Application.JavaScript) }
72-
73-
get("/logs") {
74-
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 200
75-
call.respond(LogTapEvents.snapshot(limit))
76-
}
77-
get("/api/logs") {
78-
val sinceId = call.request.queryParameters["sinceId"]?.toLongOrNull()
79-
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 500
80-
call.respond(store.snapshot(sinceId, limit))
81-
}
82-
post("/api/clear") {
83-
store.clear()
84-
call.respondText("ok")
85-
}
86-
webSocket("/ws") {
87-
val session = this
88-
val collector = CoroutineScope(Dispatchers.IO).launch {
89-
store.stream.collect { ev: LogEvent ->
90-
session.send(Frame.Text( json.encodeToString(LogEvent.serializer(), ev)))
91-
}
92-
}
93-
val backlog = LogTapEvents.snapshot(200)
94-
for (ev in backlog) {
95-
send(Frame.Text( json.encodeToString(LogEvent.serializer(), ev)))
96-
}
147+
val eng = buildServer(p, config)
148+
// Start may throw BindException from CIO internal coroutine
149+
eng.start(wait = false)
150+
return eng
151+
} catch (e: java.net.BindException) {
152+
lastError = e
153+
try {
154+
// Ensure any partially started engine is stopped
155+
server?.stop(200, 500)
156+
} catch (_: Throwable) {}
157+
// Try next port candidate
158+
} catch (t: Throwable) {
159+
// If the throwable root cause is BindException, retry on next port
160+
val cause = t.cause
161+
if (cause is java.net.BindException) {
162+
lastError = cause
163+
try { server?.stop(200, 500) } catch (_: Throwable) {}
164+
continue
165+
}
166+
// Other failures are fatal for startup; rethrow
167+
throw t
168+
}
169+
}
170+
throw lastError ?: IllegalStateException("No free port found for LogTap")
171+
}
172+
173+
private fun buildServer(port: Int, config: Config): ApplicationEngine {
174+
val env = applicationEngineEnvironment {
175+
parentCoroutineContext = engineParentCtx
176+
connector {
177+
host = "0.0.0.0"
178+
this.port = port
179+
}
180+
module {
181+
install(ContentNegotiation) { json(LogTap.json) }
182+
install(WebSockets) { pingPeriod = Duration.ofSeconds(30); masking = false }
97183

98-
// Then live-stream new events
99-
val job = launch(Dispatchers.Default) {
100-
LogTapEvents.updates().collect { ev ->
101-
try {
102-
send(Frame.Text( json.encodeToString(LogEvent.serializer(), ev)))
103-
}
104-
catch (_: Throwable) { cancel() } // client likely disconnected
105-
}
184+
routing {
185+
get("/") { call.respondText(Resources.indexHtml, ContentType.Text.Html) }
186+
get("/app.css") { call.respondText(Resources.appCss, ContentType.Text.CSS) }
187+
get("/app.js") { call.respondText(Resources.appJs, ContentType.Application.JavaScript) }
188+
189+
get("/logs") {
190+
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 200
191+
call.respond(LogTapEvents.snapshot(limit))
192+
}
193+
get("/api/logs") {
194+
val sinceId = call.request.queryParameters["sinceId"]?.toLongOrNull()
195+
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 500
196+
call.respond(store.snapshot(sinceId, limit))
197+
}
198+
post("/api/clear") {
199+
store.clear()
200+
call.respondText("ok")
201+
}
202+
webSocket("/ws") {
203+
val session = this
204+
val collector = CoroutineScope(Dispatchers.IO).launch {
205+
store.stream.collect { ev: LogEvent ->
206+
session.send(Frame.Text(json.encodeToString(LogEvent.serializer(), ev)))
106207
}
107-
try {
108-
// Drain incoming until client closes
109-
for (frame in incoming) {
110-
if (frame is Frame.Close) break
111-
}
112-
} finally {
113-
job.cancel()
114-
collector.cancel()
208+
}
209+
val backlog = LogTapEvents.snapshot(200)
210+
for (ev in backlog) {
211+
send(Frame.Text(json.encodeToString(LogEvent.serializer(), ev)))
212+
}
213+
val job = launch(Dispatchers.Default) {
214+
LogTapEvents.updates().collect { ev ->
215+
try {
216+
send(Frame.Text(json.encodeToString(LogEvent.serializer(), ev)))
217+
} catch (_: Throwable) { cancel() }
115218
}
116219
}
117-
get("/about") { call.respondText(Resources.aboutHtml) }
220+
try {
221+
for (frame in incoming) { if (frame is Frame.Close) break }
222+
} finally {
223+
job.cancel(); collector.cancel()
224+
}
118225
}
226+
get("/about") { call.respondText(Resources.aboutHtml) }
119227
}
120-
121-
engine.start(wait = false)
122-
server = engine
123-
124-
val ip = getDeviceIp(context)
125-
LogTapLogger.i("LogTap server ready at http://$ip:${config.port}/")
126-
} catch (ce: CancellationException) {
127-
// engine/coroutine cancelled ⇒ do not crash app
128-
Log.w(TAG, "LogTap start cancelled", ce)
129-
} catch (t: Throwable) {
130-
// bind failures / CIO init errors, etc.
131-
Log.e(TAG, "Failed to start LogTap", t)
132228
}
133229
}
230+
return embeddedServer(CIO, env)
134231
}
135232

136233
@Synchronized
@@ -140,6 +237,11 @@ object LogTap {
140237
} catch (t: Throwable) {
141238
Log.w(TAG, "error stopping server", t)
142239
} finally {
240+
resolvedPort = null
241+
try {
242+
processLock?.release()
243+
} catch (_: Throwable) {}
244+
processLock = null
143245
server = null
144246
}
145247
}

0 commit comments

Comments
 (0)