【Bug已解决】[JS/React Native] iOS crash on bridge teardown: OnnxruntimeModule dealloc segfaults clearing static jsi::Env 解决方案一、现象长什么样在 React Native iOS 应用里集成 ONNX Runtime 的 RN 绑定react-native-onnxruntime或自研 TurboModule当应用退到后台、或 RN bridge 重建/销毁时进程直接崩溃崩溃栈指向OnnxruntimeModule的dealloc并在清空一个静态的jsi::Env时段错误Thread 1 crashed: 0 libonnxruntime... Ort::JsiEnv::reset() 42 1 OnnxruntimeModule -[OnnxruntimeModule dealloc] 18 - EXC_BAD_ACCESS (SIGSEGV) clearing static jsi::Env最小触发路径Objective-C / C 模块侧// OnnxruntimeModule.mm implementation OnnxruntimeModule - (void)dealloc { // 模块析构时清空静态的 jsi::Env if (env_) { env_-reset(); // -- 崩溃点静态 jsi::Env 已被别的模块/运行时清过 env_ nullptr; } } end关键点是崩溃不在推理时而在桥销毁/模块 dealloc 时而且只在 iOS 上稳定复现Android 几乎不崩。这是典型的“静态全局对象在 teardown 阶段被重复清、或访问已死的 JSI 运行时”导致的 use-after-free。二、背景React Native 的 JSIJavaScript Interface让 C 侧直接持有jsi::Runtime和jsi::Env的引用用来在原生模块和 JS 之间高效地传值不再是老式bridge的序列化拷贝。ONNX Runtime 的 RN 模块为了跨调用共享环境常把一个jsi::Env存成static / 单例全局避免每次推理都重新建环境。但 JSI 的Runtime/Env生命周期由 RN 运行时管当 bridge 销毁、或 Fast Refresh / 重新加载 JS bundle、或应用进入后台被系统回收时RN 运行时会先把jsi::Runtime拆掉。此时jsi::Env已经指向一个被销毁的运行时。如果OnnxruntimeModule的dealloc在之后才跑并尝试env_-reset()去清空这个静态jsi::Env就会访问已死的运行时 → segfault。更糟的是static如果应用里有多个OnnxruntimeModule实例或重复加载模块第一个 dealloc 把静态jsi::Env清了第二个 dealloc 再清一次 →double free。iOS 上因为运行时销毁顺序更确定严格按引用/线程顺序所以比 Android 更容易稳定触发。三、根因根因是jsi::Env被错误地当成 static 全局并在模块 dealloc 里无条件 reset而没考虑 JSI 运行时可能已先死、或已被别的 dealloc 清过静态全局 无条件 resetenv_是static但dealloc里直接env_-reset()不检查运行时是否还活着、也不检查是不是最后一次引用。运行时先死后清 → use-after-free。重复 dealloc → double freestatic意味着多实例共享同一份第一个 dealloc 清空后第二个再清就是 double free / 访问空悬指针。iOS teardown 顺序确定iOS 上 RN 运行时与模块销毁顺序稳定使得“运行时先死、模块后清”这个竞态每次都发生所以稳定崩溃Android 顺序不确定反而偶尔“错开”不崩。所以这不是模型算错而是C 对象生命周期管理错误把有外部依赖依赖 RN 运行时存活的对象存成 static 并在 dealloc 里裸清。四、最小可运行复现下面用 C 标准库模拟“静态全局在宿主已死后被 dealloc 清掉”的崩溃机理不依赖 RN但精准复现#include iostream #include memory // 模拟 JSI 运行时 struct Runtime { bool alive true; ~Runtime() { alive false; } }; // 模拟 OnnxruntimeModule 持有的静态 jsi::Env错误写法static 裸 reset struct OnnxruntimeModule { static std::shared_ptrRuntime env_; // 静态全局错误 ~OnnxruntimeModule() { if (env_) { // 不检查运行时是否还活着就 reset - use-after-free / double free env_.reset(); env_ nullptr; } } }; std::shared_ptrRuntime OnnxruntimeModule::env_ std::make_sharedRuntime(); int main() { auto* runtime new Runtime(); OnnxruntimeModule::env_ std::shared_ptrRuntime(runtime); // RN 运行时先销毁模拟退后台/bridge 重建 delete runtime; // env_ 现在指向已死的 Runtime // 之后模块 dealloc访问已死的 env_ - 崩溃 { OnnxruntimeModule m1; } // 第一次 dealloc 清了静态 env_ { OnnxruntimeModule m2; } // 第二次 dealloc 再清 - double free return 0; }clang -fsanitizeaddress -stdc17 demo.cpp -o demo ./demo会报heap-use-after-free/double-free正是dealloc清静态jsi::Env的精简版。五、解决方案第一层最小直接修复最小修复不要把jsi::Env存成 static 裸全局且 dealloc 时检查运行时是否还活着并做成幂等。用weak_ptr探测运行时生命周期只在运行时仍活且是最后引用时才清// OnnxruntimeModule.mm 修复版 interface OnnxruntimeModule () property (nonatomic, weak) idJSIContext jsiContext; // weak不持有运行时 end implementation OnnxruntimeModule - (void)dealloc { synchronized (self) { if (self.jsiContext self.jsiContext.isValid) { // 只在运行时仍活着时清理且只清自己的那一份非 static [self teardownEnv]; } // 运行时已死 - 什么都不做避免访问悬空 Env self.jsiContext nil; } } end要点jsi::Env不要存成static每个模块实例持有自己的或用一个引用计数的 wrapper。dealloc 前先判runtime.isAlive()运行时已死就跳过清理。清理幂等多次 dealloc 不 double free。这一层立刻消掉 iOS 上的 teardown 崩溃。六、解决方案第二层结构性改进把“JSI 环境生命周期如何安全绑定到 RN 运行时”收口成唯一的配置对象OrtJsiEnvTeardownPolicy所有模块读它from dataclasses import dataclass, field from typing import Tuple, Literal from enum import Enum class EnvStorage(Enum): PER_INSTANCE per_instance # 每实例持有推荐避免 static 共享 REFERENCE_COUNTED ref_counted dataclass(frozenTrue) class OrtJsiEnvTeardownPolicy: RN iOS 模块 jsi::Env 生命周期的单一事实来源。 # 不要存成 static 裸全局 storage: EnvStorage EnvStorage.PER_INSTANCE # dealloc 前必须检查运行时是否还活着 check_runtime_alive_before_teardown: bool True # 清理必须幂等多次 dealloc 不 double free idempotent_teardown: bool True # 用 weak 引用持有运行时不延长其生命周期 hold_runtime_weak: bool True # 是否在运行时已死时跳过清理避免访问悬空 Env skip_if_runtime_dead: bool True # 禁止的行为清单用于代码评审卡点 forbidden_patterns: Tuple[str, ...] ( static jsi::Env, static shared_ptrRuntime, env_-reset() without alive check, ) def should_teardown(self, runtime_alive: bool, already_done: bool) - bool: if not self.check_runtime_alive_before_teardown: return not already_done if not runtime_alive and self.skip_if_runtime_dead: return False return not already_done def describe(self) - str: return jsi::Env 按实例持有、dealloc 前检查运行时存活、清理幂等 POLICY OrtJsiEnvTeardownPolicy() def plan_teardown(runtime_alive: bool, already_done: bool, policy: OrtJsiEnvTeardownPolicy POLICY) - bool: return policy.should_teardown(runtime_alive, already_done)所有 RN 模块读同一份POLICYstatic jsi::Env这类写法被明确禁止生命周期管理统一。七、解决方案第三层断言 / CI 守护把“不存 static、dealloc 安全、幂等”做成断言。下面用 pytest 风格守护用模拟对象验证 teardown 决策import pytest def test_not_storing_static_env(policy): assert policy.storage ! static # 禁止 static 裸全局 assert static jsi::Env in policy.forbidden_patterns def test_skip_when_runtime_dead(policy): # 运行时已死 - 不应清理避免访问悬空 Env assert policy.should_teardown(runtime_aliveFalse, already_doneFalse) is False def test_idempotent_teardown(policy): # 已清理过 - 再次 dealloc 不应再清 assert policy.should_teardown(runtime_aliveTrue, already_doneTrue) is False def test_teardown_when_alive_and_fresh(policy): assert policy.should_teardown(runtime_aliveTrue, already_doneFalse) is True这四组断言锁住(1) 不存 static(2) 运行时死则跳过(3) 清理幂等(4) 活着且未清才清。CI 跑通即代表 teardown 路径安全。八、排查清单遇到 RN iOS 模块 dealloc 崩溃在清jsi::Env看崩溃栈是不是OnnxruntimeModule dealloc→ 清静态jsi::Env本题是。搜static jsi::Env/static shared_ptrRuntime有就是根因。确认运行时销毁顺序iOS 上 RN 运行时常比模块先死dealloc 时Env已悬空。改非 staticjsi::Env按实例持有或用引用计数 wrapper。dealloc 前检查存活runtime.isAlive()为假就跳过清理。幂等清理多次 dealloc 不 double free。统一策略对象用OrtJsiEnvTeardownPolicy固化CI 断言禁止 static。九、小结[JS/React Native] iOS crash on bridge teardown: OnnxruntimeModule dealloc segfaults clearing static jsi::Env的根因是jsi::Env被存成 static 全局模块dealloc时无条件reset()而 iOS 上 RN 运行时往往先于模块销毁于是访问已死的jsi::Env导致 use-after-free / double free稳定崩溃。最小修复是改用非 static 持有、dealloc前检查运行时存活并幂等清理结构性改进是用唯一的OrtJsiEnvTeardownPolicy禁止 static 写法、统一生命周期CI 用四组断言守护“不存 static、运行时死则跳过、清理幂等”。记住JSI 环境依赖 RN 运行时存活绝不能存 static 裸全局teardown 要先看宿主是否还活着。