From 8fbfc0ecc832adb0f300359ebedc73cd82035968 Mon Sep 17 00:00:00 2001 From: znzryb Date: Mon, 26 Jan 2026 00:24:44 +0800 Subject: [PATCH 1/3] Fix: make Competitive Companion listener robust on IPv4/IPv6 localhost --- .DS_Store | Bin 0 -> 6148 bytes .gitignore | 3 + gradle.properties | 2 +- .../gather/base/ProblemGatheringBridge.kt | 35 ++++- .../autocp/gather/base/localServer.kt | 139 ++++++++++++++++-- 5 files changed, 158 insertions(+), 21 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..eb5bacc3a72581fcbeb2540698fad5289d2097f7 GIT binary patch literal 6148 zcmeH~%Wl&^6o$_vY2!3pf&i%-Bui|o5L$$U*n~6@L;@<+1r~rpoLI%GYlqlrgrZ1U z!#nT_YJW_A^ zH+K~Hanfjf7lmSJW3z0Pt*Z6bc^^%kq@PUUR)74OTSrk8xaal0`_lKOz1ofaC`|f( z=#8a=KlEVo=9M1~qiHLegu__+I_ZE_u`0dV_H4G>XzbYc8oToyd-hwEX-^NMxz z_ML}^y_4WHj6TV0CV_90OqZ3O%V$)y6`eWbFo?oq^s-0oHx;VWF^#EDLoo8tI-!JO z?OO*{f@jlNU7~$@0)o%!F?HxE9YE`JnrAG2 z2Z(ht1CL?}AAO3>@m@teLrPy+t1+^FD=`do^+0rvlO) zkZjX4=nBf>ibnZ5#h{Z&N+DJi!)xtx#q=_>6yZ0fL2gA%O@AKHT$|K;VO7^!8UsVX z5cuB+@b|$+W^F26q$)ohDC7|UT0*x>sPm5l``AjGN*AeO1WBZ>(9~6^BZf%b(Qhle zrqV^KsXGaE_z)_xP$v{2qho$snv-azYDz=E5XcfJrp+4f|DDz6|E!Z~83KmDKSe;4 z+D^NLC8@o2X>q*Q+Q{#aIkDX$Rq294ZO5|0Tk$rsOc=Ad0&OZ?q>3IS^CKWJn8FbF HqXd2daRCm0 literal 0 HcmV?d00001 diff --git a/.gitignore b/.gitignore index 8582ee9e..d1a0cf1a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ src/main/resources/messages/secrets.properties # these file/folders are to be tracked in docs related branches docs .nojekyll + +# ignore other extension related +competitive-companion diff --git a/gradle.properties b/gradle.properties index 559e1a89..20683200 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ pluginName=AutoCp pluginGroup=com.github.pushpavel.autocp pluginRepositoryUrl = https://github.com/Pushpavel/AutoCp -pluginVersion=0.9.0 +pluginVersion=0.9.0-fix-ipv6-log-more # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html # for insight into build numbers and IntelliJ Platform versions. pluginSinceBuild=243 diff --git a/src/main/kotlin/com/github/pushpavel/autocp/gather/base/ProblemGatheringBridge.kt b/src/main/kotlin/com/github/pushpavel/autocp/gather/base/ProblemGatheringBridge.kt index 5b24bc14..567ea8f4 100644 --- a/src/main/kotlin/com/github/pushpavel/autocp/gather/base/ProblemGatheringBridge.kt +++ b/src/main/kotlin/com/github/pushpavel/autocp/gather/base/ProblemGatheringBridge.kt @@ -11,10 +11,13 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.startup.ProjectActivity import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json import java.net.SocketTimeoutException @@ -50,22 +53,37 @@ class ProblemGatheringBridge : Disposable { // initialize server serverJob = scope.launch { try { - val serverSocket = openServerSocketAsync( + val serverSockets = openServerSocketsAsync( R.others.competitiveCompanionPorts ).await() ?: throw ProblemGatheringErr.AllPortsTakenErr(R.others.competitiveCompanionPorts) - serverSocket.use { + val messages = Channel(Channel.RENDEZVOUS) + try { + serverSockets.forEach { socket -> + // keep accept responsive to cancellation + socket.soTimeout = 1000 + launch { + while (isActive) { + try { + val message = listenForMessageAsync(socket, 1000).await() ?: continue + messages.send(message) + } catch (_: SocketTimeoutException) { + // keep looping + } + } + } + } + while (isActive) { try { coroutineScope { - val message = listenForMessageAsync( - serverSocket, R.others.problemGatheringTimeoutMillis - ).await() ?: return@coroutineScope - + val message = withTimeout(R.others.problemGatheringTimeoutMillis.toLong()) { + messages.receive() + } val json = serializer.decodeFromString(message) BatchProcessor.onJsonReceived(json) } - } catch (e: SocketTimeoutException) { + } catch (_: TimeoutCancellationException) { BatchProcessor.interruptBatch(ProblemGatheringErr.TimeoutErr) } catch (e: SerializationException) { BatchProcessor.interruptBatch(ProblemGatheringErr.JsonErr) @@ -73,6 +91,9 @@ class ProblemGatheringBridge : Disposable { while (BatchProcessor.isCurrentBatchBlocking()) delay(100) } + } finally { + messages.close() + serverSockets.forEach { kotlin.runCatching { it.close() } } } } catch (e: ProblemGatheringErr) { R.notify.problemGatheringErr(e) diff --git a/src/main/kotlin/com/github/pushpavel/autocp/gather/base/localServer.kt b/src/main/kotlin/com/github/pushpavel/autocp/gather/base/localServer.kt index 37163812..92377ef4 100644 --- a/src/main/kotlin/com/github/pushpavel/autocp/gather/base/localServer.kt +++ b/src/main/kotlin/com/github/pushpavel/autocp/gather/base/localServer.kt @@ -6,14 +6,25 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import java.io.BufferedReader +import java.io.BufferedInputStream +import java.io.ByteArrayOutputStream import java.io.InputStream import java.io.InputStreamReader +import java.io.OutputStreamWriter import java.net.InetAddress import java.net.ServerSocket import java.net.SocketException +import java.net.SocketTimeoutException -fun CoroutineScope.openServerSocketAsync(ports: List) = async(Dispatchers.IO) { +/** + * Try to bind server sockets on loopback addresses for a port from [ports]. + * + * Competitive Companion always sends to `http://localhost:/`. + * On some systems `localhost` can resolve to IPv6 first (`::1`), while AutoCp may only be bound to IPv4 (`127.0.0.1`) + * (or vice versa). To make this robust, we try to bind both loopback addresses on the same port (if the OS allows it). + */ +fun CoroutineScope.openServerSocketsAsync(ports: List) = async(Dispatchers.IO) { val log = Logger.getInstance("${R.keys.pluginId} openServerSocketAsync") var portIndex = 0 @@ -22,13 +33,32 @@ fun CoroutineScope.openServerSocketAsync(ports: List) = async(Dispatchers.I if (portIndex != 0) log.info("Port ${ports[portIndex - 1]} taken. retrying with Port ${ports[portIndex]}") + val port = ports[portIndex] + val sockets = mutableListOf() try { - // successfully starting the server - return@async ServerSocket(ports[portIndex], 50, InetAddress.getByName("localhost")) - } catch (e: SocketException) { - // failed retrying with next port - portIndex++ + // Prefer IPv4 loopback to keep current behavior, then also try IPv6 loopback. + runCatching { sockets.add(ServerSocket(port, 50, InetAddress.getByName("127.0.0.1"))) } + runCatching { sockets.add(ServerSocket(port, 50, InetAddress.getByName("::1"))) } + + if (sockets.isNotEmpty()) { + runCatching { + val bound = sockets.joinToString(", ") { s -> s.inetAddress.hostAddress + ":" + s.localPort } + log.info("Listening for Competitive Companion on $bound") + } + return@async sockets + } + } catch (_: SocketException) { + // ignore and retry next port + } catch (_: Exception) { + // ignore and retry next port + } finally { + if (sockets.isEmpty()) { + // Ensure we don't leak partially opened sockets when retrying. + sockets.forEach { runCatching { it.close() } } + } } + + portIndex++ } if (portIndex != 0) @@ -42,15 +72,98 @@ fun CoroutineScope.openServerSocketAsync(ports: List) = async(Dispatchers.I */ fun CoroutineScope.listenForMessageAsync(serverSocket: ServerSocket, timeout: Int) = async(Dispatchers.IO) { serverSocket.soTimeout = timeout - serverSocket.accept().use { - val inputStream = it.getInputStream() - val request = readFromStream(inputStream) - val strings = request.split("\n\n".toPattern(), 2).toTypedArray() + try { + serverSocket.accept().use { + // ServerSocket.soTimeout only affects accept(). Ensure reads don't block forever. + it.soTimeout = timeout + + val inputStream = BufferedInputStream(it.getInputStream()) + val body = readHttpBody(inputStream) + + // Competitive Companion doesn't need the response body, but responding avoids clients + // keeping the connection open (which would make our old "read-until-EOF" logic hang). + runCatching { + OutputStreamWriter(it.getOutputStream(), Charsets.US_ASCII).use { writer -> + writer.write("HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n") + writer.flush() + } + } - if (strings.size > 1) - return@async strings[1] + if (!body.isNullOrEmpty()) return@async body + } + return@async null + } catch (_: SocketTimeoutException) { + // Normal: no incoming connection within timeout. + return@async null + } catch (_: SocketException) { + // Can happen on shutdown / dynamic plugin unload. + return@async null } - return@async null +} + +/** + * Reads an HTTP request body without waiting for EOF. + * Supports both CRLF and LF separators, and uses Content-Length when available. + */ +private fun readHttpBody(inputStream: BufferedInputStream): String? { + val headerBytes = ByteArrayOutputStream() + var prev = -1 + var curr: Int + var seenLfLf = false + var seenCrlfCrlf = false + + // Read headers up to a sane limit + val maxHeaderBytes = 64 * 1024 + while (headerBytes.size() < maxHeaderBytes) { + curr = inputStream.read() + if (curr == -1) break + headerBytes.write(curr) + + // detect \n\n + if (prev == '\n'.code && curr == '\n'.code) { + seenLfLf = true + break + } + // detect \r\n\r\n + val hb = headerBytes.toByteArray() + val n = hb.size + if (n >= 4 && + hb[n - 4] == '\r'.code.toByte() && + hb[n - 3] == '\n'.code.toByte() && + hb[n - 2] == '\r'.code.toByte() && + hb[n - 1] == '\n'.code.toByte() + ) { + seenCrlfCrlf = true + break + } + + prev = curr + } + + if (!seenLfLf && !seenCrlfCrlf) { + // couldn't find header terminator; fall back to old behavior + return null + } + + val headers = headerBytes.toString(Charsets.ISO_8859_1) + val contentLength = Regex("(?im)^Content-Length:\\s*(\\d+)\\s*$") + .find(headers) + ?.groupValues + ?.getOrNull(1) + ?.toIntOrNull() + ?: return null + + if (contentLength <= 0) return null + + val bodyBytes = ByteArray(contentLength) + var off = 0 + while (off < contentLength) { + val read = inputStream.read(bodyBytes, off, contentLength - off) + if (read <= 0) break + off += read + } + if (off <= 0) return null + return bodyBytes.copyOf(off).toString(Charsets.UTF_8) } /** From 11e7151c614112de87f0bcb14ad1490b33c0b73a Mon Sep 17 00:00:00 2001 From: znzryb Date: Mon, 26 Jan 2026 00:33:56 +0800 Subject: [PATCH 2/3] Delete DS_Store and version in gradle properties --- .DS_Store | Bin 6148 -> 0 bytes .gitignore | 3 +++ gradle.properties | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index eb5bacc3a72581fcbeb2540698fad5289d2097f7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~%Wl&^6o$_vY2!3pf&i%-Bui|o5L$$U*n~6@L;@<+1r~rpoLI%GYlqlrgrZ1U z!#nT_YJW_A^ zH+K~Hanfjf7lmSJW3z0Pt*Z6bc^^%kq@PUUR)74OTSrk8xaal0`_lKOz1ofaC`|f( z=#8a=KlEVo=9M1~qiHLegu__+I_ZE_u`0dV_H4G>XzbYc8oToyd-hwEX-^NMxz z_ML}^y_4WHj6TV0CV_90OqZ3O%V$)y6`eWbFo?oq^s-0oHx;VWF^#EDLoo8tI-!JO z?OO*{f@jlNU7~$@0)o%!F?HxE9YE`JnrAG2 z2Z(ht1CL?}AAO3>@m@teLrPy+t1+^FD=`do^+0rvlO) zkZjX4=nBf>ibnZ5#h{Z&N+DJi!)xtx#q=_>6yZ0fL2gA%O@AKHT$|K;VO7^!8UsVX z5cuB+@b|$+W^F26q$)ohDC7|UT0*x>sPm5l``AjGN*AeO1WBZ>(9~6^BZf%b(Qhle zrqV^KsXGaE_z)_xP$v{2qho$snv-azYDz=E5XcfJrp+4f|DDz6|E!Z~83KmDKSe;4 z+D^NLC8@o2X>q*Q+Q{#aIkDX$Rq294ZO5|0Tk$rsOc=Ad0&OZ?q>3IS^CKWJn8FbF HqXd2daRCm0 diff --git a/.gitignore b/.gitignore index d1a0cf1a..d93c72d4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ docs # ignore other extension related competitive-companion + +# ignore MacOS related files +*.DS_Store \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 20683200..559e1a89 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ pluginName=AutoCp pluginGroup=com.github.pushpavel.autocp pluginRepositoryUrl = https://github.com/Pushpavel/AutoCp -pluginVersion=0.9.0-fix-ipv6-log-more +pluginVersion=0.9.0 # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html # for insight into build numbers and IntelliJ Platform versions. pluginSinceBuild=243 From f66542246560b612ea69718c4e0ca8174474aee2 Mon Sep 17 00:00:00 2001 From: znzryb Date: Mon, 26 Jan 2026 21:43:02 +0800 Subject: [PATCH 3/3] delete http 200 code reply --- .../github/pushpavel/autocp/gather/base/localServer.kt | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/main/kotlin/com/github/pushpavel/autocp/gather/base/localServer.kt b/src/main/kotlin/com/github/pushpavel/autocp/gather/base/localServer.kt index 92377ef4..f4ec8e51 100644 --- a/src/main/kotlin/com/github/pushpavel/autocp/gather/base/localServer.kt +++ b/src/main/kotlin/com/github/pushpavel/autocp/gather/base/localServer.kt @@ -10,7 +10,6 @@ import java.io.BufferedInputStream import java.io.ByteArrayOutputStream import java.io.InputStream import java.io.InputStreamReader -import java.io.OutputStreamWriter import java.net.InetAddress import java.net.ServerSocket import java.net.SocketException @@ -80,15 +79,6 @@ fun CoroutineScope.listenForMessageAsync(serverSocket: ServerSocket, timeout: In val inputStream = BufferedInputStream(it.getInputStream()) val body = readHttpBody(inputStream) - // Competitive Companion doesn't need the response body, but responding avoids clients - // keeping the connection open (which would make our old "read-until-EOF" logic hang). - runCatching { - OutputStreamWriter(it.getOutputStream(), Charsets.US_ASCII).use { writer -> - writer.write("HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n") - writer.flush() - } - } - if (!body.isNullOrEmpty()) return@async body } return@async null