From 522c6e2ba9230243cc4857571d1d417ff49121c5 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:33:38 -0400 Subject: [PATCH 01/11] test: pin peer readiness candidate --- .depot/workflows/build-apk.yml | 4 ++-- .github/workflows/build.yml | 4 ++-- .github/workflows/ci.yml | 4 ++-- .github/workflows/preview.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- buildScript/lib/olcrtc.sh | 18 ++++++++---------- 6 files changed, 18 insertions(+), 20 deletions(-) diff --git a/.depot/workflows/build-apk.yml b/.depot/workflows/build-apk.yml index f7ce2aac3..104cdf514 100644 --- a/.depot/workflows/build-apk.yml +++ b/.depot/workflows/build-apk.yml @@ -13,8 +13,8 @@ env: MDVPN_REF: android-vpnservice-protect-hook MDVPN_COMMIT: d481d72d4b86783a87d536c214d2c68cc4e9320e NAIVE_VERSION: v149.0.7827.114-1 - OLCRTC_REPO: https://github.com/openlibrecommunity/olcrtc.git - OLCRTC_COMMIT: 58df8899c1a12cab22282448d2e7fc25e175822b + OLCRTC_REPO: https://github.com/hawkff/olcrtc.git + OLCRTC_COMMIT: ad5cc1e3d60b657b15ccd26f8db91395bf9630d0 jobs: build-apk: name: Build OSS Debug APK diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5c3277257..54ad04d3c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,8 +11,8 @@ env: MDVPN_REF: android-vpnservice-protect-hook MDVPN_COMMIT: d481d72d4b86783a87d536c214d2c68cc4e9320e NAIVE_VERSION: v149.0.7827.114-1 - OLCRTC_REPO: https://github.com/openlibrecommunity/olcrtc.git - OLCRTC_COMMIT: 1255cf8248ee4a52cb355ceb8cf72281a5f1affd + OLCRTC_REPO: https://github.com/hawkff/olcrtc.git + OLCRTC_COMMIT: ad5cc1e3d60b657b15ccd26f8db91395bf9630d0 permissions: contents: read jobs: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3401a487c..3a66042b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,8 +6,8 @@ env: MDVPN_REF: android-vpnservice-protect-hook MDVPN_COMMIT: d481d72d4b86783a87d536c214d2c68cc4e9320e NAIVE_VERSION: v149.0.7827.114-1 - OLCRTC_REPO: https://github.com/openlibrecommunity/olcrtc.git - OLCRTC_COMMIT: 1255cf8248ee4a52cb355ceb8cf72281a5f1affd + OLCRTC_REPO: https://github.com/hawkff/olcrtc.git + OLCRTC_COMMIT: ad5cc1e3d60b657b15ccd26f8db91395bf9630d0 permissions: contents: read jobs: diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 760b28af3..60e3f5282 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -7,8 +7,8 @@ env: MDVPN_REF: android-vpnservice-protect-hook MDVPN_COMMIT: d481d72d4b86783a87d536c214d2c68cc4e9320e NAIVE_VERSION: v149.0.7827.114-1 - OLCRTC_REPO: https://github.com/openlibrecommunity/olcrtc.git - OLCRTC_COMMIT: 1255cf8248ee4a52cb355ceb8cf72281a5f1affd + OLCRTC_REPO: https://github.com/hawkff/olcrtc.git + OLCRTC_COMMIT: ad5cc1e3d60b657b15ccd26f8db91395bf9630d0 permissions: contents: read jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4de0a3f22..b0b2de9ac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,8 +15,8 @@ env: MDVPN_REF: android-vpnservice-protect-hook MDVPN_COMMIT: d481d72d4b86783a87d536c214d2c68cc4e9320e NAIVE_VERSION: v149.0.7827.114-1 - OLCRTC_REPO: https://github.com/openlibrecommunity/olcrtc.git - OLCRTC_COMMIT: 1255cf8248ee4a52cb355ceb8cf72281a5f1affd + OLCRTC_REPO: https://github.com/hawkff/olcrtc.git + OLCRTC_COMMIT: ad5cc1e3d60b657b15ccd26f8db91395bf9630d0 permissions: contents: read jobs: diff --git a/buildScript/lib/olcrtc.sh b/buildScript/lib/olcrtc.sh index c939a94c1..d5431e189 100755 --- a/buildScript/lib/olcrtc.sh +++ b/buildScript/lib/olcrtc.sh @@ -2,7 +2,7 @@ # Build the olcRTC CLIENT as an Android native executable for all ABIs and install # them as bundled sidecars (app/executableSo//libolcrtc.so). # -# olcRTC (github.com/openlibrecommunity/olcrtc) is an encrypted TCP-over-WebRTC +# olcRTC (github.com/hawkff/olcrtc) is an encrypted TCP-over-WebRTC # tunnel. We bundle ONLY the client (cnc role): it opens a loopback SOCKS5 listener # that a sing-box `socks` outbound dials, and tunnels traffic out over a common meet # service. The server side is deployed separately and is never shipped in the app. @@ -15,15 +15,13 @@ # # A tiny wrapper main (buildScript/lib/olcrtc-src) imports olcRTC's `mobile` package at # a pinned commit, wires socket protection to libcore's protect_path unix socket, and -# parses CLI flags. We clone the pinned upstream commit and point the wrapper module at +# parses CLI flags. We clone the pinned source commit and point the wrapper module at # it via a replace directive so the build is fully reproducible and offline-stable. # -# OLCRTC_REPO/OLCRTC_COMMIT default to the upstream project at a commit that carries -# the protected pion net (internal/protect/pionnet.go + the jitsi SetNet hook, merged -# in openlibrecommunity/olcrtc#111) plus Jitsi ICE-service URL normalization -# (openlibrecommunity/olcrtc#121). Pinning at/after both keeps the media path off -# the tun and prevents malformed service-discovery entries from failing peer- -# connection setup. +# OLCRTC_REPO/OLCRTC_COMMIT default to the maintained fork at an immutable commit +# that includes protected pion networking, Jitsi ICE-service URL normalization, +# and deterministic targeted peer readiness. Pinning the full commit keeps these +# transport and routing invariants identical between the app and server builds. # # Usage: ./run lib olcrtc set -e @@ -36,8 +34,8 @@ if [ -z "$ANDROID_NDK_HOME" ]; then exit 1 fi -OLCRTC_REPO="${OLCRTC_REPO:-https://github.com/openlibrecommunity/olcrtc.git}" -OLCRTC_COMMIT="${OLCRTC_COMMIT:-1255cf8248ee4a52cb355ceb8cf72281a5f1affd}" +OLCRTC_REPO="${OLCRTC_REPO:-https://github.com/hawkff/olcrtc.git}" +OLCRTC_COMMIT="${OLCRTC_COMMIT:-ad5cc1e3d60b657b15ccd26f8db91395bf9630d0}" if ! command -v go >/dev/null 2>&1; then echo "Error: go not found on PATH (olcRTC needs Go 1.26+)." >&2 From 57e0c3c187e9dc00cc5c51e95c1f9262960296a5 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:35:40 -0400 Subject: [PATCH 02/11] fix(olcrtc): validate profile inputs --- .../sagernet/fmt/olcrtc/OlcrtcFmt.kt | 251 +++++++++++++---- .../ui/profile/OlcrtcSettingsActivity.kt | 25 +- .../nekohasekai/sagernet/fmt/OlcrtcFmtTest.kt | 260 +++++++++++++++--- 3 files changed, 434 insertions(+), 102 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/olcrtc/OlcrtcFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/olcrtc/OlcrtcFmt.kt index 89ea351c9..20507893b 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/olcrtc/OlcrtcFmt.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/olcrtc/OlcrtcFmt.kt @@ -8,6 +8,8 @@ package io.nekohasekai.sagernet.fmt.olcrtc +import java.net.URI + /** * Parser/emitter for the `olcrtc://` client URI. * @@ -28,9 +30,47 @@ package io.nekohasekai.sagernet.fmt.olcrtc */ private const val SCHEME = "olcrtc://" -private val SUPPORTED_TRANSPORTS = setOf("vp8channel", "datachannel") -private val SUPPORTED_CARRIERS = setOf("jitsi", "telemost", "wbstream") +private const val TRANSPORT_VP8 = "vp8channel" +private const val TRANSPORT_DATA = "datachannel" +private val SUPPORTED_TRANSPORTS_BY_CARRIER = mapOf( + "jitsi" to setOf(TRANSPORT_VP8, TRANSPORT_DATA), + "telemost" to setOf(TRANSPORT_VP8), + "wbstream" to setOf(TRANSPORT_VP8), +) private val DELIMITERS = setOf('<', '>', '&', '=', '@', '#', '$', '?') +private val VP8_FPS_RANGE = 1..120 +private val VP8_BATCH_RANGE = 1..64 + +/** + * Validates fields shared by URI import/export, the profile editor, and runtime args. + * Plain upstream URIs do not carry a client id, so callers opt into that requirement. + */ +fun OlcrtcBean.validateOlcrtcProfile(requireClientId: Boolean = false) { + val carrierName = carrier.orEmpty() + val transportName = transport.orEmpty() + val room = roomId.orEmpty() + val identity = clientId.orEmpty() + val hex = keyHex.orEmpty() + val fps = vp8Fps ?: 0 + val batchSize = vp8BatchSize ?: 0 + val resolver = dnsServer.orEmpty() + + val allowedTransports = SUPPORTED_TRANSPORTS_BY_CARRIER[carrierName] + require(allowedTransports != null) { "olcRTC: unsupported carrier" } + require(transportName in allowedTransports) { "olcRTC: transport is not supported by carrier" } + require(room.isNotBlank()) { "olcRTC: room id / URL is required" } + if (requireClientId) { + require(identity.isNotBlank()) { "olcRTC: client id is required" } + } + require(hex.length == 64 && hex.all { it.isHexDigit() }) { + "olcRTC: encryption key must be 64 hex characters" + } + require(fps in VP8_FPS_RANGE) { "olcRTC: VP8 FPS must be between 1 and 120" } + require(batchSize in VP8_BATCH_RANGE) { "olcRTC: VP8 batch size must be between 1 and 64" } + require(resolver.isBlank() || resolver.isIpPortLiteral()) { + "olcRTC: DNS resolver must be an IP literal with a valid port" + } +} /** Parses an `olcrtc://` link into an [OlcrtcBean]. Fails fast on malformed input. */ fun parseOlcrtc(url: String): OlcrtcBean { @@ -59,10 +99,7 @@ fun parseOlcrtc(url: String): OlcrtcBean { require(q >= 0) { "invalid olcrtc link: missing '?' before transport" } val carrier = body.substring(0, q).trim() require(carrier.isNotEmpty()) { "invalid olcrtc link: empty carrier" } - require(carrier in SUPPORTED_CARRIERS) { - "olcrtc link unsupported carrier '$carrier' (supported: ${SUPPORTED_CARRIERS.joinToString()})" - } - var afterQ = body.substring(q + 1) + val afterQ = body.substring(q + 1) // roomId after the FIRST '@' that is NOT inside the `<...>` payload block. val payloadEnd = if (afterQ.startsWith("<") || afterQ.contains('<')) afterQ.indexOf('>') else -1 @@ -79,10 +116,14 @@ fun parseOlcrtc(url: String): OlcrtcBean { if (lt >= 0) { val gt = transportPart.indexOf('>', lt) require(gt >= 0) { "invalid olcrtc link: unterminated '<...>' transport payload" } + require(transportPart.substring(gt + 1).isBlank()) { + "invalid olcrtc link: unexpected text after transport payload" + } payload = transportPart.substring(lt + 1, gt) transportPart = transportPart.substring(0, lt) } - val transport = transportPart.trim().ifEmpty { "vp8channel" } + val transport = transportPart.trim().ifEmpty { TRANSPORT_VP8 } + val payloadValues = parsePayload(payload) return OlcrtcBean().apply { // serverAddress/Port are unused by this protocol; keep a stable placeholder. @@ -90,59 +131,64 @@ fun parseOlcrtc(url: String): OlcrtcBean { this.carrier = carrier this.roomId = roomId this.keyHex = keyHex - require(transport in SUPPORTED_TRANSPORTS) { - "olcrtc link unsupported transport '$transport' (supported: ${SUPPORTED_TRANSPORTS.joinToString()})" - } this.transport = transport name = comment + initializeDefaultValues() + + payloadValues.forEach { (key, value) -> + when (key) { + "vp8-fps" -> vp8Fps = value.toIntOrNull() + ?: throw IllegalArgumentException("olcRTC: VP8 FPS must be an integer") + + "vp8-batch" -> vp8BatchSize = value.toIntOrNull() + ?: throw IllegalArgumentException("olcRTC: VP8 batch size must be an integer") - parsePayload(payload).forEach { (k, v) -> - when (k) { - "vp8-fps" -> v.toIntOrNull()?.let { vp8Fps = it } - "vp8-batch" -> v.toIntOrNull()?.let { vp8BatchSize = it } // Our non-standard pairing-token carrier. - "cid", "client-id", "clientid" -> clientId = v + "cid", "client-id", "clientid" -> clientId = value } } - initializeDefaultValues() - - require(keyHex.isNotBlank()) { "olcrtc link missing encryption key" } - require(keyHex.length == 64 && keyHex.all { it.isHexDigit() }) { - "olcrtc link encryption key must be 64 hex characters" - } + validateOlcrtcProfile() } } /** Serializes an [OlcrtcBean] to a shareable `olcrtc://` link, carrying clientId as `&cid=`. */ fun OlcrtcBean.toUri(): String { - require(carrier.isNotBlank()) { "olcRTC: cannot build share link without a carrier" } - require(roomId.isNotBlank()) { "olcRTC: cannot build share link without a room id" } + validateOlcrtcProfile() + val carrierName = carrier.orEmpty() + val transportName = transport.orEmpty() + val room = roomId.orEmpty() + val shareClientId = clientId.orEmpty() + val hex = keyHex.orEmpty() + val fps = vp8Fps ?: 0 + val batchSize = vp8BatchSize ?: 0 + val profileName = name.orEmpty() + // The URI uses bare delimiters with no escaping convention; refuse to emit a link that // would not round-trip rather than silently producing a corrupt one. - require(clientId.none { it in DELIMITERS }) { - "olcRTC: clientId must not contain any of: ${DELIMITERS.joinToString(" ")}" + require(shareClientId.none { it in DELIMITERS }) { + "olcRTC: client id contains a reserved delimiter" } // roomId is emitted raw before '#'; a '$' in it would be mis-parsed as the comment // delimiter on re-import. Refuse rather than emit a link that won't round-trip. - require(roomId.none { it == '$' }) { "olcRTC: room id must not contain '\$'" } - require(name.none { it == '$' }) { "olcRTC: profile name must not contain '\$'" } + require(room.none { it == '$' }) { "olcRTC: room id must not contain '\$'" } + require(profileName.none { it == '$' }) { "olcRTC: profile name must not contain '\$'" } val payloadParts = mutableListOf() - if (transport == "vp8channel") { + if (transportName == TRANSPORT_VP8) { val defaults = OlcrtcBean().apply { initializeDefaultValues() } - if (vp8Fps != defaults.vp8Fps) payloadParts += "vp8-fps=$vp8Fps" - if (vp8BatchSize != defaults.vp8BatchSize) payloadParts += "vp8-batch=$vp8BatchSize" + if (fps != defaults.vp8Fps) payloadParts += "vp8-fps=$fps" + if (batchSize != defaults.vp8BatchSize) payloadParts += "vp8-batch=$batchSize" } - if (clientId.isNotBlank()) payloadParts += "cid=$clientId" + if (shareClientId.isNotBlank()) payloadParts += "cid=$shareClientId" val payload = if (payloadParts.isEmpty()) "" else "<${payloadParts.joinToString("&")}>" val sb = StringBuilder(SCHEME) - sb.append(carrier).append('?').append(transport).append(payload) - sb.append('@').append(roomId) - sb.append('#').append(keyHex) - if (name.isNotBlank()) sb.append('$').append(name) + sb.append(carrierName).append('?').append(transportName).append(payload) + sb.append('@').append(room) + sb.append('#').append(hex) + if (profileName.isNotBlank()) sb.append('$').append(profileName) return sb.toString() } @@ -164,23 +210,28 @@ fun OlcrtcBean.buildOlcrtcArgs( dnsFallback: String, readyTimeoutMs: Long, ): List { - require(!carrier.isNullOrBlank()) { "olcRTC: carrier is required" } - require(!roomId.isNullOrBlank()) { "olcRTC: room id is required" } - val hex = keyHex ?: "" - require(hex.length == 64 && hex.all { it.isHexDigit() }) { - "olcRTC: encryption key must be 64 hex characters" + validateOlcrtcProfile(requireClientId = true) + val carrierName = carrier.orEmpty() + val transportName = transport.orEmpty() + val room = roomId.orEmpty() + val identity = clientId.orEmpty() + val hex = keyHex.orEmpty() + val fps = vp8Fps ?: 0 + val batchSize = vp8BatchSize ?: 0 + val resolver = dnsServer.orEmpty().ifBlank { dnsFallback } + require(resolver.isIpPortLiteral()) { + "olcRTC: DNS resolver must be an IP literal with a valid port" } - val transportName = if (transport in SUPPORTED_TRANSPORTS) transport else "vp8channel" val args = mutableListOf( - "-carrier", carrier, + "-carrier", carrierName, "-transport", transportName, - "-room", roomId, - "-client-id", clientId ?: "", + "-room", room, + "-client-id", identity, "-key", hex, "-socks-port", port.toString(), - "-dns", (dnsServer ?: "").ifBlank { dnsFallback }, - "-vp8-fps", vp8Fps.toString(), - "-vp8-batch", vp8BatchSize.toString(), + "-dns", resolver, + "-vp8-fps", fps.toString(), + "-vp8-batch", batchSize.toString(), "-protect-path", protectPath, "-ready-timeout-ms", readyTimeoutMs.toString(), ) @@ -199,12 +250,16 @@ fun OlcrtcBean.buildOlcrtcArgs( * may resolve further hosts at runtime; those rely on the sidecar's own protected * resolver. ICE candidates are typically raw IPs, so signaling is the common blocker. */ -fun OlcrtcBean.carrierHost(): String? = when (carrier) { - "jitsi" -> { - // roomId is host/room or https://host/room; extract the host. - val s = roomId.substringAfter("://").trimStart('/') - s.substringBefore('/').substringBefore(':').ifBlank { null } - } +fun OlcrtcBean.carrierHost(): String? = when (carrier.orEmpty()) { + "jitsi" -> runCatching { + val room = roomId.orEmpty() + if (room.isBlank()) return@runCatching null + // Prefix a scheme for the accepted bare host/room form, then let URI parse the authority. + val value = if (room.contains("://")) room else "https://${room.trimStart('/')}" + URI(value).host + ?.removeSurrounding("[", "]") + ?.ifBlank { null } + }.getOrNull() "telemost" -> "telemost.yandex.ru" "wbstream" -> "stream.wb.ru" else -> null @@ -212,10 +267,92 @@ fun OlcrtcBean.carrierHost(): String? = when (carrier) { private fun parsePayload(payload: String): Map { if (payload.isBlank()) return emptyMap() - return payload.split('&').mapNotNull { pair -> - val i = pair.indexOf('=') - if (i <= 0) null else pair.substring(0, i).trim() to pair.substring(i + 1).trim() - }.toMap() + return payload.split('&').associate { pair -> + val separator = pair.indexOf('=') + require(separator > 0) { "invalid olcrtc transport payload" } + val key = pair.substring(0, separator).trim() + require(key.isNotEmpty()) { "invalid olcrtc transport payload" } + key to pair.substring(separator + 1).trim() + } +} + +private fun String.isIpPortLiteral(): Boolean { + val (host, port) = if (startsWith('[')) { + val closingBracket = indexOf(']') + if (closingBracket <= 1 || closingBracket != lastIndexOf(']')) return false + if (closingBracket + 1 >= length || this[closingBracket + 1] != ':') return false + substring(1, closingBracket) to substring(closingBracket + 2) + } else { + val separator = indexOf(':') + if (separator <= 0 || separator != lastIndexOf(':')) return false + substring(0, separator) to substring(separator + 1) + } + if (!port.isValidPort()) return false + return if (startsWith('[')) host.isIpv6Literal() else host.isIpv4Literal() +} + +private fun String.isValidPort(): Boolean { + if (isEmpty() || any { it !in '0'..'9' }) return false + val value = toIntOrNull() ?: return false + return value in 1..65535 +} + +private fun String.isIpv4Literal(): Boolean { + val octets = split('.') + return octets.size == 4 && octets.all { octet -> + if (octet.isEmpty() || octet.any { it !in '0'..'9' }) return@all false + if (octet.length > 1 && octet.startsWith('0')) return@all false + val value = octet.toIntOrNull() ?: return@all false + value in 0..255 + } } +private fun String.isIpv6Literal(): Boolean { + val zoneSeparator = indexOf('%') + val address: String + if (zoneSeparator >= 0) { + if (zoneSeparator == 0 || zoneSeparator != lastIndexOf('%')) return false + val zone = substring(zoneSeparator + 1) + if (zone.isEmpty() || zone.any { !it.isSafeZoneCharacter() }) return false + address = substring(0, zoneSeparator) + } else { + address = this + } + if (':' !in address) return false + + val compression = address.indexOf("::") + if (compression != address.lastIndexOf("::")) return false + val left: List + val right: List + if (compression >= 0) { + left = address.substring(0, compression).ipv6Segments() ?: return false + right = address.substring(compression + 2).ipv6Segments() ?: return false + if (left.any { '.' in it }) return false + } else { + left = address.ipv6Segments() ?: return false + right = emptyList() + } + val segments = left + right + var groups = 0 + segments.forEachIndexed { index, segment -> + if ('.' in segment) { + if (index != segments.lastIndex || !segment.isIpv4Literal()) return false + groups += 2 + } else { + if (segment.length !in 1..4 || !segment.all { it.isHexDigit() }) return false + groups += 1 + } + } + return if (compression >= 0) groups < 8 else groups == 8 +} + +private fun String.ipv6Segments(): List? { + if (isEmpty()) return emptyList() + if (startsWith(':') || endsWith(':')) return null + return split(':').takeIf { segments -> segments.none { it.isEmpty() } } +} + +private fun Char.isSafeZoneCharacter(): Boolean = + this in 'a'..'z' || this in 'A'..'Z' || this in '0'..'9' || this == '_' || this == '-' || this == '.' + private fun Char.isHexDigit(): Boolean = this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F' diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/OlcrtcSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/OlcrtcSettingsActivity.kt index 5447f815d..8f2698ec2 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/OlcrtcSettingsActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/OlcrtcSettingsActivity.kt @@ -10,6 +10,7 @@ package io.nekohasekai.sagernet.ui.profile import android.os.Bundle +import android.widget.Toast import androidx.preference.EditTextPreference import androidx.preference.PreferenceFragmentCompat import io.nekohasekai.sagernet.Key @@ -17,7 +18,9 @@ import io.nekohasekai.sagernet.R import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers import io.nekohasekai.sagernet.fmt.olcrtc.OlcrtcBean +import io.nekohasekai.sagernet.fmt.olcrtc.validateOlcrtcProfile import io.nekohasekai.sagernet.ktx.applyDefaultValues +import io.nekohasekai.sagernet.ktx.onMainDispatcher class OlcrtcSettingsActivity : ProfileSettingsActivity() { @@ -51,12 +54,22 @@ class OlcrtcSettingsActivity : ProfileSettingsActivity() { dnsServer = DataStore.olcrtcDnsServer // Fail fast in the editor instead of saving a profile that can only fail at connect. - // DataStore string() may return null if a field was never edited; coalesce to "". - require(!roomId.isNullOrBlank()) { "olcRTC: room id / URL is required" } - require(!clientId.isNullOrBlank()) { "olcRTC: client id is required" } - val hex = keyHex ?: "" - require(hex.length == 64 && hex.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) { - "olcRTC: encryption key must be 64 hex characters" + validateOlcrtcProfile(requireClientId = true) + } + + override suspend fun saveAndExit() { + try { + // Validate a temporary bean before the base path can stop an active profile. + createEntity().apply { serialize() } + super.saveAndExit() + } catch (e: IllegalArgumentException) { + onMainDispatcher { + Toast.makeText( + applicationContext, + e.message ?: "Invalid olcRTC profile", + Toast.LENGTH_LONG, + ).show() + } } } diff --git a/app/src/test/java/io/nekohasekai/sagernet/fmt/OlcrtcFmtTest.kt b/app/src/test/java/io/nekohasekai/sagernet/fmt/OlcrtcFmtTest.kt index e1d267c27..5b391e892 100644 --- a/app/src/test/java/io/nekohasekai/sagernet/fmt/OlcrtcFmtTest.kt +++ b/app/src/test/java/io/nekohasekai/sagernet/fmt/OlcrtcFmtTest.kt @@ -2,9 +2,12 @@ package io.nekohasekai.sagernet.fmt import io.nekohasekai.sagernet.fmt.olcrtc.OlcrtcBean import io.nekohasekai.sagernet.fmt.olcrtc.buildOlcrtcArgs +import io.nekohasekai.sagernet.fmt.olcrtc.carrierHost import io.nekohasekai.sagernet.fmt.olcrtc.parseOlcrtc import io.nekohasekai.sagernet.fmt.olcrtc.toUri import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test @@ -43,16 +46,12 @@ class OlcrtcFmtTest { @Test fun roundTrip_preservesFieldsAndCid() { - val bean = OlcrtcBean().apply { - carrier = "telemost" - roomId = "room-7" - clientId = "device-x" - keyHex = key - transport = "vp8channel" - vp8Fps = 60 - vp8BatchSize = 64 - name = "my olc" - }.apply { /* defaults already set explicitly above */ } + val bean = validBean( + carrier = "telemost", + clientId = "device-x", + vp8Fps = 60, + vp8Batch = 64, + ).apply { name = "my olc" } val uri = bean.toUri() assertTrue(uri.startsWith("olcrtc://telemost?vp8channel")) @@ -69,6 +68,14 @@ class OlcrtcFmtTest { assertEquals(bean.name, parsed.name) } + @Test + fun roundTrip_allowsBlankUpstreamClientId() { + val uri = validBean(clientId = "").toUri() + + assertFalse(uri.contains("cid=")) + assertEquals("", parseOlcrtc(uri).clientId) + } + @Test fun parse_rejectsMissingKey() { val link = "olcrtc://jitsi?datachannel@room-01" @@ -88,52 +95,227 @@ class OlcrtcFmtTest { @Test fun parse_rejectsUnsupportedCarrier() { - val link = "olcrtc://zoom?datachannel@room-01#$key" + val link = "olcrtc://unsupported?datachannel@room-01#$key" assertThrows(IllegalArgumentException::class.java) { parseOlcrtc(link) } } @Test - fun buildArgs_unsetDnsUsesFallback() { - // A bean whose dns field was never set (the default) must not crash; the - // fallback dns is used instead. Regression guard for the connect-time NPE. - val bean = OlcrtcBean().apply { - initializeDefaultValues() - carrier = "jitsi" - roomId = "room-01" - clientId = "dev1" - keyHex = key + fun parse_enforcesCarrierTransportMatrix() { + val accepted = listOf( + "jitsi" to "vp8channel", + "jitsi" to "datachannel", + "telemost" to "vp8channel", + "wbstream" to "vp8channel", + ) + accepted.forEach { (carrier, transport) -> + val parsed = parseOlcrtc("olcrtc://$carrier?$transport@review-4821#$key") + assertEquals(carrier, parsed.carrier) + assertEquals(transport, parsed.transport) } - val args = bean.buildOlcrtcArgs( - port = 10800, - protectPath = "/tmp/protect", - socksUser = "", - socksPass = "", - verbose = false, - dnsFallback = "9.9.9.9:53", - readyTimeoutMs = 15_000L, + + listOf("telemost", "wbstream").forEach { carrier -> + assertThrows(IllegalArgumentException::class.java) { + parseOlcrtc("olcrtc://$carrier?datachannel@review-4821#$key") + } + } + } + + @Test + fun parse_enforcesVp8BoundsAndDefaults() { + val minimum = parseOlcrtc( + "olcrtc://jitsi?vp8channel@review-4821#$key", + ) + assertEquals(1, minimum.vp8Fps) + assertEquals(1, minimum.vp8BatchSize) + + val maximum = parseOlcrtc( + "olcrtc://jitsi?vp8channel@review-4821#$key", + ) + assertEquals(120, maximum.vp8Fps) + assertEquals(64, maximum.vp8BatchSize) + + val defaults = parseOlcrtc("olcrtc://jitsi?vp8channel@review-4821#$key") + assertEquals(30, defaults.vp8Fps) + assertEquals(8, defaults.vp8BatchSize) + } + + @Test + fun parse_rejectsMalformedOrOutOfRangeVp8Values() { + val payloads = listOf( + "vp8-fps=abc", + "vp8-fps=0", + "vp8-fps=121", + "vp8-fps=-1", + "vp8-batch=abc", + "vp8-batch=0", + "vp8-batch=65", + "vp8-batch=-1", + "vp8-fps", + " =ignored", ) + payloads.forEach { payload -> + assertThrows(IllegalArgumentException::class.java) { + parseOlcrtc("olcrtc://jitsi?vp8channel<$payload>@review-4821#$key") + } + } + } + + @Test + fun parse_rejectsTextAfterTransportPayload() { + assertThrows(IllegalArgumentException::class.java) { + parseOlcrtc("olcrtc://jitsi?vp8channeljunk@review-4821#$key") + } + } + + @Test + fun buildArgs_unsetDnsUsesFallback() { + val args = buildArgs(validBean(dns = "")) val dnsIdx = args.indexOf("-dns") assertTrue(dnsIdx >= 0) assertEquals("9.9.9.9:53", args[dnsIdx + 1]) } + @Test + fun buildArgs_acceptsIpLiteralDnsEndpoints() { + val endpoints = listOf( + "0.0.0.0:1", + "9.9.9.9:53", + "255.255.255.255:65535", + "[2001:4860:4860::8888]:53", + "[::ffff:192.0.2.1]:53", + "[fe80::1%wlan0]:53", + ) + endpoints.forEach { endpoint -> + val args = buildArgs(validBean(dns = endpoint)) + assertEquals(endpoint, args[args.indexOf("-dns") + 1]) + } + } + + @Test + fun buildArgs_rejectsInvalidDnsEndpointsWithoutEchoingThem() { + val endpoints = listOf( + "dns.example:53", + "9.9.9.9", + "2001:4860:4860::8888:53", + ":53", + "[]:53", + "9.9.9.9:", + "9.9.9.9:dns", + "9.9.9.9:0", + "9.9.9.9:65536", + "999.1.1.1:53", + "09.9.9.9:53", + "[2001:db8:::1]:53", + "[192.0.2.1::]:53", + "[fe80::1%]:53", + ) + endpoints.forEach { endpoint -> + val error = assertThrows(IllegalArgumentException::class.java) { + buildArgs(validBean(dns = endpoint)) + } + assertFalse(error.message.orEmpty().contains(endpoint)) + } + } + + @Test + fun buildArgs_rejectsBlankClientAndUnsupportedTransport() { + assertThrows(IllegalArgumentException::class.java) { + buildArgs(validBean(clientId = "")) + } + assertThrows(IllegalArgumentException::class.java) { + buildArgs(validBean(transport = "futurechannel")) + } + } + @Test fun buildArgs_blankRoomThrowsArgumentError() { + assertThrows(IllegalArgumentException::class.java) { + buildArgs(validBean().apply { roomId = "" }) + } + } + + @Test + fun toUri_rejectsUnsupportedCarrierTransportCombination() { + assertThrows(IllegalArgumentException::class.java) { + validBean(carrier = "telemost", transport = "datachannel").toUri() + } + assertThrows(IllegalArgumentException::class.java) { + validBean(transport = "futurechannel").toUri() + } + } + + @Test + fun uninitializedBeanFailsWithArgumentErrorInsteadOfNullPointer() { + val bean = OlcrtcBean() + + assertThrows(IllegalArgumentException::class.java) { bean.toUri() } + assertThrows(IllegalArgumentException::class.java) { buildArgs(bean) } + } + + @Test + fun toUri_treatsNullOptionalJavaFieldsAsBlank() { val bean = OlcrtcBean().apply { - initializeDefaultValues() carrier = "jitsi" + transport = "vp8channel" + roomId = "review-4821" keyHex = key + vp8Fps = 30 + vp8BatchSize = 8 } - assertThrows(IllegalArgumentException::class.java) { - bean.buildOlcrtcArgs( - port = 10800, - protectPath = "/tmp/protect", - socksUser = "", - socksPass = "", - verbose = false, - dnsFallback = "9.9.9.9:53", - readyTimeoutMs = 15_000L, - ) + + assertEquals("olcrtc://jitsi?vp8channel@review-4821#$key", bean.toUri()) + } + + @Test + fun carrierHost_extractsHostnameAndBracketedIpv6() { + val cases = mapOf( + "https://meet.example.org/room" to "meet.example.org", + "https://meet.example.org:8443/room" to "meet.example.org", + "meet.example.org/room" to "meet.example.org", + "https://[2001:db8::1]/room" to "2001:db8::1", + "https://[2001:db8::1]:8443/room" to "2001:db8::1", + "[2001:db8::1]/room" to "2001:db8::1", + ) + cases.forEach { (room, expectedHost) -> + assertEquals(expectedHost, validBean(roomId = room).carrierHost()) } } + + @Test + fun carrierHost_preservesFixedCarrierHosts() { + assertEquals("telemost.yandex.ru", validBean(carrier = "telemost").carrierHost()) + assertEquals("stream.wb.ru", validBean(carrier = "wbstream").carrierHost()) + assertNull(validBean().apply { carrier = "unsupported" }.carrierHost()) + assertNull(OlcrtcBean().apply { carrier = "jitsi" }.carrierHost()) + } + + private fun validBean( + carrier: String = "jitsi", + transport: String = "vp8channel", + roomId: String = "review-4821", + clientId: String = "device-7", + dns: String = "", + vp8Fps: Int = 30, + vp8Batch: Int = 8, + ) = OlcrtcBean().apply { + initializeDefaultValues() + this.carrier = carrier + this.transport = transport + this.roomId = roomId + this.clientId = clientId + keyHex = key + dnsServer = dns + this.vp8Fps = vp8Fps + vp8BatchSize = vp8Batch + } + + private fun buildArgs(bean: OlcrtcBean) = bean.buildOlcrtcArgs( + port = 10800, + protectPath = "/tmp/protect", + socksUser = "", + socksPass = "", + verbose = false, + dnsFallback = "9.9.9.9:53", + readyTimeoutMs = 15_000L, + ) } From a305c21ea8b7f930ade9a3ff0e79cc8bbbf6e1ae Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:52:22 -0400 Subject: [PATCH 03/11] fix(olcrtc): address profile review feedback --- .../sagernet/fmt/olcrtc/OlcrtcFmt.kt | 56 +++++++++++++------ .../nekohasekai/sagernet/fmt/OlcrtcFmtTest.kt | 23 ++++++++ 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/olcrtc/OlcrtcFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/olcrtc/OlcrtcFmt.kt index 20507893b..79fd014f9 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/olcrtc/OlcrtcFmt.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/olcrtc/OlcrtcFmt.kt @@ -8,8 +8,6 @@ package io.nekohasekai.sagernet.fmt.olcrtc -import java.net.URI - /** * Parser/emitter for the `olcrtc://` client URI. * @@ -32,6 +30,8 @@ import java.net.URI private const val SCHEME = "olcrtc://" private const val TRANSPORT_VP8 = "vp8channel" private const val TRANSPORT_DATA = "datachannel" +private const val DEFAULT_VP8_FPS = 30 +private const val DEFAULT_VP8_BATCH = 8 private val SUPPORTED_TRANSPORTS_BY_CARRIER = mapOf( "jitsi" to setOf(TRANSPORT_VP8, TRANSPORT_DATA), "telemost" to setOf(TRANSPORT_VP8), @@ -65,8 +65,10 @@ fun OlcrtcBean.validateOlcrtcProfile(requireClientId: Boolean = false) { require(hex.length == 64 && hex.all { it.isHexDigit() }) { "olcRTC: encryption key must be 64 hex characters" } - require(fps in VP8_FPS_RANGE) { "olcRTC: VP8 FPS must be between 1 and 120" } - require(batchSize in VP8_BATCH_RANGE) { "olcRTC: VP8 batch size must be between 1 and 64" } + if (transportName == TRANSPORT_VP8) { + require(fps in VP8_FPS_RANGE) { "olcRTC: VP8 FPS must be between 1 and 120" } + require(batchSize in VP8_BATCH_RANGE) { "olcRTC: VP8 batch size must be between 1 and 64" } + } require(resolver.isBlank() || resolver.isIpPortLiteral()) { "olcRTC: DNS resolver must be an IP literal with a valid port" } @@ -144,7 +146,12 @@ fun parseOlcrtc(url: String): OlcrtcBean { ?: throw IllegalArgumentException("olcRTC: VP8 batch size must be an integer") // Our non-standard pairing-token carrier. - "cid", "client-id", "clientid" -> clientId = value + "cid", "client-id", "clientid" -> { + require(value.none { it in DELIMITERS }) { + "olcRTC: client id contains a reserved delimiter" + } + clientId = value + } } } @@ -160,8 +167,8 @@ fun OlcrtcBean.toUri(): String { val room = roomId.orEmpty() val shareClientId = clientId.orEmpty() val hex = keyHex.orEmpty() - val fps = vp8Fps ?: 0 - val batchSize = vp8BatchSize ?: 0 + val fps = vp8Fps ?: DEFAULT_VP8_FPS + val batchSize = vp8BatchSize ?: DEFAULT_VP8_BATCH val profileName = name.orEmpty() // The URI uses bare delimiters with no escaping convention; refuse to emit a link that @@ -216,8 +223,16 @@ fun OlcrtcBean.buildOlcrtcArgs( val room = roomId.orEmpty() val identity = clientId.orEmpty() val hex = keyHex.orEmpty() - val fps = vp8Fps ?: 0 - val batchSize = vp8BatchSize ?: 0 + val fps = if (transportName == TRANSPORT_VP8) { + vp8Fps ?: DEFAULT_VP8_FPS + } else { + DEFAULT_VP8_FPS + } + val batchSize = if (transportName == TRANSPORT_VP8) { + vp8BatchSize ?: DEFAULT_VP8_BATCH + } else { + DEFAULT_VP8_BATCH + } val resolver = dnsServer.orEmpty().ifBlank { dnsFallback } require(resolver.isIpPortLiteral()) { "olcRTC: DNS resolver must be an IP literal with a valid port" @@ -251,15 +266,20 @@ fun OlcrtcBean.buildOlcrtcArgs( * resolver. ICE candidates are typically raw IPs, so signaling is the common blocker. */ fun OlcrtcBean.carrierHost(): String? = when (carrier.orEmpty()) { - "jitsi" -> runCatching { - val room = roomId.orEmpty() - if (room.isBlank()) return@runCatching null - // Prefix a scheme for the accepted bare host/room form, then let URI parse the authority. - val value = if (room.contains("://")) room else "https://${room.trimStart('/')}" - URI(value).host - ?.removeSurrounding("[", "]") - ?.ifBlank { null } - }.getOrNull() + "jitsi" -> { + // Mirror upstream's permissive host/room split, but keep bracketed IPv6 intact. + val room = roomId.orEmpty().trim() + val authority = room.substringAfter("://", room).trimStart('/').substringBefore('/').trim() + when { + authority.isBlank() -> null + authority.startsWith('[') -> { + val closingBracket = authority.indexOf(']') + if (closingBracket <= 1) null else authority.substring(1, closingBracket) + } + authority.count { it == ':' } == 1 -> authority.substringBefore(':').ifBlank { null } + else -> authority + } + } "telemost" -> "telemost.yandex.ru" "wbstream" -> "stream.wb.ru" else -> null diff --git a/app/src/test/java/io/nekohasekai/sagernet/fmt/OlcrtcFmtTest.kt b/app/src/test/java/io/nekohasekai/sagernet/fmt/OlcrtcFmtTest.kt index 5b391e892..e0923d5fc 100644 --- a/app/src/test/java/io/nekohasekai/sagernet/fmt/OlcrtcFmtTest.kt +++ b/app/src/test/java/io/nekohasekai/sagernet/fmt/OlcrtcFmtTest.kt @@ -99,6 +99,15 @@ class OlcrtcFmtTest { assertThrows(IllegalArgumentException::class.java) { parseOlcrtc(link) } } + @Test + fun parse_rejectsClientIdReservedDelimiter() { + val error = assertThrows(IllegalArgumentException::class.java) { + parseOlcrtc("olcrtc://jitsi?vp8channel@review-4821#$key") + } + + assertFalse(error.message.orEmpty().contains("device=7")) + } + @Test fun parse_enforcesCarrierTransportMatrix() { val accepted = listOf( @@ -139,6 +148,19 @@ class OlcrtcFmtTest { assertEquals(8, defaults.vp8BatchSize) } + @Test + fun datachannel_ignoresUnusedVp8Values() { + val bean = validBean(transport = "datachannel").apply { + vp8Fps = null + vp8BatchSize = 999 + } + + assertFalse(bean.toUri().contains("vp8-")) + val args = buildArgs(bean) + assertEquals("30", args[args.indexOf("-vp8-fps") + 1]) + assertEquals("8", args[args.indexOf("-vp8-batch") + 1]) + } + @Test fun parse_rejectsMalformedOrOutOfRangeVp8Values() { val payloads = listOf( @@ -272,6 +294,7 @@ class OlcrtcFmtTest { "https://meet.example.org/room" to "meet.example.org", "https://meet.example.org:8443/room" to "meet.example.org", "meet.example.org/room" to "meet.example.org", + "https://meet_private.example/room" to "meet_private.example", "https://[2001:db8::1]/room" to "2001:db8::1", "https://[2001:db8::1]:8443/room" to "2001:db8::1", "[2001:db8::1]/room" to "2001:db8::1", From d15e256c2321d11ac47658a0ab3279c75e43954d Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:53:32 -0400 Subject: [PATCH 04/11] fix(olcrtc): supervise sidecar recovery --- .github/workflows/ci.yml | 1 + .../sagernet/bg/GuardedProcessPool.kt | 197 +++++++++++++----- .../bg/GuardedProcessRestartPolicy.kt | 36 ++++ .../sagernet/bg/proto/BoxInstance.kt | 122 ++++++----- .../sagernet/bg/proto/TestInstance.kt | 2 + .../bg/GuardedProcessRestartPolicyTest.kt | 62 ++++++ buildScript/lib/olcrtc-src/main.go | 24 ++- buildScript/lib/olcrtc-src/main_test.go | 48 +++++ buildScript/lib/olcrtc.sh | 3 +- 9 files changed, 392 insertions(+), 103 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessRestartPolicy.kt create mode 100644 app/src/test/java/io/nekohasekai/sagernet/bg/GuardedProcessRestartPolicyTest.kt create mode 100644 buildScript/lib/olcrtc-src/main_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a66042b9..ed5b8a8e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -180,6 +180,7 @@ jobs: buildScript/lib/naive.sh \ buildScript/lib/olcrtc.sh \ buildScript/lib/olcrtc-src/main.go \ + buildScript/lib/olcrtc-src/main_test.go \ buildScript/lib/olcrtc-src/go.mod \ buildScript/init/env.sh \ buildScript/init/env_ndk.sh \ diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt index 9994353d0..305ffcb78 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt @@ -12,12 +12,23 @@ import io.nekohasekai.sagernet.utils.Commandline import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.selects.select import libcore.Libcore import java.io.File import java.io.IOException import java.io.InputStream import kotlin.concurrent.thread +private data class ProcessGenerationExit( + val exitCode: Int, + val readyAtMillis: Long? = null, +) + +private data class RestartReadinessResult( + val readyAtMillis: Long? = null, + val error: IOException? = null, +) + class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : CoroutineScope { companion object { private val pid by lazy { @@ -43,51 +54,143 @@ class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : C }.start() } + private fun watchProcess(cmdName: String, exitChannel: Channel) { + val proc = process + thread(name = "stderr-$cmdName") { + streamLogger(proc.errorStream) { + Libcore.nekoLogPrintln("[$cmdName] ${Commandline.redactProcessOutput(it)}") + } + } + thread(name = "stdout-$cmdName") { + streamLogger(proc.inputStream) { + Libcore.nekoLogPrintln("[$cmdName] ${Commandline.redactProcessOutput(it)}") + } + } + // The channel is generation-local and buffered, so this waiter never blocks a + // later generation and remains available to bounded NonCancellable teardown. + thread(name = "waitFor-$cmdName") { + val code = proc.waitFor() + if (exitChannel.trySendBlocking(code).isFailure) { + Logs.w("$cmdName: could not deliver exit code $code (channel closed)") + } + } + } + + private suspend fun observeRestart( + cmdName: String, + exitChannel: Channel, + onRestartCallback: suspend () -> Unit, + ): ProcessGenerationExit = coroutineScope { + val readiness = async { + try { + onRestartCallback() + RestartReadinessResult(readyAtMillis = SystemClock.elapsedRealtime()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + RestartReadinessResult( + error = if (e is IOException) e else IOException("restart readiness check failed", e), + ) + } + } + select { + exitChannel.onReceive { exitCode -> + readiness.cancelAndJoin() + ProcessGenerationExit(exitCode) + } + readiness.onAwait { result -> + val readinessError = result.error + if (readinessError == null) { + ProcessGenerationExit( + exitCode = exitChannel.receive(), + readyAtMillis = result.readyAtMillis, + ) + } else { + Logs.w("$cmdName restart readiness failed; restarting") + val exitCode = terminateProcess(exitChannel) + ?: throw IOException( + "$cmdName could not stop after restart readiness failure", + readinessError, + ) + ProcessGenerationExit(exitCode) + } + } + } + } + + private suspend fun terminateProcess(exitChannel: Channel): Int? = withContext(NonCancellable) { + exitChannel.tryReceive().getOrNull()?.let { return@withContext it } + if (Build.VERSION.SDK_INT < 24) { + try { + Os.kill(pid.get(process) as Int, OsConstants.SIGTERM) + } catch (e: ErrnoException) { + if (e.errno != OsConstants.ESRCH) Logs.w(e) + } catch (e: ReflectiveOperationException) { + Logs.w(e) + } + withTimeoutOrNull(500) { exitChannel.receive() }?.let { return@withContext it } + } + process.destroy() + if (Build.VERSION.SDK_INT >= 26) { + withTimeoutOrNull(1000) { exitChannel.receive() }?.let { return@withContext it } + process.destroyForcibly() + } + withTimeoutOrNull(1000) { exitChannel.receive() } + } + @DelicateCoroutinesApi - suspend fun looper(onRestartCallback: (suspend () -> Unit)?) { + suspend fun looper(onRestartCallback: (suspend () -> Unit)?, restartPolicy: GuardedProcessRestartPolicy?) { var running = true + var restarted = false + var currentExitChannel: Channel? = null val cmdName = File(cmd.first()).nameWithoutExtension - val exitChannel = Channel() + val backoff = restartPolicy.createBackoff() try { while (true) { - thread(name = "stderr-$cmdName") { - streamLogger(process.errorStream) { - Libcore.nekoLogPrintln("[$cmdName] ${Commandline.redactProcessOutput(it)}") - } - } - thread(name = "stdout-$cmdName") { - streamLogger(process.inputStream) { - Libcore.nekoLogPrintln("[$cmdName] ${Commandline.redactProcessOutput(it)}") - } - } - // Dedicated waiter thread (lifecycle independent of the pool's Job) so the - // NonCancellable teardown below can still drain the exit code even after the - // pool is cancelled. Use trySendBlocking instead of runBlocking { send } to - // avoid spinning up a coroutine dispatcher on this raw thread. - val proc = process - thread(name = "waitFor-$cmdName") { - val code = proc.waitFor() - // If the channel is already closed/failed, log rather than silently drop - // (the NonCancellable teardown below also bounds its receive()). - if (exitChannel.trySendBlocking(code).isFailure) { - Logs.w("$cmdName: could not deliver exit code $code (channel closed)") - } - } + val exitChannel = Channel(capacity = 1) + currentExitChannel = exitChannel + watchProcess(cmdName, exitChannel) val startTime = SystemClock.elapsedRealtime() - val exitCode = exitChannel.receive() + val generation = if (restarted && onRestartCallback != null) { + observeRestart(cmdName, exitChannel, onRestartCallback) + } else { + ProcessGenerationExit(exitChannel.receive()) + } running = false - when { - SystemClock.elapsedRealtime() - startTime < 1000 -> throw IOException( - "$cmdName exits too fast (exit code: $exitCode)", + currentExitChannel = null + exitChannel.close() + + val exitTime = SystemClock.elapsedRealtime() + val processUptimeMillis = exitTime - startTime + if (restartPolicy == null && processUptimeMillis < 1000L) { + throw IOException("$cmdName exits too fast (exit code: ${generation.exitCode})") + } + when (generation.exitCode) { + 128 + OsConstants.SIGKILL -> Logs.w("$cmdName was killed") + else -> Logs.w( + IOException("$cmdName unexpectedly exits with code ${generation.exitCode}"), ) + } - exitCode == 128 + OsConstants.SIGKILL -> Logs.w("$cmdName was killed") - else -> Logs.w(IOException("$cmdName unexpectedly exits with code $exitCode")) + val readyDurationMillis = generation.readyAtMillis?.let { + (exitTime - it).coerceAtLeast(0L) + } + val restartDelayMillis = backoff?.delayAfterExit(readyDurationMillis) + if (restartDelayMillis != null) { + Logs.i( + "restart process after ${restartDelayMillis}ms: " + + Commandline.toRedactedString(cmd), + ) + delay(restartDelayMillis) + } else { + Logs.i( + "restart process: ${Commandline.toRedactedString(cmd)} " + + "(last exit code: ${generation.exitCode})", + ) } - Logs.i("restart process: ${Commandline.toRedactedString(cmd)} (last exit code: $exitCode)") start() running = true - onRestartCallback?.invoke() + restarted = true } } catch (e: IOException) { Logs.w("error occurred. stop guard: ${Commandline.toRedactedString(cmd)}") @@ -95,26 +198,11 @@ class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : C // and stop a freshly-restarted instance. this@GuardedProcessPool.launch(Dispatchers.Main.immediate) { onFatal(e) } } finally { - if (running) { - withContext(NonCancellable) { // clean-up cannot be cancelled - if (Build.VERSION.SDK_INT < 24) { - try { - Os.kill(pid.get(process) as Int, OsConstants.SIGTERM) - } catch (e: ErrnoException) { - if (e.errno != OsConstants.ESRCH) Logs.w(e) - } catch (e: ReflectiveOperationException) { - Logs.w(e) - } - if (withTimeoutOrNull(500) { exitChannel.receive() } != null) return@withContext - } - process.destroy() // kill the process - if (Build.VERSION.SDK_INT >= 26) { - if (withTimeoutOrNull(1000) { exitChannel.receive() } != null) return@withContext - process.destroyForcibly() // Force to kill the process if it's still alive - } - // Bounded so a missed exit-code send (closed channel) can't hang teardown. - withTimeoutOrNull(1000) { exitChannel.receive() } - } // otherwise process already exited, nothing to be done + val exitChannel = currentExitChannel + if (running && exitChannel != null) { + terminateProcess(exitChannel) + } else if (running) { + process.destroy() } } } @@ -128,11 +216,12 @@ class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : C cmd: List, env: MutableMap = mutableMapOf(), onRestartCallback: (suspend () -> Unit)? = null, + restartPolicy: GuardedProcessRestartPolicy? = null, ) { Logs.i("start process: ${Commandline.toRedactedString(cmd)}") Guard(cmd, env).apply { start() // if start fails, IOException will be thrown directly - launch { looper(onRestartCallback) } + launch { looper(onRestartCallback, restartPolicy) } } processCount += 1 } diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessRestartPolicy.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessRestartPolicy.kt new file mode 100644 index 000000000..cba44092a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessRestartPolicy.kt @@ -0,0 +1,36 @@ +package io.nekohasekai.sagernet.bg + +data class GuardedProcessRestartPolicy( + val initialDelayMillis: Long = 1_000L, + val maximumDelayMillis: Long = 30_000L, + val stableAfterReadyMillis: Long = 60_000L, +) { + init { + require(initialDelayMillis > 0L) { "initial restart delay must be positive" } + require(maximumDelayMillis >= initialDelayMillis) { + "maximum restart delay must not be smaller than the initial delay" + } + require(stableAfterReadyMillis > 0L) { "stable readiness duration must be positive" } + } +} + +internal class GuardedProcessRestartBackoff( + private val policy: GuardedProcessRestartPolicy, +) { + private var nextDelayMillis = policy.initialDelayMillis + + fun delayAfterExit(readyDurationMillis: Long?): Long { + if (readyDurationMillis != null && readyDurationMillis >= policy.stableAfterReadyMillis) { + nextDelayMillis = policy.initialDelayMillis + } + val delayMillis = nextDelayMillis + nextDelayMillis = if (nextDelayMillis >= policy.maximumDelayMillis - nextDelayMillis) { + policy.maximumDelayMillis + } else { + nextDelayMillis * 2L + } + return delayMillis + } +} + +internal fun GuardedProcessRestartPolicy?.createBackoff() = this?.let(::GuardedProcessRestartBackoff) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt index ad7c32f3c..261d073f5 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt @@ -4,6 +4,7 @@ import android.os.SystemClock import io.nekohasekai.sagernet.SagerNet import io.nekohasekai.sagernet.bg.AbstractInstance import io.nekohasekai.sagernet.bg.GuardedProcessPool +import io.nekohasekai.sagernet.bg.GuardedProcessRestartPolicy import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.ProxyEntity import io.nekohasekai.sagernet.fmt.ConfigBuildResult @@ -44,7 +45,14 @@ abstract class BoxInstance( val pluginConfigs = hashMapOf>() val externalInstances = hashMapOf() open lateinit var processes: GuardedProcessPool + protected open val enableOlcrtcRecovery = true private var cacheFiles = ArrayList() + + private fun olcrtcReadyTimeoutMillis() = maxOf( + 60_000L, + DataStore.connectionTestTimeout.toLong(), + ) + fun isInitialized(): Boolean { return ::config.isInitialized && ::box.isInitialized } @@ -115,7 +123,7 @@ abstract class BoxInstance( // rather than start an unauthenticated listener if they are missing. val creds = config.localProxyCredentials[port] ?: error("olcRTC: missing loopback SOCKS credentials for port $port") - val readyTimeoutMs = maxOf(60_000L, DataStore.connectionTestTimeout.toLong()) + val readyTimeoutMs = olcrtcReadyTimeoutMillis() val args = bean.buildOlcrtcArgs( port, File(app.noBackupFilesDir, "protect_path").absolutePath, @@ -293,7 +301,21 @@ abstract class BoxInstance( // signal-based preemption can fault during the first protected dial // in the VpnService process context. val env = mutableMapOf("GODEBUG" to "asyncpreemptoff=1") - processes.start(commands, env) + if (enableOlcrtcRecovery) { + processes.start( + commands, + env, + onRestartCallback = { + awaitExternalPortReady( + port, + olcrtcReadyTimeoutMillis() + 5_000L, + ) + }, + restartPolicy = GuardedProcessRestartPolicy(), + ) + } else { + processes.start(commands, env) + } } } } @@ -302,6 +324,35 @@ abstract class BoxInstance( box.start() } + private suspend fun pendingExternalPorts(ports: Collection, timeoutMillis: Long) = + withContext(Dispatchers.IO) { + val deadline = SystemClock.elapsedRealtime() + timeoutMillis + val pending = ports.toMutableSet() + while (pending.isNotEmpty() && SystemClock.elapsedRealtime() < deadline) { + ensureActive() + val iterator = pending.iterator() + while (iterator.hasNext()) { + val port = iterator.next() + try { + Socket().use { + it.connect(InetSocketAddress(LOCALHOST, port), 100) + } + iterator.remove() + } catch (_: IOException) { + // not ready yet + } + } + if (pending.isNotEmpty()) delay(50) + } + pending + } + + private suspend fun awaitExternalPortReady(port: Int, timeoutMillis: Long) { + if (pendingExternalPorts(listOf(port), timeoutMillis).isNotEmpty()) { + throw IOException("sidecar listener not ready on port: $port") + } + } + /** * Waits until every external sidecar's local SOCKS listener is accepting connections * before the service reports Connected, so the sing-box socks outbound (and any @@ -348,52 +399,29 @@ abstract class BoxInstance( maxOf(1_000L, DataStore.connectionTestTimeout.toLong()) } - withContext(Dispatchers.IO) { - val deadline = SystemClock.elapsedRealtime() + readinessTimeoutMs - val pending = ports.toMutableSet() - while (pending.isNotEmpty() && SystemClock.elapsedRealtime() < deadline) { - // Honor cancellation promptly: if this start was superseded (reload/profile - // switch), the connect job is cancelled and the sidecars are torn down. Exiting - // here stops us from polling a now-dead port for the full (60s for MasterDnsVPN) - // window and then throwing a false "sidecar listener not ready". - ensureActive() - val iterator = pending.iterator() - while (iterator.hasNext()) { - val port = iterator.next() - try { - Socket().use { - it.connect(InetSocketAddress(LOCALHOST, port), 100) - } - iterator.remove() - } catch (_: IOException) { - // not ready yet - } - } - if (pending.isNotEmpty()) delay(50) + val pending = pendingExternalPorts(ports, readinessTimeoutMs) + if (pending.isNotEmpty()) { + // If the process pool is no longer active, its sidecars were torn down (e.g. a + // superseded start during reload). A port that never bound on a dead pool is an + // orphan, not a real failure - drop it instead of throwing. + if (!processes.isActive) { + Logs.w( + "sidecar listener not ready on port(s): ${pending.joinToString()}; " + + "process pool already stopped (superseded start), ignoring", + ) + return } - if (pending.isNotEmpty()) { - // If the process pool is no longer active, its sidecars were torn down (e.g. a - // superseded start during reload). A port that never bound on a dead pool is an - // orphan, not a real failure - drop it instead of throwing. - if (!processes.isActive) { - Logs.w( - "sidecar listener not ready on port(s): ${pending.joinToString()}; " + - "process pool already stopped (superseded start), ignoring", - ) - return@withContext - } - // MasterDnsVPN must have its listener up before the first dial (it crashed - // otherwise), so a timeout there is fatal. Other sidecars (Mieru/Naïve/ - // TrojanGo/Hysteria) were historically fire-and-forget: the first sing-box - // dial retries, so a slow bind shouldn't hard-fail VPN start - log and continue. - // For a URL test (strict), there is no retry window, so a listener that never - // binds is reported as a clear error instead of a flaky "connection refused". - val message = "sidecar listener not ready on port(s): ${pending.joinToString()}" - if (hasMasterDnsVpn || hasOlcrtc || strict) { - throw IOException(message) - } else { - Logs.w("$message; continuing (sing-box will retry the connection)") - } + // MasterDnsVPN must have its listener up before the first dial (it crashed + // otherwise), so a timeout there is fatal. Other sidecars (Mieru/Naïve/ + // TrojanGo/Hysteria) were historically fire-and-forget: the first sing-box + // dial retries, so a slow bind shouldn't hard-fail VPN start - log and continue. + // For a URL test (strict), there is no retry window, so a listener that never + // binds is reported as a clear error instead of a flaky "connection refused". + val message = "sidecar listener not ready on port(s): ${pending.joinToString()}" + if (hasMasterDnsVpn || hasOlcrtc || strict) { + throw IOException(message) + } else { + Logs.w("$message; continuing (sing-box will retry the connection)") } } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt index e4dced4fe..2a7514e21 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt @@ -16,6 +16,8 @@ import kotlin.coroutines.resumeWithException class TestInstance(profile: ProxyEntity, val link: String, private val timeout: Int) : BoxInstance(profile) { + protected override val enableOlcrtcRecovery = false + // close() can be reached from two paths that may overlap on cancellation: the // suspendCancellableCoroutine's invokeOnCancellation and the `use { }` block's // exit. BoxInstance.close() is not safe to run twice (native box.close()), so diff --git a/app/src/test/java/io/nekohasekai/sagernet/bg/GuardedProcessRestartPolicyTest.kt b/app/src/test/java/io/nekohasekai/sagernet/bg/GuardedProcessRestartPolicyTest.kt new file mode 100644 index 000000000..809617a59 --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/bg/GuardedProcessRestartPolicyTest.kt @@ -0,0 +1,62 @@ +package io.nekohasekai.sagernet.bg + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class GuardedProcessRestartPolicyTest { + + @Test + fun delayAfterExit_progressesAndCaps() { + val backoff = GuardedProcessRestartPolicy().createBackoff()!! + + assertEquals( + listOf(1_000L, 2_000L, 4_000L, 8_000L, 16_000L, 30_000L, 30_000L), + List(7) { backoff.delayAfterExit(readyDurationMillis = null) }, + ) + } + + @Test + fun delayAfterExit_resetsOnlyAfterStableReadyDuration() { + val backoff = GuardedProcessRestartPolicy().createBackoff()!! + + assertEquals(1_000L, backoff.delayAfterExit(readyDurationMillis = null)) + assertEquals(2_000L, backoff.delayAfterExit(readyDurationMillis = 59_999L)) + assertEquals(1_000L, backoff.delayAfterExit(readyDurationMillis = 60_000L)) + assertEquals(2_000L, backoff.delayAfterExit(readyDurationMillis = null)) + } + + @Test + fun delayAfterExit_capsWithoutOverflow() { + val policy = GuardedProcessRestartPolicy( + initialDelayMillis = Long.MAX_VALUE - 1L, + maximumDelayMillis = Long.MAX_VALUE, + stableAfterReadyMillis = 1L, + ) + val backoff = policy.createBackoff()!! + + assertEquals(Long.MAX_VALUE - 1L, backoff.delayAfterExit(readyDurationMillis = null)) + assertEquals(Long.MAX_VALUE, backoff.delayAfterExit(readyDurationMillis = null)) + } + + @Test + fun policy_rejectsInvalidBounds() { + assertThrows(IllegalArgumentException::class.java) { + GuardedProcessRestartPolicy(initialDelayMillis = 0L) + } + assertThrows(IllegalArgumentException::class.java) { + GuardedProcessRestartPolicy(initialDelayMillis = 2L, maximumDelayMillis = 1L) + } + assertThrows(IllegalArgumentException::class.java) { + GuardedProcessRestartPolicy(stableAfterReadyMillis = 0L) + } + } + + @Test + fun absentPolicy_hasNoBackoff() { + val policy: GuardedProcessRestartPolicy? = null + + assertNull(policy.createBackoff()) + } +} diff --git a/buildScript/lib/olcrtc-src/main.go b/buildScript/lib/olcrtc-src/main.go index 81f68cb4d..5dee54233 100644 --- a/buildScript/lib/olcrtc-src/main.go +++ b/buildScript/lib/olcrtc-src/main.go @@ -94,10 +94,32 @@ func main() { sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) - <-sig + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + defer signal.Stop(sig) + + if !waitAfterReady(sig, ticker.C, mobile.IsRunning) { + mobile.Stop() + log.Fatal("olcrtc runtime stopped after readiness") + } mobile.Stop() } +// waitAfterReady returns true for a requested signal shutdown and false when the +// already-ready mobile runtime reaches its terminal stopped state. +func waitAfterReady(signals <-chan os.Signal, ticks <-chan time.Time, isRunning func() bool) bool { + for { + select { + case <-signals: + return true + case <-ticks: + if !isRunning() { + return false + } + } + } +} + // installProtectedDefaults replaces net.DefaultResolver and http.DefaultTransport // so that (1) hostname lookups use dnsServer over a protected UDP/TCP socket // instead of Android's VPN fake-IP resolver, and (2) every TCP socket dialed by diff --git a/buildScript/lib/olcrtc-src/main_test.go b/buildScript/lib/olcrtc-src/main_test.go new file mode 100644 index 000000000..477a8a222 --- /dev/null +++ b/buildScript/lib/olcrtc-src/main_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "os" + "syscall" + "testing" + "time" +) + +func TestWaitAfterReadySignalIsGraceful(t *testing.T) { + signals := make(chan os.Signal, 1) + signals <- syscall.SIGTERM + + if !waitAfterReady(signals, make(chan time.Time), func() bool { return true }) { + t.Fatal("signal shutdown was reported as runtime failure") + } +} + +func TestWaitAfterReadyStopsOnFirstUnhealthyTick(t *testing.T) { + ticks := make(chan time.Time, 1) + ticks <- time.Now() + + if waitAfterReady(make(chan os.Signal), ticks, func() bool { return false }) { + t.Fatal("stopped runtime was reported as graceful shutdown") + } +} + +func TestWaitAfterReadyHealthyTicksContinue(t *testing.T) { + signals := make(chan os.Signal) + ticks := make(chan time.Time) + result := make(chan bool, 1) + checks := 0 + go func() { + result <- waitAfterReady(signals, ticks, func() bool { + checks++ + return true + }) + }() + + ticks <- time.Now() + signals <- syscall.SIGTERM + if graceful := <-result; !graceful { + t.Fatal("healthy runtime tick stopped the wait loop") + } + if checks != 1 { + t.Fatalf("running checks = %d, want 1", checks) + } +} diff --git a/buildScript/lib/olcrtc.sh b/buildScript/lib/olcrtc.sh index d5431e189..a38bd3462 100755 --- a/buildScript/lib/olcrtc.sh +++ b/buildScript/lib/olcrtc.sh @@ -81,8 +81,9 @@ fi BUILD="$(pwd)/.olcrtc-wrapper" rm -rf "$BUILD" mkdir -p "$BUILD" -cp "$SRC/main.go" "$SRC/go.mod" "$BUILD/" +cp "$SRC/main.go" "$SRC/main_test.go" "$SRC/go.mod" "$BUILD/" ( cd "$BUILD" && go mod edit -replace "github.com/openlibrecommunity/olcrtc=$WORK" && go mod tidy ) +( cd "$BUILD" && go test . ) build_abi() { local abi="$1" goarch="$2" cc="$3" goarm="$4" From 066f57761571e8863ba43e858517ab8f92b19aba Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:16:59 -0400 Subject: [PATCH 05/11] fix(olcrtc): harden restart readiness --- .../sagernet/bg/GuardedProcessPool.kt | 10 ++++- .../sagernet/bg/proto/BoxInstance.kt | 37 +++++++++++++--- .../bg/proto/SidecarReadinessPolicy.kt | 10 +++++ .../bg/proto/SidecarReadinessPolicyTest.kt | 44 +++++++++++++++++++ buildScript/lib/olcrtc-src/main.go | 37 ++++++++++++++++ buildScript/lib/olcrtc-src/main_test.go | 32 ++++++++++++++ 6 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt create mode 100644 app/src/test/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicyTest.kt diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt index 305ffcb78..55aac2211 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt @@ -139,7 +139,11 @@ class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : C } @DelicateCoroutinesApi - suspend fun looper(onRestartCallback: (suspend () -> Unit)?, restartPolicy: GuardedProcessRestartPolicy?) { + suspend fun looper( + onRestartPrepare: (() -> Unit)?, + onRestartCallback: (suspend () -> Unit)?, + restartPolicy: GuardedProcessRestartPolicy?, + ) { var running = true var restarted = false var currentExitChannel: Channel? = null @@ -176,6 +180,7 @@ class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : C (exitTime - it).coerceAtLeast(0L) } val restartDelayMillis = backoff?.delayAfterExit(readyDurationMillis) + onRestartPrepare?.invoke() if (restartDelayMillis != null) { Logs.i( "restart process after ${restartDelayMillis}ms: " + @@ -215,13 +220,14 @@ class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : C fun start( cmd: List, env: MutableMap = mutableMapOf(), + onRestartPrepare: (() -> Unit)? = null, onRestartCallback: (suspend () -> Unit)? = null, restartPolicy: GuardedProcessRestartPolicy? = null, ) { Logs.i("start process: ${Commandline.toRedactedString(cmd)}") Guard(cmd, env).apply { start() // if start fails, IOException will be thrown directly - launch { looper(onRestartCallback, restartPolicy) } + launch { looper(onRestartPrepare, onRestartCallback, restartPolicy) } } processCount += 1 } diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt index 261d073f5..ff8ed376b 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt @@ -46,6 +46,7 @@ abstract class BoxInstance( val externalInstances = hashMapOf() open lateinit var processes: GuardedProcessPool protected open val enableOlcrtcRecovery = true + private val olcrtcReadyMarkers = hashMapOf() private var cacheFiles = ArrayList() private fun olcrtcReadyTimeoutMillis() = maxOf( @@ -124,6 +125,8 @@ abstract class BoxInstance( val creds = config.localProxyCredentials[port] ?: error("olcRTC: missing loopback SOCKS credentials for port $port") val readyTimeoutMs = olcrtcReadyTimeoutMillis() + val readyMarker = File(app.noBackupFilesDir, "olcrtc_ready_$port") + olcrtcReadyMarkers[port] = readyMarker val args = bean.buildOlcrtcArgs( port, File(app.noBackupFilesDir, "protect_path").absolutePath, @@ -132,7 +135,7 @@ abstract class BoxInstance( DataStore.logLevel >= 3, "9.9.9.9:53", readyTimeoutMs, - ) + ) + listOf("-ready-marker", readyMarker.absolutePath) pluginConfigs[port] = profile.type to args.joinToString("\u0000") } } @@ -301,10 +304,13 @@ abstract class BoxInstance( // signal-based preemption can fault during the first protected dial // in the VpnService process context. val env = mutableMapOf("GODEBUG" to "asyncpreemptoff=1") + val readyMarker = olcrtcReadyMarkers.getValue(port) + clearOlcrtcReadyMarker(readyMarker) if (enableOlcrtcRecovery) { processes.start( commands, env, + onRestartPrepare = { clearOlcrtcReadyMarker(readyMarker) }, onRestartCallback = { awaitExternalPortReady( port, @@ -324,6 +330,12 @@ abstract class BoxInstance( box.start() } + private fun clearOlcrtcReadyMarker(marker: File) { + if (marker.exists() && !marker.delete() && marker.exists()) { + throw IOException("olcRTC: could not reset readiness marker") + } + } + private suspend fun pendingExternalPorts(ports: Collection, timeoutMillis: Long) = withContext(Dispatchers.IO) { val deadline = SystemClock.elapsedRealtime() + timeoutMillis @@ -333,6 +345,8 @@ abstract class BoxInstance( val iterator = pending.iterator() while (iterator.hasNext()) { val port = iterator.next() + val readyMarker = olcrtcReadyMarkers[port] + if (!readinessMarkerSatisfied(readyMarker != null, readyMarker?.isFile == true)) continue try { Socket().use { it.connect(InetSocketAddress(LOCALHOST, port), 100) @@ -359,9 +373,9 @@ abstract class BoxInstance( * connection test) doesn't race a sidecar that hasn't bound its port yet. * * Most sidecars open their listener immediately, so the short connection-test timeout - * is sufficient. MasterDnsVPN is the exception: it only starts listening after DNS - * MTU probing and session setup, which can take tens of seconds (with retries) on - * lossy or restricted links, so it gets a longer readiness window. + * is sufficient. MasterDnsVPN and olcRTC are exceptions: they only start listening + * after carrier setup, which can take tens of seconds (with retries) on lossy or + * restricted links, so they get a longer readiness window. * * @param strict when true (URL test), a sidecar that never binds is a hard failure with * a clear message, instead of the live-service behavior of logging and continuing @@ -411,14 +425,22 @@ abstract class BoxInstance( ) return } - // MasterDnsVPN must have its listener up before the first dial (it crashed - // otherwise), so a timeout there is fatal. Other sidecars (Mieru/Naïve/ + // MasterDnsVPN and olcRTC must have their listeners up before the first dial, + // so a timeout on either is fatal. Other sidecars (Mieru/Naïve/ // TrojanGo/Hysteria) were historically fire-and-forget: the first sing-box // dial retries, so a slow bind shouldn't hard-fail VPN start - log and continue. // For a URL test (strict), there is no retry window, so a listener that never // binds is reported as a clear error instead of a flaky "connection refused". val message = "sidecar listener not ready on port(s): ${pending.joinToString()}" - if (hasMasterDnsVpn || hasOlcrtc || strict) { + val requiredPorts = config.externalIndex.flatMap { idx -> + idx.chain.mapNotNull { (port, profile) -> + when (profile.requireBean()) { + is MasterDnsVpnBean, is OlcrtcBean -> port + else -> null + } + } + }.toSet() + if (shouldFailSidecarReadiness(pending, requiredPorts, strict)) { throw IOException(message) } else { Logs.w("$message; continuing (sing-box will retry the connection)") @@ -440,6 +462,7 @@ abstract class BoxInstance( } if (::processes.isInitialized) processes.close(GlobalScope + Dispatchers.IO) + olcrtcReadyMarkers.values.forEach { it.delete() } if (::box.isInitialized) { box.close() diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt new file mode 100644 index 000000000..659dd6160 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt @@ -0,0 +1,10 @@ +package io.nekohasekai.sagernet.bg.proto + +internal fun readinessMarkerSatisfied(markerRequired: Boolean, markerPresent: Boolean) = + !markerRequired || markerPresent + +internal fun shouldFailSidecarReadiness( + pendingPorts: Set, + requiredPorts: Set, + strict: Boolean, +) = strict || pendingPorts.any { it in requiredPorts } diff --git a/app/src/test/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicyTest.kt b/app/src/test/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicyTest.kt new file mode 100644 index 000000000..6bc78dffb --- /dev/null +++ b/app/src/test/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicyTest.kt @@ -0,0 +1,44 @@ +package io.nekohasekai.sagernet.bg.proto + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SidecarReadinessPolicyTest { + + @Test + fun markerIsRequiredOnlyForMarkedSidecars() { + assertTrue(readinessMarkerSatisfied(markerRequired = false, markerPresent = false)) + assertTrue(readinessMarkerSatisfied(markerRequired = true, markerPresent = true)) + assertFalse(readinessMarkerSatisfied(markerRequired = true, markerPresent = false)) + } + + @Test + fun unrelatedPendingPortDoesNotMakeMixedChainFatal() { + assertFalse( + shouldFailSidecarReadiness( + pendingPorts = setOf(20001), + requiredPorts = setOf(20002), + strict = false, + ), + ) + } + + @Test + fun requiredOrStrictPendingPortIsFatal() { + assertTrue( + shouldFailSidecarReadiness( + pendingPorts = setOf(20001, 20002), + requiredPorts = setOf(20002), + strict = false, + ), + ) + assertTrue( + shouldFailSidecarReadiness( + pendingPorts = setOf(20001), + requiredPorts = emptySet(), + strict = true, + ), + ) + } +} diff --git a/buildScript/lib/olcrtc-src/main.go b/buildScript/lib/olcrtc-src/main.go index 5dee54233..d68dcc732 100644 --- a/buildScript/lib/olcrtc-src/main.go +++ b/buildScript/lib/olcrtc-src/main.go @@ -34,6 +34,7 @@ import ( "net/netip" "os" "os/signal" + "path/filepath" "syscall" "time" @@ -56,11 +57,13 @@ func main() { vp8FPS = flag.Int("vp8-fps", 30, "vp8 fps") vp8Batch = flag.Int("vp8-batch", 8, "vp8 batch size") protectPath = flag.String("protect-path", "", "path to libcore protect unix socket") + readyMarker = flag.String("ready-marker", "", "app-private readiness marker path") readyMillis = flag.Int("ready-timeout-ms", 60000, "readiness wait in ms") debug = flag.Bool("debug", false, "verbose logging") ) flag.Parse() + removeReadyMarker(*readyMarker) mobile.SetDebug(*debug) if !*debug { // Quiet by default so room ids / carrier urls are not written to logs. @@ -91,6 +94,11 @@ func main() { mobile.Stop() log.Fatalf("olcrtc wait ready: %v", err) } + if err := publishReadyMarker(*readyMarker); err != nil { + mobile.Stop() + log.Fatalf("olcrtc ready marker: %v", err) + } + defer removeReadyMarker(*readyMarker) sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) @@ -120,6 +128,35 @@ func waitAfterReady(signals <-chan os.Signal, ticks <-chan time.Time, isRunning } } +// publishReadyMarker atomically proves that this app-owned wrapper, rather than +// an unrelated loopback listener, reached mobile readiness. The parent keeps the +// marker in its private no-backup directory and clears it before each generation. +func publishReadyMarker(path string) error { + if path == "" { + return nil + } + temp, err := os.CreateTemp(filepath.Dir(path), ".olcrtc-ready-*") + if err != nil { + return err + } + tempPath := temp.Name() + defer os.Remove(tempPath) + if _, err := temp.WriteString("ready\n"); err != nil { + temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + return os.Rename(tempPath, path) +} + +func removeReadyMarker(path string) { + if path != "" { + _ = os.Remove(path) + } +} + // installProtectedDefaults replaces net.DefaultResolver and http.DefaultTransport // so that (1) hostname lookups use dnsServer over a protected UDP/TCP socket // instead of Android's VPN fake-IP resolver, and (2) every TCP socket dialed by diff --git a/buildScript/lib/olcrtc-src/main_test.go b/buildScript/lib/olcrtc-src/main_test.go index 477a8a222..3505ace0b 100644 --- a/buildScript/lib/olcrtc-src/main_test.go +++ b/buildScript/lib/olcrtc-src/main_test.go @@ -2,6 +2,7 @@ package main import ( "os" + "path/filepath" "syscall" "testing" "time" @@ -25,6 +26,37 @@ func TestWaitAfterReadyStopsOnFirstUnhealthyTick(t *testing.T) { } } +func TestPublishReadyMarkerReplacesStaleMarker(t *testing.T) { + path := filepath.Join(t.TempDir(), "ready") + if err := os.WriteFile(path, []byte("stale\n"), 0o600); err != nil { + t.Fatal(err) + } + + if err := publishReadyMarker(path); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(content) != "ready\n" { + t.Fatalf("marker content = %q, want ready", content) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if mode := info.Mode().Perm(); mode != 0o600 { + t.Fatalf("marker mode = %o, want 600", mode) + } +} + +func TestPublishReadyMarkerAllowsDisabledMarker(t *testing.T) { + if err := publishReadyMarker(""); err != nil { + t.Fatal(err) + } +} + func TestWaitAfterReadyHealthyTicksContinue(t *testing.T) { signals := make(chan os.Signal) ticks := make(chan time.Time) From 2222e72aa1b1ec923e95d80c480022299e045fa0 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:26:09 -0400 Subject: [PATCH 06/11] style: apply sidecar readiness formatting --- .../sagernet/bg/proto/SidecarReadinessPolicy.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt index 659dd6160..e52076bfd 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt @@ -3,8 +3,5 @@ package io.nekohasekai.sagernet.bg.proto internal fun readinessMarkerSatisfied(markerRequired: Boolean, markerPresent: Boolean) = !markerRequired || markerPresent -internal fun shouldFailSidecarReadiness( - pendingPorts: Set, - requiredPorts: Set, - strict: Boolean, -) = strict || pendingPorts.any { it in requiredPorts } +internal fun shouldFailSidecarReadiness(pendingPorts: Set, requiredPorts: Set, strict: Boolean) = + strict || pendingPorts.any { it in requiredPorts } From deb1b4c01226a0fb166c68ea37847b57178a1134 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:32:17 -0400 Subject: [PATCH 07/11] fix: handle empty sidecar readiness sets --- .../sagernet/bg/proto/SidecarReadinessPolicy.kt | 2 +- .../sagernet/bg/proto/SidecarReadinessPolicyTest.kt | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt index e52076bfd..d712e956f 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt @@ -4,4 +4,4 @@ internal fun readinessMarkerSatisfied(markerRequired: Boolean, markerPresent: Bo !markerRequired || markerPresent internal fun shouldFailSidecarReadiness(pendingPorts: Set, requiredPorts: Set, strict: Boolean) = - strict || pendingPorts.any { it in requiredPorts } + pendingPorts.isNotEmpty() && (strict || pendingPorts.any { it in requiredPorts }) diff --git a/app/src/test/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicyTest.kt b/app/src/test/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicyTest.kt index 6bc78dffb..46b7a69c1 100644 --- a/app/src/test/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicyTest.kt +++ b/app/src/test/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicyTest.kt @@ -24,6 +24,17 @@ class SidecarReadinessPolicyTest { ) } + @Test + fun strictModeWithNoPendingPortIsNotFatal() { + assertFalse( + shouldFailSidecarReadiness( + pendingPorts = emptySet(), + requiredPorts = emptySet(), + strict = true, + ), + ) + } + @Test fun requiredOrStrictPendingPortIsFatal() { assertTrue( From bfd93dc80e46e7a8d8c2fe40aa04b6055ff3a032 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:34:52 -0400 Subject: [PATCH 08/11] fix(olcrtc): harden combined readiness lifecycle --- .depot/workflows/build-apk.yml | 1 + .github/workflows/build.yml | 1 + .github/workflows/preview.yml | 1 + .github/workflows/release.yml | 1 + .../sagernet/bg/GuardedProcessPool.kt | 42 +++++++++++++------ .../bg/GuardedProcessRestartPolicy.kt | 6 +++ .../sagernet/bg/proto/BoxInstance.kt | 9 +++- .../bg/proto/SidecarReadinessPolicy.kt | 3 ++ .../sagernet/fmt/olcrtc/OlcrtcFmt.kt | 8 ++-- .../bg/GuardedProcessRestartPolicyTest.kt | 38 +++++++++++++++++ .../bg/proto/SidecarReadinessPolicyTest.kt | 9 ++++ .../nekohasekai/sagernet/fmt/OlcrtcFmtTest.kt | 7 ++++ buildScript/lib/olcrtc-src/main.go | 2 + 13 files changed, 111 insertions(+), 17 deletions(-) diff --git a/.depot/workflows/build-apk.yml b/.depot/workflows/build-apk.yml index 104cdf514..664b0a6da 100644 --- a/.depot/workflows/build-apk.yml +++ b/.depot/workflows/build-apk.yml @@ -64,6 +64,7 @@ jobs: buildScript/lib/naive.sh \ buildScript/lib/olcrtc.sh \ buildScript/lib/olcrtc-src/main.go \ + buildScript/lib/olcrtc-src/main_test.go \ buildScript/lib/olcrtc-src/go.mod \ buildScript/init/env.sh \ buildScript/init/env_ndk.sh \ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 54ad04d3c..a9a04caa4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -64,6 +64,7 @@ jobs: buildScript/lib/naive.sh \ buildScript/lib/olcrtc.sh \ buildScript/lib/olcrtc-src/main.go \ + buildScript/lib/olcrtc-src/main_test.go \ buildScript/lib/olcrtc-src/go.mod \ buildScript/init/env.sh \ buildScript/init/env_ndk.sh \ diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 60e3f5282..ea0894bca 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -60,6 +60,7 @@ jobs: buildScript/lib/naive.sh \ buildScript/lib/olcrtc.sh \ buildScript/lib/olcrtc-src/main.go \ + buildScript/lib/olcrtc-src/main_test.go \ buildScript/lib/olcrtc-src/go.mod \ buildScript/init/env.sh \ buildScript/init/env_ndk.sh \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0b2de9ac..91a7bda61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,6 +68,7 @@ jobs: buildScript/lib/naive.sh \ buildScript/lib/olcrtc.sh \ buildScript/lib/olcrtc-src/main.go \ + buildScript/lib/olcrtc-src/main_test.go \ buildScript/lib/olcrtc-src/go.mod \ buildScript/init/env.sh \ buildScript/init/env_ndk.sh \ diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt index 55aac2211..cf54b8ba1 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt @@ -118,22 +118,28 @@ class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : C } } + private fun signalProcess(signal: Int) { + try { + Os.kill(pid.get(process) as Int, signal) + } catch (e: ErrnoException) { + if (e.errno != OsConstants.ESRCH) Logs.w(e) + } catch (e: ReflectiveOperationException) { + Logs.w(e) + } + } + private suspend fun terminateProcess(exitChannel: Channel): Int? = withContext(NonCancellable) { exitChannel.tryReceive().getOrNull()?.let { return@withContext it } if (Build.VERSION.SDK_INT < 24) { - try { - Os.kill(pid.get(process) as Int, OsConstants.SIGTERM) - } catch (e: ErrnoException) { - if (e.errno != OsConstants.ESRCH) Logs.w(e) - } catch (e: ReflectiveOperationException) { - Logs.w(e) - } + signalProcess(OsConstants.SIGTERM) withTimeoutOrNull(500) { exitChannel.receive() }?.let { return@withContext it } } process.destroy() + withTimeoutOrNull(1000) { exitChannel.receive() }?.let { return@withContext it } if (Build.VERSION.SDK_INT >= 26) { - withTimeoutOrNull(1000) { exitChannel.receive() }?.let { return@withContext it } process.destroyForcibly() + } else { + signalProcess(OsConstants.SIGKILL) } withTimeoutOrNull(1000) { exitChannel.receive() } } @@ -143,6 +149,7 @@ class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : C onRestartPrepare: (() -> Unit)?, onRestartCallback: (suspend () -> Unit)?, restartPolicy: GuardedProcessRestartPolicy?, + restartOnExit: Boolean, ) { var running = true var restarted = false @@ -166,8 +173,8 @@ class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : C val exitTime = SystemClock.elapsedRealtime() val processUptimeMillis = exitTime - startTime - if (restartPolicy == null && processUptimeMillis < 1000L) { - throw IOException("$cmdName exits too fast (exit code: ${generation.exitCode})") + if (shouldFailAfterProcessExit(restartOnExit, restartPolicy, processUptimeMillis)) { + throw IOException("$cmdName exited (exit code: ${generation.exitCode})") } when (generation.exitCode) { 128 + OsConstants.SIGKILL -> Logs.w("$cmdName was killed") @@ -180,7 +187,17 @@ class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : C (exitTime - it).coerceAtLeast(0L) } val restartDelayMillis = backoff?.delayAfterExit(readyDurationMillis) - onRestartPrepare?.invoke() + try { + onRestartPrepare?.invoke() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + throw if (e is IOException) { + e + } else { + IOException("$cmdName restart preparation failed", e) + } + } if (restartDelayMillis != null) { Logs.i( "restart process after ${restartDelayMillis}ms: " + @@ -223,11 +240,12 @@ class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : C onRestartPrepare: (() -> Unit)? = null, onRestartCallback: (suspend () -> Unit)? = null, restartPolicy: GuardedProcessRestartPolicy? = null, + restartOnExit: Boolean = true, ) { Logs.i("start process: ${Commandline.toRedactedString(cmd)}") Guard(cmd, env).apply { start() // if start fails, IOException will be thrown directly - launch { looper(onRestartPrepare, onRestartCallback, restartPolicy) } + launch { looper(onRestartPrepare, onRestartCallback, restartPolicy, restartOnExit) } } processCount += 1 } diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessRestartPolicy.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessRestartPolicy.kt index cba44092a..e3d63e7f3 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessRestartPolicy.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessRestartPolicy.kt @@ -34,3 +34,9 @@ internal class GuardedProcessRestartBackoff( } internal fun GuardedProcessRestartPolicy?.createBackoff() = this?.let(::GuardedProcessRestartBackoff) + +internal fun shouldFailAfterProcessExit( + restartOnExit: Boolean, + restartPolicy: GuardedProcessRestartPolicy?, + processUptimeMillis: Long, +) = !restartOnExit || (restartPolicy == null && processUptimeMillis < 1_000L) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt index ff8ed376b..0e283dab4 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt @@ -33,6 +33,7 @@ import java.io.File import java.io.IOException import java.net.InetSocketAddress import java.net.Socket +import java.util.UUID abstract class BoxInstance( val profile: ProxyEntity, @@ -47,6 +48,7 @@ abstract class BoxInstance( open lateinit var processes: GuardedProcessPool protected open val enableOlcrtcRecovery = true private val olcrtcReadyMarkers = hashMapOf() + private val olcrtcReadyMarkerOwner = UUID.randomUUID().toString() private var cacheFiles = ArrayList() private fun olcrtcReadyTimeoutMillis() = maxOf( @@ -125,7 +127,10 @@ abstract class BoxInstance( val creds = config.localProxyCredentials[port] ?: error("olcRTC: missing loopback SOCKS credentials for port $port") val readyTimeoutMs = olcrtcReadyTimeoutMillis() - val readyMarker = File(app.noBackupFilesDir, "olcrtc_ready_$port") + val readyMarker = File( + app.noBackupFilesDir, + olcrtcReadyMarkerFileName(port, olcrtcReadyMarkerOwner), + ) olcrtcReadyMarkers[port] = readyMarker val args = bean.buildOlcrtcArgs( port, @@ -320,7 +325,7 @@ abstract class BoxInstance( restartPolicy = GuardedProcessRestartPolicy(), ) } else { - processes.start(commands, env) + processes.start(commands, env, restartOnExit = false) } } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt index d712e956f..6d0eb46a2 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt @@ -1,5 +1,8 @@ package io.nekohasekai.sagernet.bg.proto +internal fun olcrtcReadyMarkerFileName(port: Int, ownerToken: String) = + "olcrtc_ready_${port}_$ownerToken" + internal fun readinessMarkerSatisfied(markerRequired: Boolean, markerPresent: Boolean) = !markerRequired || markerPresent diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/olcrtc/OlcrtcFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/olcrtc/OlcrtcFmt.kt index 79fd014f9..1484104d0 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/olcrtc/OlcrtcFmt.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/olcrtc/OlcrtcFmt.kt @@ -176,9 +176,11 @@ fun OlcrtcBean.toUri(): String { require(shareClientId.none { it in DELIMITERS }) { "olcRTC: client id contains a reserved delimiter" } - // roomId is emitted raw before '#'; a '$' in it would be mis-parsed as the comment - // delimiter on re-import. Refuse rather than emit a link that won't round-trip. - require(room.none { it == '$' }) { "olcRTC: room id must not contain '\$'" } + // roomId is emitted raw before '#'. A '$' would be mis-parsed as the comment delimiter, + // while '<' would be mistaken for the transport payload opener on re-import. + require(room.none { it == '$' || it == '<' }) { + "olcRTC: room id contains a reserved delimiter" + } require(profileName.none { it == '$' }) { "olcRTC: profile name must not contain '\$'" } val payloadParts = mutableListOf() diff --git a/app/src/test/java/io/nekohasekai/sagernet/bg/GuardedProcessRestartPolicyTest.kt b/app/src/test/java/io/nekohasekai/sagernet/bg/GuardedProcessRestartPolicyTest.kt index 809617a59..af15a4db8 100644 --- a/app/src/test/java/io/nekohasekai/sagernet/bg/GuardedProcessRestartPolicyTest.kt +++ b/app/src/test/java/io/nekohasekai/sagernet/bg/GuardedProcessRestartPolicyTest.kt @@ -1,8 +1,10 @@ package io.nekohasekai.sagernet.bg import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue import org.junit.Test class GuardedProcessRestartPolicyTest { @@ -59,4 +61,40 @@ class GuardedProcessRestartPolicyTest { assertNull(policy.createBackoff()) } + + @Test + fun disabledRestartFailsOnEveryExit() { + assertTrue( + shouldFailAfterProcessExit( + restartOnExit = false, + restartPolicy = null, + processUptimeMillis = 60_000L, + ), + ) + } + + @Test + fun legacyAndPolicyRestartDecisionsRemainUnchanged() { + assertTrue( + shouldFailAfterProcessExit( + restartOnExit = true, + restartPolicy = null, + processUptimeMillis = 999L, + ), + ) + assertFalse( + shouldFailAfterProcessExit( + restartOnExit = true, + restartPolicy = null, + processUptimeMillis = 1_000L, + ), + ) + assertFalse( + shouldFailAfterProcessExit( + restartOnExit = true, + restartPolicy = GuardedProcessRestartPolicy(), + processUptimeMillis = 0L, + ), + ) + } } diff --git a/app/src/test/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicyTest.kt b/app/src/test/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicyTest.kt index 46b7a69c1..4a0069aab 100644 --- a/app/src/test/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicyTest.kt +++ b/app/src/test/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicyTest.kt @@ -1,11 +1,20 @@ package io.nekohasekai.sagernet.bg.proto import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue import org.junit.Test class SidecarReadinessPolicyTest { + @Test + fun markerNamesAreOwnedByOneBoxGeneration() { + assertNotEquals( + olcrtcReadyMarkerFileName(1080, "owner-a"), + olcrtcReadyMarkerFileName(1080, "owner-b"), + ) + } + @Test fun markerIsRequiredOnlyForMarkedSidecars() { assertTrue(readinessMarkerSatisfied(markerRequired = false, markerPresent = false)) diff --git a/app/src/test/java/io/nekohasekai/sagernet/fmt/OlcrtcFmtTest.kt b/app/src/test/java/io/nekohasekai/sagernet/fmt/OlcrtcFmtTest.kt index e0923d5fc..1127c36a2 100644 --- a/app/src/test/java/io/nekohasekai/sagernet/fmt/OlcrtcFmtTest.kt +++ b/app/src/test/java/io/nekohasekai/sagernet/fmt/OlcrtcFmtTest.kt @@ -256,6 +256,13 @@ class OlcrtcFmtTest { } } + @Test + fun toUri_rejectsRoomPayloadDelimiter() { + assertThrows(IllegalArgumentException::class.java) { + validBean(roomId = "review<4821").toUri() + } + } + @Test fun toUri_rejectsUnsupportedCarrierTransportCombination() { assertThrows(IllegalArgumentException::class.java) { diff --git a/buildScript/lib/olcrtc-src/main.go b/buildScript/lib/olcrtc-src/main.go index d68dcc732..281789c80 100644 --- a/buildScript/lib/olcrtc-src/main.go +++ b/buildScript/lib/olcrtc-src/main.go @@ -96,6 +96,7 @@ func main() { } if err := publishReadyMarker(*readyMarker); err != nil { mobile.Stop() + removeReadyMarker(*readyMarker) log.Fatalf("olcrtc ready marker: %v", err) } defer removeReadyMarker(*readyMarker) @@ -108,6 +109,7 @@ func main() { if !waitAfterReady(sig, ticker.C, mobile.IsRunning) { mobile.Stop() + removeReadyMarker(*readyMarker) log.Fatal("olcrtc runtime stopped after readiness") } mobile.Stop() From ab4a02165cbf3daf5ab89441a9c36841c23fec90 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:41:45 -0400 Subject: [PATCH 09/11] style: format readiness marker helper --- .../io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt index 6d0eb46a2..af2ebaccd 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt @@ -1,7 +1,6 @@ package io.nekohasekai.sagernet.bg.proto -internal fun olcrtcReadyMarkerFileName(port: Int, ownerToken: String) = - "olcrtc_ready_${port}_$ownerToken" +internal fun olcrtcReadyMarkerFileName(port: Int, ownerToken: String) = "olcrtc_ready_${port}_$ownerToken" internal fun readinessMarkerSatisfied(markerRequired: Boolean, markerPresent: Boolean) = !markerRequired || markerPresent From 969146509d7635c54ca554f6fc30ee2074c2c295 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:18:13 -0400 Subject: [PATCH 10/11] fix: align sidecar readiness checks --- .../sagernet/bg/proto/BoxInstance.kt | 18 +++++++++---- .../bg/proto/SidecarReadinessPolicy.kt | 5 ++++ .../bg/proto/SidecarReadinessPolicyTest.kt | 26 +++++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt index 0e283dab4..33a670635 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/BoxInstance.kt @@ -51,9 +51,9 @@ abstract class BoxInstance( private val olcrtcReadyMarkerOwner = UUID.randomUUID().toString() private var cacheFiles = ArrayList() - private fun olcrtcReadyTimeoutMillis() = maxOf( - 60_000L, - DataStore.connectionTestTimeout.toLong(), + private fun olcrtcReadyTimeoutMillis() = olcrtcSidecarReadyTimeoutMillis( + configuredTimeoutMillis = DataStore.connectionTestTimeout.toLong(), + recoveryEnabled = enableOlcrtcRecovery, ) fun isInitialized(): Boolean { @@ -345,8 +345,13 @@ abstract class BoxInstance( withContext(Dispatchers.IO) { val deadline = SystemClock.elapsedRealtime() + timeoutMillis val pending = ports.toMutableSet() - while (pending.isNotEmpty() && SystemClock.elapsedRealtime() < deadline) { + while ( + pending.isNotEmpty() && + SystemClock.elapsedRealtime() < deadline && + processes.isActive + ) { ensureActive() + if (!processes.isActive) break val iterator = pending.iterator() while (iterator.hasNext()) { val port = iterator.next() @@ -361,7 +366,10 @@ abstract class BoxInstance( // not ready yet } } - if (pending.isNotEmpty()) delay(50) + if (pending.isNotEmpty()) { + if (!processes.isActive) break + delay(50) + } } pending } diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt index af2ebaccd..a755b2814 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt @@ -2,6 +2,11 @@ package io.nekohasekai.sagernet.bg.proto internal fun olcrtcReadyMarkerFileName(port: Int, ownerToken: String) = "olcrtc_ready_${port}_$ownerToken" +internal fun olcrtcSidecarReadyTimeoutMillis( + configuredTimeoutMillis: Long, + recoveryEnabled: Boolean, +) = maxOf(if (recoveryEnabled) 60_000L else 15_000L, configuredTimeoutMillis) + internal fun readinessMarkerSatisfied(markerRequired: Boolean, markerPresent: Boolean) = !markerRequired || markerPresent diff --git a/app/src/test/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicyTest.kt b/app/src/test/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicyTest.kt index 4a0069aab..fa170a0cc 100644 --- a/app/src/test/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicyTest.kt +++ b/app/src/test/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicyTest.kt @@ -1,5 +1,6 @@ package io.nekohasekai.sagernet.bg.proto +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue @@ -15,6 +16,31 @@ class SidecarReadinessPolicyTest { ) } + @Test + fun olcrtcReadyTimeoutMatchesExecutionMode() { + assertEquals( + 60_000L, + olcrtcSidecarReadyTimeoutMillis( + configuredTimeoutMillis = 3_000L, + recoveryEnabled = true, + ), + ) + assertEquals( + 15_000L, + olcrtcSidecarReadyTimeoutMillis( + configuredTimeoutMillis = 3_000L, + recoveryEnabled = false, + ), + ) + assertEquals( + 90_000L, + olcrtcSidecarReadyTimeoutMillis( + configuredTimeoutMillis = 90_000L, + recoveryEnabled = false, + ), + ) + } + @Test fun markerIsRequiredOnlyForMarkedSidecars() { assertTrue(readinessMarkerSatisfied(markerRequired = false, markerPresent = false)) From 23b292d846e4803959f4621f0dbeb9fca766f06e Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:35:09 -0400 Subject: [PATCH 11/11] style: format readiness timeout helper --- .../nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt index a755b2814..49d473cb9 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/proto/SidecarReadinessPolicy.kt @@ -2,10 +2,8 @@ package io.nekohasekai.sagernet.bg.proto internal fun olcrtcReadyMarkerFileName(port: Int, ownerToken: String) = "olcrtc_ready_${port}_$ownerToken" -internal fun olcrtcSidecarReadyTimeoutMillis( - configuredTimeoutMillis: Long, - recoveryEnabled: Boolean, -) = maxOf(if (recoveryEnabled) 60_000L else 15_000L, configuredTimeoutMillis) +internal fun olcrtcSidecarReadyTimeoutMillis(configuredTimeoutMillis: Long, recoveryEnabled: Boolean) = + maxOf(if (recoveryEnabled) 60_000L else 15_000L, configuredTimeoutMillis) internal fun readinessMarkerSatisfied(markerRequired: Boolean, markerPresent: Boolean) = !markerRequired || markerPresent