【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 重建/销毁时,进程直接崩溃,崩溃栈指向 OnnxruntimeModuledealloc,并在清空一个静态的 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::Runtimejsi::Env 的引用,用来在原生模块和 JS 之间高效地传值(不再是老式 bridge 的序列化拷贝)。ONNX Runtime 的 RN 模块为了跨调用共享环境,常把一个 jsi::Env 存成 static / 单例全局,避免每次推理都重新建环境。

但 JSI 的 Runtime/Env 生命周期由 RN 运行时管:当 bridge 销毁、或 Fast Refresh / 重新加载 JS bundle、或应用进入后台被系统回收时,RN 运行时会jsi::Runtime 拆掉。此时 jsi::Env 已经指向一个被销毁的运行时。如果 OnnxruntimeModuledealloc之后才跑,并尝试 env_->reset() 去清空这个静态 jsi::Env,就会访问已死的运行时 → segfault。

更糟的是 static:如果应用里有多个 OnnxruntimeModule 实例(或重复加载模块),第一个 dealloc 把静态 jsi::Env 清了,第二个 dealloc 再清一次 → double free。iOS 上因为运行时销毁顺序更确定(严格按引用/线程顺序),所以比 Android 更容易稳定触发。

三、根因

根因是 jsi::Env 被错误地当成 static 全局,并在模块 dealloc 里无条件 reset,而没考虑 JSI 运行时可能已先死、或已被别的 dealloc 清过

  1. 静态全局 + 无条件 resetenv_static,但 dealloc 里直接 env_->reset() 不检查运行时是否还活着、也不检查是不是最后一次引用。运行时先死后清 → use-after-free。
  2. 重复 dealloc → double freestatic 意味着多实例共享同一份,第一个 dealloc 清空后,第二个再清就是 double free / 访问空悬指针。
  3. 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 模块读同一份 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_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

  1. 看崩溃栈:是不是 OnnxruntimeModule dealloc → 清静态 jsi::Env?本题是。
  2. static jsi::Env / static shared_ptr<Runtime>:有就是根因。
  3. 确认运行时销毁顺序:iOS 上 RN 运行时常比模块先死,dealloc 时 Env 已悬空。
  4. 改非 staticjsi::Env 按实例持有,或用引用计数 wrapper。
  5. dealloc 前检查存活runtime.isAlive() 为假就跳过清理。
  6. 幂等清理:多次 dealloc 不 double free。
  7. 统一策略对象:用 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 要先看宿主是否还活着。

配图

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐