【Bug已解决】[JS/React Native] iOS crash on bridge teardown: OnnxruntimeModule dealloc segfaults clearin…
【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 的 JSI(JavaScript 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 清过:
- 静态全局 + 无条件 reset:
env_是static,但dealloc里直接env_->reset()不检查运行时是否还活着、也不检查是不是最后一次引用。运行时先死后清 → use-after-free。 - 重复 dealloc → double free:
static意味着多实例共享同一份,第一个 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_ptr<Runtime> env_; // 静态全局,错误
~OnnxruntimeModule() {
if (env_) {
// 不检查运行时是否还活着就 reset -> use-after-free / double free
env_.reset();
env_ = nullptr;
}
}
};
std::shared_ptr<Runtime> OnnxruntimeModule::env_ = std::make_shared<Runtime>();
int main() {
auto* runtime = new Runtime();
OnnxruntimeModule::env_ = std::shared_ptr<Runtime>(runtime);
// RN 运行时先销毁(模拟退后台/bridge 重建)
delete runtime; // env_ 现在指向已死的 Runtime
// 之后模块 dealloc,访问已死的 env_ -> 崩溃
{ OnnxruntimeModule m1; } // 第一次 dealloc 清了静态 env_
{ OnnxruntimeModule m2; } // 第二次 dealloc 再清 -> double free
return 0;
}
clang++ -fsanitize=address -std=c++17 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) id<JSIContext> 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(frozen=True)
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_ptr<Runtime>", "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 模块读同一份 POLICY,static 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_alive=False, already_done=False) is False
def test_idempotent_teardown(policy):
# 已清理过 -> 再次 dealloc 不应再清
assert policy.should_teardown(runtime_alive=True, already_done=True) is False
def test_teardown_when_alive_and_fresh(policy):
assert policy.should_teardown(runtime_alive=True, already_done=False) is True
这四组断言锁住:(1) 不存 static;(2) 运行时死则跳过;(3) 清理幂等;(4) 活着且未清才清。CI 跑通即代表 teardown 路径安全。
八、排查清单
遇到 RN iOS 模块 dealloc 崩溃在清 jsi::Env:
- 看崩溃栈:是不是
OnnxruntimeModule dealloc→ 清静态jsi::Env?本题是。 - 搜
static jsi::Env/static shared_ptr<Runtime>:有就是根因。 - 确认运行时销毁顺序:iOS 上 RN 运行时常比模块先死,dealloc 时
Env已悬空。 - 改非 static:
jsi::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 要先看宿主是否还活着。

更多推荐


所有评论(0)