Преглед изворни кода

修复正则替换超时取消

5151561 пре 3 месеци
родитељ
комит
798f133afb

+ 80 - 44
app/src/main/java/io/legado/app/utils/RegexExtensions.kt

@@ -1,6 +1,5 @@
 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
@@ -8,15 +7,13 @@ 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.suspendCancellableCoroutine
+import kotlinx.coroutines.runInterruptible
+import kotlinx.coroutines.withTimeout
 import splitties.init.appCtx
-import kotlin.coroutines.resume
-import kotlin.coroutines.resumeWithException
-
-private val handler by lazy { buildMainHandler() }
 
 /**
  * 带有超时检测的正则替换
@@ -39,48 +36,87 @@ fun CharSequence.replace(
         null
     }
     return runBlocking {
-        suspendCancellableCoroutine { block ->
-            val coroutine = Coroutine.async(executeContext = IO) {
-                try {
+        try {
+            withTimeout(timeout) {
+                val timeoutContext = currentCoroutineContext()
+                // 在 IO 线程执行匹配,让 runBlocking 事件循环空出来触发超时;
+                // runInterruptible 保证超时取消时安全中断该线程(不会把中断标记泄漏给线程池)。
+                runInterruptible(IO) {
                     val pattern = regex.toPattern()
-                    val matcher = pattern.matcher(charSequence)
+                    val matcher = pattern.matcher(InterruptibleCharSequence(charSequence))
                     val stringBuffer = StringBuffer()
-                    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()
+                    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)
+                            }
                         }
+                        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
+}

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

@@ -0,0 +1,111 @@
+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"
+    }
+}