@@ -4,12 +4,17 @@ import android.annotation.SuppressLint
44import android.util.Log
55import java.time.Duration
66import kotlinx.coroutines.*
7+ import kotlinx.coroutines.sync.Mutex
8+ import kotlinx.coroutines.sync.withLock
9+ import kotlinx.coroutines.CoroutineExceptionHandler
710import java.net.InetAddress
11+ import java.net.ServerSocket
812import io.ktor.server.cio.CIO
913import android.content.Context
1014import android.net.ConnectivityManager
1115import android.net.NetworkCapabilities
1216import io.ktor.server.engine.*
17+ import io.ktor.server.engine.applicationEngineEnvironment
1318import io.ktor.websocket.Frame
1419import io.ktor.http.ContentType
1520import io.ktor.server.routing.*
@@ -23,6 +28,10 @@ import io.ktor.server.plugins.contentnegotiation.*
2328import java.net.Inet4Address
2429import kotlin.time.toKotlinDuration
2530
31+ import java.io.RandomAccessFile
32+ import java.nio.channels.FileLock
33+ import java.nio.channels.FileChannel
34+
2635private const val TAG = " LogTap"
2736
2837object 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