Browse Source

恢复正则超时强制重启

5151561 3 months ago
parent
commit
e8c20cddff

+ 43 - 79
app/src/main/java/io/legado/app/utils/RegexExtensions.kt

@@ -1,5 +1,6 @@
 package io.legado.app.utils
 
+import androidx.core.os.postDelayed
 import com.script.ScriptBindings
 import com.script.rhino.RhinoScriptEngine
 import io.legado.app.data.appDb
@@ -7,13 +8,15 @@ import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.exception.RegexTimeoutException
 import io.legado.app.help.CrashHandler
+import io.legado.app.help.coroutine.Coroutine
 import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.TimeoutCancellationException
-import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.runInterruptible
-import kotlinx.coroutines.withTimeout
+import kotlinx.coroutines.suspendCancellableCoroutine
 import splitties.init.appCtx
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+private val handler by lazy { buildMainHandler() }
 
 /**
  * 带有超时检测的正则替换
@@ -36,87 +39,48 @@ fun CharSequence.replace(
         null
     }
     return runBlocking {
-        try {
-            withTimeout(timeout) {
-                val timeoutContext = currentCoroutineContext()
-                // 在 IO 线程执行匹配,让 runBlocking 事件循环空出来触发超时;
-                // runInterruptible 保证超时取消时安全中断该线程(不会把中断标记泄漏给线程池)。
-                runInterruptible(IO) {
+        suspendCancellableCoroutine { block ->
+            val coroutine = Coroutine.async(executeContext = IO) {
+                try {
                     val pattern = regex.toPattern()
-                    val matcher = pattern.matcher(InterruptibleCharSequence(charSequence))
+                    val matcher = pattern.matcher(charSequence)
                     val stringBuffer = StringBuffer()
-                    try {
-                        while (matcher.find()) {
-                            if (isJs) {
-                                val jsResult = RhinoScriptEngine.run {
-                                    val bindings = ScriptBindings()
-                                    bindings["result"] = matcher.group()
-                                    bindings["chapter"] = chapter
-                                    bindings["book"] = book
-                                    eval(
-                                        replacement1,
-                                        getRuntimeScope(bindings),
-                                        timeoutContext
-                                    )
-                                }.toString()
-                                val quotedResult = jsResult.quoteReplacementJs()
-                                matcher.appendReplacement(stringBuffer, quotedResult)
-                            } else {
-                                matcher.appendReplacement(stringBuffer, replacement1)
-                            }
+                    while (matcher.find()) {
+                        if (isJs) {
+                            val jsResult = RhinoScriptEngine.run {
+                                val bindings = ScriptBindings()
+                                bindings["result"] = matcher.group()
+                                bindings["chapter"] = chapter
+                                bindings["book"] = book
+                                eval(replacement1, bindings)
+                            }.toString()
+                            val quotedResult = jsResult.quoteReplacementJs()
+                            matcher.appendReplacement(stringBuffer, quotedResult)
+                        } else {
+                            matcher.appendReplacement(stringBuffer, replacement1)
+                        }
+                    }
+                    matcher.appendTail(stringBuffer)
+                    block.resume(stringBuffer.toString())
+                } catch (e: Exception) {
+                    block.resumeWithException(e)
+                }
+            }
+            handler.postDelayed(timeout) {
+                if (coroutine.isActive) {
+                    val timeoutMsg =
+                        "替换超时,3秒后还未结束将重启应用\n替换规则$regex\n替换内容:$charSequence"
+                    val exception = RegexTimeoutException(timeoutMsg)
+                    block.cancel(exception)
+                    appCtx.longToastOnUi(timeoutMsg)
+                    CrashHandler.saveCrashInfo2File(exception)
+                    handler.postDelayed(3000) {
+                        if (coroutine.isActive) {
+                            appCtx.restart()
                         }
-                        matcher.appendTail(stringBuffer)
-                    } catch (e: RegexInterruptedException) {
-                        // 中断由超时取消触发,转成 InterruptedException,
-                        // 交由 runInterruptible 归一化为协程取消 -> withTimeout 抛出超时。
-                        throw InterruptedException()
                     }
-                    stringBuffer.toString()
                 }
             }
-        } catch (e: TimeoutCancellationException) {
-            val timeoutMsg = "替换超时\n替换规则$regex\n替换内容:$charSequence"
-            val exception = RegexTimeoutException(timeoutMsg)
-            appCtx.longToastOnUi(timeoutMsg)
-            CrashHandler.saveCrashInfo2File(exception)
-            throw exception
-        }
-    }
-}
-
-/**
- * charAt() 中检测线程中断标记的 CharSequence 包装。
- *
- * java.util.regex 的回溯匹配会反复读取输入字符,因此在字符读取处检查中断,
- * 就能让陷入灾难性回溯的 matcher.find() 及时抛出并结束,无需重启进程。
- * 采样检测(每 1024 次读取查一次)避免正常替换时逐字符做 native 调用的开销。
- */
-private class InterruptibleCharSequence(private val delegate: CharSequence) : CharSequence {
-
-    private var counter = 0
-
-    override val length: Int get() = delegate.length
-
-    override fun get(index: Int): Char {
-        if ((counter++ and CHECK_MASK) == 0 && Thread.currentThread().isInterrupted) {
-            throw RegexInterruptedException()
         }
-        return delegate[index]
     }
-
-    override fun subSequence(startIndex: Int, endIndex: Int): CharSequence =
-        InterruptibleCharSequence(delegate.subSequence(startIndex, endIndex))
-
-    override fun toString(): String = delegate.toString()
-
-    private companion object {
-        const val CHECK_MASK = 0x3FF
-    }
-}
-
-/**
- * 由 [InterruptibleCharSequence] 在检测到线程中断时抛出。无栈以降低开销。
- */
-private class RegexInterruptedException : RuntimeException() {
-    override fun fillInStackTrace(): Throwable = this
 }

+ 0 - 111
app/src/test/java/io/legado/app/utils/RegexExtensionsTest.kt

@@ -1,111 +0,0 @@
-package io.legado.app.utils
-
-import android.app.Application
-import io.legado.app.exception.RegexTimeoutException
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertThrows
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.RuntimeEnvironment
-import org.robolectric.annotation.Config
-import splitties.init.injectAsAppCtx
-import java.util.concurrent.atomic.AtomicLong
-
-@RunWith(RobolectricTestRunner::class)
-@Config(application = Application::class, sdk = [35])
-class RegexExtensionsTest {
-
-    @Before
-    fun setUp() {
-        RuntimeEnvironment.getApplication().injectAsAppCtx()
-    }
-
-    @Test(timeout = 10_000)
-    fun `JS 替换脚本超时后终止执行`() {
-        System.clearProperty(JS_STATE_KEY)
-        try {
-            assertThrows(RegexTimeoutException::class.java) {
-                "text".replace(
-                    regex = Regex("text"),
-                    replacement = """
-                        @js:
-                        var count = 0;
-                        while (true) {
-                            java.lang.System.setProperty('$JS_STATE_KEY', String(++count));
-                        }
-                    """.trimIndent(),
-                    timeout = 100,
-                )
-            }
-
-            val countAfterReturn = System.getProperty(JS_STATE_KEY)?.toLongOrNull()
-            assertTrue("JS 应在超时前进入循环", countAfterReturn != null && countAfterReturn > 0)
-            Thread.sleep(250)
-            assertEquals(countAfterReturn.toString(), System.getProperty(JS_STATE_KEY))
-        } finally {
-            System.clearProperty(JS_STATE_KEY)
-        }
-    }
-
-    @Test
-    fun `JS 替换脚本正常返回结果`() {
-        val result = "text".replace(
-            regex = Regex("text"),
-            replacement = "@js:result.toUpperCase()",
-            timeout = 1_000,
-        )
-
-        assertEquals("TEXT", result)
-    }
-
-    @Test
-    fun `普通正则替换保留捕获组语义`() {
-        val result = "text-123".replace(
-            regex = Regex("([a-z]+)-(\\d+)"),
-            replacement = "$2-$1",
-            timeout = 1_000,
-        )
-
-        assertEquals("123-text", result)
-    }
-
-    @Test(timeout = 10_000)
-    fun `灾难性正则回溯超时后终止执行`() {
-        val input = CountingCharSequence("a".repeat(30_000).plus("!"))
-
-        assertThrows(RegexTimeoutException::class.java) {
-            input.replace(
-                regex = Regex("(a+)+$"),
-                replacement = "",
-                timeout = 100,
-            )
-        }
-
-        val readCountAfterReturn = input.readCount.get()
-        assertTrue("正则应在超时前开始读取输入", readCountAfterReturn > 0)
-        Thread.sleep(250)
-        assertEquals(readCountAfterReturn, input.readCount.get())
-    }
-
-    private class CountingCharSequence(private val value: String) : CharSequence {
-
-        val readCount = AtomicLong()
-
-        override val length: Int get() = value.length
-
-        override fun get(index: Int): Char {
-            readCount.incrementAndGet()
-            return value[index]
-        }
-
-        override fun subSequence(startIndex: Int, endIndex: Int): CharSequence =
-            value.subSequence(startIndex, endIndex)
-    }
-
-    private companion object {
-        const val JS_STATE_KEY = "io.legado.app.utils.RegexExtensionsTest.jsState"
-    }
-}