fix(server): don't crash when the proxy port is still in use

CIO reports a failed bind from its accept coroutine; unhandled, that
exception terminates the process on Kotlin/Native before the port fallback
can run, so relaunching iOS shortly after playback crashed (EADDRINUSE).
Contain it with a parent CoroutineExceptionHandler and enable reuseAddress
so the preferred port can be rebound while old connections are in TIME_WAIT.
This commit is contained in:
Kingkor Roy Tirtho 2026-10-26 13:40:00 +06:00
parent 7eb56c8593
commit 99ba08501b

View File

@ -21,7 +21,10 @@ package org.spotube.spotube.core.server
import io.ktor.server.application.Application
import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.connector
import io.ktor.server.engine.embeddedServer
import io.ktor.server.application.serverConfig
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withTimeout
@ -37,6 +40,9 @@ internal class BoundServer(val engine: EmbeddedServer<*, *>, val port: Int, val
* preferred port can't be bound, retry on an OS-assigned free port (port 0).
*
* The caller must read [BoundServer.port] instead of assuming [preferredPort].
*
* `reuseAddress` lets a restarted app rebind its old port while connections from the previous
* run (e.g. the player's) are still in TIME_WAIT.
*/
internal suspend fun startServerWithPortFallback(
host: String,
@ -47,8 +53,25 @@ internal suspend fun startServerWithPortFallback(
val candidates = listOf(preferredPort, 0).distinct()
var lastFailure: Throwable? = null
// CIO reports a failed bind from its accept coroutine. Unhandled, that exception kills the
// process on Kotlin/Native (iOS) before the fallback below gets to run; the failure still
// reaches us through resolvedConnectors(), so it only needs to be contained here.
val swallowBindFailure = CoroutineExceptionHandler { _, _ -> }
for (candidate in candidates) {
val engine = embeddedServer(factory = CIO, host = host, port = candidate, module = module)
val engine = embeddedServer(
factory = CIO,
rootConfig = serverConfig {
parentCoroutineContext = swallowBindFailure
module { module() }
},
configure = {
connector {
this.host = host
this.port = candidate
}
reuseAddress = true
})
try {
engine.start(wait = false)
val boundPort = withTimeout(5.seconds) { engine.engine.resolvedConnectors().first().port }