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.application.Application
import io.ktor.server.cio.CIO import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.connector
import io.ktor.server.engine.embeddedServer import io.ktor.server.engine.embeddedServer
import io.ktor.server.application.serverConfig
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withTimeout 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). * 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]. * 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( internal suspend fun startServerWithPortFallback(
host: String, host: String,
@ -47,8 +53,25 @@ internal suspend fun startServerWithPortFallback(
val candidates = listOf(preferredPort, 0).distinct() val candidates = listOf(preferredPort, 0).distinct()
var lastFailure: Throwable? = null 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) { 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 { try {
engine.start(wait = false) engine.start(wait = false)
val boundPort = withTimeout(5.seconds) { engine.engine.resolvedConnectors().first().port } val boundPort = withTimeout(5.seconds) { engine.engine.resolvedConnectors().first().port }