Skip to content

内存安全与生命周期

FFI 边界是内存安全最容易出问题的地方——Rust 的借用检查器管不到一根从 C++ 传过来的裸指针。这一页说清楚这个项目怎么应对,而不是简单地在文档里写一句"小心点"。

核心原则:句柄是标识符,不是指针

Player/Entity/Block 这些"句柄对象"故意不是缓存的原生指针。它们是轻量的、可以安全复制的标识符,每次调用都会在 C++ 桥接内部按这个标识符重新查一次活的对象:

句柄标识符是什么每次调用怎么重新解析
Player名字 / xuid / uuid在当前在线玩家列表里按标识符查找(Level::forEachPlayer 遍历比对)
EntityActorUniqueID(引擎自己维护的稳定 id,不是内存地址)在当前 runtime actor 列表里按 id 查找
Block(维度, x, y, z) 坐标重新调用 BlockSource::getBlock(pos)

也就是说,player.send_message(...) 这类调用的真实过程是:把 player 携带的名字字符串传过 FFI 边界 → C++ 桥接现查一遍谁是这个名字的在线玩家 → 找到了就操作,找不到就返回失败——不是对着一个上次查到的指针直接操作。这样即使玩家在你拿到这个句柄之后离线了,下一次调用只会得到一个"找不到"的错误结果,不会有任何机会碰到已经失效的内存。

这个模式不是新发明的,现有代码里已经用了同一种思路来处理一个更具体的问题:LeviLamina 的很多事件(玩家加入、聊天等)序列化出来的数据里会嵌一个"这是某个 Player 的引用"的指针存根。桥接层绝不直接相信这个指针——它先收集一份当前真正在线的玩家地址集合,只有嵌入的指针出现在这个集合里,才会真的解引用它去读玩家的真实身份:

cpp
// 只有在这个"当前在线"的集合里出现过的指针,才会被解引用
std::unordered_set<uintptr_t> livePlayerAddrs() { /* 遍历 forEachPlayer 收集地址 */ }

std::string enrichWithPlayer(CompoundTag const& data) {
    uintptr_t addr = findPlayerPointer(data);      // 找到疑似指针,还不敢信
    auto addrs = livePlayerAddrs();
    if (addrs.find(addr) != addrs.end()) {         // 验证过是活的,才解引用
        // ...
    }
}

句柄设计把这同一条原则往前推了一步:与其"每次用之前验证一下指针",不如从一开始就不持有指针,只持有一个可以拿去重新查询的标识符。多花一次线性查找的开销(对一个 modding API 完全可以接受),换来的是编译期和运行期都不可能出现悬垂指针——不是提醒模组作者小心,是架构上排除了这类错误。

⚠️ 对模组作者的实际含义:不要把一个句柄跨 tick / 跨回调保存下来当"缓存"用。这么做本身不会崩溃(因为句柄不是指针),但每次用的时候都会重新解析一次,长期保存并不会带来性能收益,反而可能让代码里的"这个玩家还在吗"逻辑变得含糊。需要长期持有的是句柄携带的那个标识符本身(名字/id/坐标),而不是"handle 对象活着就代表游戏对象还在"这种假设。

线程模型

只有 Log::*Scheduler::*Server::gaming_status() 是线程安全的,可以从任意线程调用。除此之外的一切——包括所有句柄方法、Command::executeWorld::*——都只能在服务器线程上调用。这不是保守起见的限制:BDS/LeviLamina 自己的核心数据结构(玩家列表、区块、方块源)本身就不是线程安全的,从其他线程调用是未定义行为,不是"可能会慢"。

后台线程(比如一个 Tokio 任务、一个 HTTP 回调)要影响游戏世界,唯一合法路径是 Scheduler::run(f) / Scheduler::run_after(delay, f)——把闭包投递回服务器线程排队执行,f 本身在服务器线程上跑的时候,就可以正常调用其余所有 API 了。

Panic 处理:不让 unwind 跨越 FFI 边界

一个模组的回调(事件处理、命令处理、调度任务、生命周期钩子)如果内部 panic,绝不能让这个 panic 展开(unwind)穿过 FFI 边界钻进 C++ 里——这是未定义行为,Rust 的 unwind 机制不保证 C++ 那一侧能正确处理,尤其是跨 cdylib 边界的情况。

因此每一个从 C++ 侧被调用的 Rust 回调入口都用 catch_unwind 包了一层:

rust
// 事件回调、命令回调、调度任务、生命周期钩子,模式都一样
if catch_unwind(AssertUnwindSafe(|| cb(&mut ev))).is_err() {
    Logger(()).error("panic in event handler");
    return; // 吞掉 panic,记录日志,正常返回给 C++
}

真实代码里这个模式出现在 event_trampolinetask_trampolineregister_commandtrampoline、以及生命周期钩子的 __lifecycle 里——凡是 C++ 会调用进 Rust 的入口,都在这一层做了兜底。模组作者的代码 panic 时,效果是"这次调用没有生效、日志里能看到一条错误",而不是让整个服务器进程带着一个已经越过 FFI 边界的未决 unwind 状态继续跑下去。

跨语言字符串的生命周期规则

LeviRsStr{指针, 长度} 视图)故意不保证 NUL 结尾,而且只在当次调用期间有效——传进回调的字符串,回调返回之后指针指向的内存可能已经不存在了,需要长期保留就必须在回调内部拷贝成 String/Vec<u8>。字符串"从 Rust 传出"的方向则用 sink 回调模式(在调用帧内被同步调用,而不是返回一个跨界的指针),这样所有权就永远不用跨越 FFI 边界改变归属,也不需要一套跨语言的内存释放协议。

LeviRsStr 底层复用的 std::string_view 内存布局本身也不是标准保证的(只是当前工具链的实现细节),这条风险和应对方式(运行时验证 + 编译期 static_assert)在架构与 ABI 设计里有单独说明。

一个原生平台限制如何塑造安全 API:命令无法注销

Bedrock 引擎本身不支持注销已注册的命令。这不是这个项目的选择,是底层平台的硬限制。安全 Rust API 对此的应对是:register_command 返回 Ok(()) 之后,这个命令的回调会被有意"泄漏"Box::into_raw 之后不再 Box::from_raw 释放)——因为它本来就要活到整个服务器进程结束。模组被禁用/卸载时,对应的命令绑定会被静音(调用时直接返回"不可用"错误)而不是被移除,卸载后也永远保持静音状态,而不会去尝试做一件引擎本身就不支持的事。这是"原生平台的限制,直接决定了安全层该提供什么保证"的一个具体例子——register_command 的文档没有承诺"这个命令可以被撤销",因为撤销这件事本身就不存在。