Просмотр исходного кода

fix(rhino): 脚本入口先确保上下文工厂已安装,修 RhinoContext 强转崩溃 (#2258)

Context.enter() 取的是 Rhino 全局 ContextFactory,而生产 RhinoContext 的自定义工厂只在
RhinoScriptEngine 的 object init 中通过 ContextFactory.initGlobal 安装。安装完成前执行脚本
(冷启动早期打开书源登录、换源、解析等)会拿到普通 org.mozilla.javascript.Context:
强转 RhinoContext 直接抛 ClassCastException;且强转在 enter 之后抛出,Context.exit() 不会执行,
该线程会被永久留在普通 Context 上,此后这条线程上的任何脚本执行都会失败。

改动:
- 新增 enterRhinoContext():先确保全局工厂已安装,类型不符时先 Context.exit() 再抛出可诊断的 IllegalStateException
- runScriptWithContext(suspend/非 suspend)、RhinoCompiledScript、RhinoScriptEngine.eval/evalSuspend 统一走该入口
- App.initRhino() 从 Coroutine.async 末尾移到 onCreate 同步执行,消除"先跑脚本、后装工厂"的窗口(RhinoWrapFactory 注册同理)

验证:新增 RhinoContextEntryTest 3 项通过;既有 JsTest 8 项通过;把入口还原成裸强转可让新用例以 ClassCastException 失败。
Mupceet 1 месяц назад
Родитель
Сommit
fec7e6aa6b

+ 7 - 1
app/src/main/java/io/legado/app/App.kt

@@ -176,6 +176,13 @@ class App : Application(), SingletonImageLoader.Factory {
             ThreadUtils.setThreadAssertsDisabledForTesting(true)
         }
         registerActivityLifecycleCallbacks(LifecycleHelp)
+        // Rhino 的全局 ContextFactory 必须在任何线程执行 Context.enter() 之前安装:
+        // 它只在 RhinoScriptEngine 的 object init 里通过 ContextFactory.initGlobal 生效,
+        // 而 Context.enter() 取的就是这个全局工厂。原先 initRhino() 排在下面那个
+        // Coroutine.async 的末尾,冷启动早期执行的脚本(书源登录/换源/解析)可能先拿到
+        // 普通 Context,强转 RhinoContext 会直接崩溃并把该线程永久污染。
+        // 这里改为在 onCreate 中同步执行;RhinoWrapFactory 注册同理要早于任何脚本执行。
+        initRhino()
         Coroutine.async {
             get<BackupSettingsGateway>().settings
                 .map {
@@ -212,7 +219,6 @@ class App : Application(), SingletonImageLoader.Factory {
             DispatchersMonitor.init()
             URL.setURLStreamHandlerFactory(ObsoleteUrlFactory(okHttpClient))
             launch { installGmsTlsProvider(appCtx) }
-            initRhino()
             //初始化封面
             BookCover.toString()
             //清除过期数据

+ 95 - 0
app/src/test/java/io/legado/app/RhinoContextEntryTest.kt

@@ -0,0 +1,95 @@
+package io.legado.app
+
+import com.script.rhino.RhinoContext
+import com.script.rhino.enterRhinoContext
+import com.script.rhino.runScriptWithContext
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Test
+import org.mozilla.javascript.Context
+import org.mozilla.javascript.ContextFactory
+import java.util.concurrent.atomic.AtomicReference
+
+/**
+ * Rhino 入口必须保证先安装自定义全局 ContextFactory 再 `Context.enter()`。
+ *
+ * 回归对象:Rhino 的 `Context.enter()` 取的是全局工厂,而生产 [RhinoContext] 的工厂只在
+ * RhinoScriptEngine 的 object init 中安装。安装前进入上下文会拿到普通
+ * `org.mozilla.javascript.Context`,直接强转既抛 ClassCastException,又因为没有
+ * `Context.exit()` 把该线程永久留在普通 Context 上。
+ */
+class RhinoContextEntryTest {
+
+    @Test
+    fun enterRhinoContextYieldsRhinoContextAndLeavesNoContext() {
+        inFreshThread {
+            val cx = enterRhinoContext()
+            assertEquals(RhinoContext::class.java.name, cx.javaClass.name)
+            assertSame(cx, Context.getCurrentContext())
+            Context.exit()
+            assertNull(Context.getCurrentContext())
+        }
+    }
+
+    /**
+     * 线程已被非 RhinoContext 占用时(旧实现崩溃后留下的线程):
+     * 必须抛出可诊断的 IllegalStateException,而不是 ClassCastException;
+     * 且不能多退一次 Context,清理干净后线程还能正常进入 Rhino 上下文。
+     */
+    @Test
+    fun foreignContextOnThreadFailsLoudlyWithoutBreakingThreadState() {
+        inFreshThread {
+            val foreignFactory = ContextFactory()
+            val foreign = foreignFactory.enterContext()
+            try {
+                enterRhinoContext()
+                fail("应抛出 IllegalStateException,而不是 ClassCastException")
+            } catch (e: IllegalStateException) {
+                assertTrue(
+                    "错误信息应带上实际 Context 类型,实际为: ${e.message}",
+                    e.message.orEmpty().contains(foreign.javaClass.name)
+                )
+            }
+            // 失败路径不能多退:外来 Context 的 enter 计数必须原样保留
+            assertSame(foreign, Context.getCurrentContext())
+            // 调用方自己退出后线程应彻底干净
+            Context.exit()
+            assertNull(Context.getCurrentContext())
+
+            val cx = enterRhinoContext()
+            assertEquals(RhinoContext::class.java.name, cx.javaClass.name)
+            Context.exit()
+            assertNull(Context.getCurrentContext())
+        }
+    }
+
+    /** 书源登录页计算 headers 用的就是这个 suspend 重载。 */
+    @Test
+    fun suspendRunScriptWithContextEnterAndExitCleanly() = runBlocking {
+        val result = runScriptWithContext { "ok" }
+        assertEquals("ok", result)
+        assertNull(Context.getCurrentContext())
+    }
+
+    private fun inFreshThread(block: () -> Unit) {
+        val failure = AtomicReference<Throwable?>(null)
+        val thread = Thread {
+            try {
+                block()
+            } catch (t: Throwable) {
+                failure.set(t)
+            }
+        }
+        thread.start()
+        thread.join(30_000)
+        if (thread.isAlive) {
+            thread.interrupt()
+            fail("测试线程未在超时时间内结束")
+        }
+        failure.get()?.let { throw it }
+    }
+}

+ 3 - 3
modules/rhino/src/main/java/com/script/rhino/RhinoCompiledScript.kt

@@ -55,7 +55,7 @@ internal class RhinoCompiledScript(
     }
 
     override fun eval(scope: Scriptable, coroutineContext: CoroutineContext?): Any? {
-        val cx = Context.enter() as RhinoContext
+        val cx = enterRhinoContext()
         val previousCoroutineContext = cx.coroutineContext
         if (coroutineContext != null && coroutineContext[Job] != null) {
             cx.coroutineContext = coroutineContext
@@ -87,7 +87,7 @@ internal class RhinoCompiledScript(
     }
 
     override suspend fun evalSuspend(scope: Scriptable): Any? {
-        val cx = Context.enter() as RhinoContext
+        val cx = enterRhinoContext()
         var ret: Any?
         withContext(VMBridgeReflect.contextLocal.asContextElement()) {
             cx.allowScriptRun = true
@@ -132,4 +132,4 @@ internal class RhinoCompiledScript(
         return engine.unwrapReturnValue(ret)
     }
 
-}
+}

+ 28 - 4
modules/rhino/src/main/java/com/script/rhino/RhinoExtensions.kt

@@ -17,6 +17,32 @@ val rhinoContext: RhinoContext
 val rhinoContextOrNull: RhinoContext?
     get() = Context.getCurrentContext() as? RhinoContext
 
+/**
+ * 进入脚本上下文。所有需要 [RhinoContext] 的入口都必须走这里,不要直接写
+ * `Context.enter() as RhinoContext`。
+ *
+ * 原因:`Context.enter()` 取的是 Rhino 的**全局** [org.mozilla.javascript.ContextFactory],
+ * 而生产 [RhinoContext] 的自定义工厂只在 [RhinoScriptEngine] 的 object init 中通过
+ * `ContextFactory.initGlobal` 安装。在安装完成前调用会拿到普通
+ * `org.mozilla.javascript.Context`:强转会抛 ClassCastException,且这个 Context 不会
+ * 被 `Context.exit()` 释放,会把当前线程永久留在普通 Context 上——此后该线程上的任何
+ * 脚本执行都会失败(书源搜索/换源/正文解析等)。
+ */
+fun enterRhinoContext(): RhinoContext {
+    @Suppress("UNUSED_EXPRESSION")
+    RhinoScriptEngine
+    val cx = Context.enter()
+    if (cx is RhinoContext) {
+        return cx
+    }
+    // 无论失败原因是什么都要撤销本次 enter,否则会把非 RhinoContext 留在线程上。
+    Context.exit()
+    error(
+        "无法进入 Rhino 上下文: Context.enter() 返回 ${cx.javaClass.name}," +
+            "当前线程已被非 RhinoContext 占用或 Rhino 全局工厂尚未安装"
+    )
+}
+
 @Suppress("LEAKED_IN_PLACE_LAMBDA", "WRONG_INVOCATION_KIND")
 @OptIn(ExperimentalContracts::class)
 inline fun <T> suspendContinuation(crossinline block: suspend CoroutineScope.() -> T): T {
@@ -40,9 +66,7 @@ inline fun <T> suspendContinuation(crossinline block: suspend CoroutineScope.()
 }
 
 inline fun <T> runScriptWithContext(context: CoroutineContext, block: () -> T): T {
-    @Suppress("UNUSED_EXPRESSION")
-    RhinoScriptEngine
-    val rhinoContext = Context.enter() as RhinoContext
+    val rhinoContext = enterRhinoContext()
     val previousCoroutineContext = rhinoContext.coroutineContext
     rhinoContext.coroutineContext = context.minusKey(ContinuationInterceptor)
     try {
@@ -54,7 +78,7 @@ inline fun <T> runScriptWithContext(context: CoroutineContext, block: () -> T):
 }
 
 suspend inline fun <T> runScriptWithContext(block: () -> T): T {
-    val rhinoContext = Context.enter() as RhinoContext
+    val rhinoContext = enterRhinoContext()
     val previousCoroutineContext = rhinoContext.coroutineContext
     rhinoContext.coroutineContext = currentCoroutineContext().minusKey(ContinuationInterceptor)
     try {

+ 2 - 2
modules/rhino/src/main/java/com/script/rhino/RhinoScriptEngine.kt

@@ -90,7 +90,7 @@ object RhinoScriptEngine : AbstractScriptEngine(), Invocable, Compilable {
         scope: Scriptable,
         coroutineContext: CoroutineContext?
     ): Any? {
-        val cx = Context.enter() as RhinoContext
+        val cx = enterRhinoContext()
         val previousCoroutineContext = cx.coroutineContext
         if (coroutineContext != null && coroutineContext[Job] != null) {
             cx.coroutineContext = coroutineContext
@@ -126,7 +126,7 @@ object RhinoScriptEngine : AbstractScriptEngine(), Invocable, Compilable {
 
     @Throws(ContinuationPending::class)
     override suspend fun evalSuspend(reader: Reader, scope: Scriptable): Any? {
-        val cx = Context.enter() as RhinoContext
+        val cx = enterRhinoContext()
         var ret: Any?
         withContext(VMBridgeReflect.contextLocal.asContextElement()) {
             cx.allowScriptRun = true