Skip to content
5 changes: 3 additions & 2 deletions .depot/workflows/build-apk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 \
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 \
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 \
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 \
Expand Down
219 changes: 166 additions & 53 deletions app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -43,78 +54,177 @@ class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : C
}.start()
}

private fun watchProcess(cmdName: String, exitChannel: Channel<Int>) {
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<Int>,
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 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>): Int? = withContext(NonCancellable) {
exitChannel.tryReceive().getOrNull()?.let { return@withContext it }
if (Build.VERSION.SDK_INT < 24) {
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) {
process.destroyForcibly()
} else {
signalProcess(OsConstants.SIGKILL)
}
withTimeoutOrNull(1000) { exitChannel.receive() }
}

@DelicateCoroutinesApi
suspend fun looper(onRestartCallback: (suspend () -> Unit)?) {
suspend fun looper(
onRestartPrepare: (() -> Unit)?,
onRestartCallback: (suspend () -> Unit)?,
restartPolicy: GuardedProcessRestartPolicy?,
restartOnExit: Boolean,
) {
var running = true
var restarted = false
var currentExitChannel: Channel<Int>? = null
val cmdName = File(cmd.first()).nameWithoutExtension
val exitChannel = Channel<Int>()
val backoff = restartPolicy.createBackoff()
try {
while (true) {
thread(name = "stderr-$cmdName") {
streamLogger(process.errorStream) {
Libcore.nekoLogPrintln("[$cmdName] ${Commandline.redactProcessOutput(it)}")
}
val exitChannel = Channel<Int>(capacity = 1)
currentExitChannel = exitChannel
watchProcess(cmdName, exitChannel)
val startTime = SystemClock.elapsedRealtime()
val generation = if (restarted && onRestartCallback != null) {
observeRestart(cmdName, exitChannel, onRestartCallback)
} else {
ProcessGenerationExit(exitChannel.receive())
}
thread(name = "stdout-$cmdName") {
streamLogger(process.inputStream) {
Libcore.nekoLogPrintln("[$cmdName] ${Commandline.redactProcessOutput(it)}")
}
running = false
currentExitChannel = null
exitChannel.close()

val exitTime = SystemClock.elapsedRealtime()
val processUptimeMillis = exitTime - startTime
if (shouldFailAfterProcessExit(restartOnExit, restartPolicy, processUptimeMillis)) {
throw IOException("$cmdName exited (exit code: ${generation.exitCode})")
}
// 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)")
when (generation.exitCode) {
128 + OsConstants.SIGKILL -> Logs.w("$cmdName was killed")
else -> Logs.w(
IOException("$cmdName unexpectedly exits with code ${generation.exitCode}"),
)
}

val readyDurationMillis = generation.readyAtMillis?.let {
(exitTime - it).coerceAtLeast(0L)
}
val restartDelayMillis = backoff?.delayAfterExit(readyDurationMillis)
try {
onRestartPrepare?.invoke()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
throw if (e is IOException) {
e
} else {
IOException("$cmdName restart preparation failed", e)
}
}
val startTime = SystemClock.elapsedRealtime()
val exitCode = exitChannel.receive()
running = false
when {
SystemClock.elapsedRealtime() - startTime < 1000 -> throw IOException(
"$cmdName exits too fast (exit code: $exitCode)",
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})",
)

exitCode == 128 + OsConstants.SIGKILL -> Logs.w("$cmdName was killed")
else -> Logs.w(IOException("$cmdName unexpectedly exits with code $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)}")
// Structured (cancelled with the pool) so a torn-down pool can't fire onFatal
// 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()
}
}
}
Expand All @@ -127,12 +237,15 @@ class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : C
fun start(
cmd: List<String>,
env: MutableMap<String, String> = mutableMapOf(),
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(onRestartCallback) }
launch { looper(onRestartPrepare, onRestartCallback, restartPolicy, restartOnExit) }
}
processCount += 1
}
Expand Down
Loading
Loading