docs/current/01-定位与路线/内存安全规范.md
GitHub ↗
当前有效

Vitro 内存安全规范

3867 字·约 10 分钟 阅读 2026-10-10 17:07

目标: 通过工程规范 + 静态分析 + 代码审查 + 自动化测试,系统性防止内存泄漏、资源泄漏、UAF(Use-After-Free)和悬空指针问题。 适用范围(2026-10-05 删区后单轨):moonbit/(唯一实现区;内存安全机制落点 = vitro/engine/memory〔check_access 单入口 / FreedLogs 有序结构 / bump + 有界隔离堆〕+ host 受检访存,S6 收官)。原 native/ Rust 冻结对照区已随 S9 工序④整体退役删除(tag rust-oracle-freeze)——文中「Rust」语言层规范与 Rust 代码示例均为删区前历史口径,保留为内存安全设计的方法学档案;线性内存 / 堆有界隔离 / 受检访存等语义章节仍为 MoonBit 侧现役语义。前端(Flutter/Dart)已于 2026-09-11 切割移交社区,本仓库不再包含 Dart 代码。

最后核对日期:2026-10-05(删区对齐:适用范围改单轨、§三与 §5.2 标历史口径、压力测试与可观测节的等价防线改现役承接。前一沿革 2026-09-27:适用范围扩为双区并补 MoonBit 侧落点)


一、核心原则(不可违背)

原则 1:谁分配,谁释放(RAII)

  • Rust:所有权系统自动管理内存;Box<T> / Vec<T> / String 在作用域结束时自动释放;禁止裸指针算术
  • 前端(已迁出):原 Dart 侧通过 FRB 自动管理 Native 资源、finalizer / dispose() 释放大型资源;这些约定随前端切割移交社区(见 §2)

原则 2:事件订阅必须对称释放(前端契约 · 已随切割移交社区)

  • 原规范要求 Riverpod provider 在 dispose() 中取消订阅、AnimationController / ScrollController / TextEditingController 必须 dispose()、避免在 StatefulWidget 中创建长期存活的无名监听器。
  • 这些是前端框架契约,随 CideFlutter/ 于 2026-09-11 移出本仓库,现属社区前端职责;后端无法也无需校验。历史实现见标签 before-frontend-split。

原则 3:跨语言边界必须拷贝,不得传引用

  • Rust C API:返回字符串禁止返回内部 String 的裸指针;必须拷贝到调用者提供的缓冲区,或明确文档化生命周期("仅在下次编译前有效")
  • wasm 边界:跨 wasm 线性内存边界同样一律拷贝——wasm-bindgen 导出的是线性内存中的视图,JS 侧不得长期持有指向 VM 线性内存的裸视图(引擎 reset/重新分配后即失效);复杂结构走 JSON 字符串(见 AGENTS.md 架构纪律)
  • 宿主(C/JS/headless 客户端):禁止在宿主侧长期持有引擎内部指针;会话销毁后所有派生指针失效

原则 4:异步任务必须可取消

  • Rust:tokio::select! 或自定义 CancellationToken 模式(当前 VM 为同步单步,无异步风险)
  • 前端(已迁出):原 Dart 侧 Future / Stream 的 cancel() / dispose() 约定随切割移交社区

原则 5:并发释放必须线程安全

  • Rust:利用所有权和 Mutex / RwLock 保证并发安全;Drop 实现避免阻塞操作
  • 前端(已迁出):原"UI 操作必须在主线程、dispose() 中禁止异步阻塞"约定随切割移交社区

二、前端(Flutter/Dart)契约 · 已随切割移交社区

2026-09-11 前端切割:CideFlutter/、FRB 桥接(native/src/api/、frb_generated)与全部 Dart 代码已从本仓库移除。本节原有的四条前端规范——StatefulWidget 生命周期契约(Controller/Listener 释放)、应用生命周期契约(Desktop 退出时不自动 dispose)、VM Session 释放模板、异步任务取消模板、CustomPainter 资源缓存——不再适用于本仓库,作为历史资产保留在标签 before-frontend-split。

移交给社区的契约要点(供社区前端自查):

契约 前端需保证
会话释放 每创建一个引擎会话(C ABI vitro_session_create / serve 会话)必须成对销毁;应用退出时集中清理
资源释放 控制器、监听器、动画对象在组件销毁时对称释放,禁止长期存活的无名监听器
异步取消 运行中被替换/取消的任务必须真正停止,且不得继续持有会话:引擎侧可用手段为 vitro_set_max_steps(步数上限)/ 会话销毁 / serve 的 session.reset(当前没有 vitro_stop 一类的异步中断 API,如实记录)
绘制资源 每帧回调中禁止重复创建绘制对象(Paint / Path / TextSpan 等)

后端只保证:会话 API 具备确定性销毁语义,且停止请求可被引擎接收;前端内存问题不由本仓库的测试防线覆盖。


三、Rust 后端规范(历史口径——实现已随 2026-10-05 删区退役;§四起的线性内存与堆隔离语义章节为 MoonBit 侧现役语义)

3.1 C API 设计规范

规则 示例
返回指针必须声明生命周期 /// 仅在下次 compile 前有效
所有输出字符串参数必须提供缓冲区+长度 char* buf, int buf_size
输入数组必须提供长度+上限校验 if (count > MAX_COUNT) return -1;
版本号+校验和(序列化) kSessionMagic = "VITROSV01"
// ❌ 错误:返回悬空指针
#[no_mangle]
pub extern "C" fn vitro_get_errors(s: *mut Session) -> *const c_char {
    let session = unsafe { &*s };
    session.errors.as_ptr()  // 重新分配后指针失效
}

// ✅ 正确:拷贝到调用者缓冲区
#[no_mangle]
pub extern "C" fn vitro_get_errors_buf(
    s: *mut Session, buf: *mut c_char, max_len: i32
) -> i32 {
    if s.is_null() || buf.is_null() || max_len <= 0 { return -1; }
    let session = unsafe { &*s };
    let c_str = CString::new(session.errors.clone()).unwrap_or_default();
    let bytes = c_str.as_bytes_with_nul();
    let copy_len = std::cmp::min(bytes.len(), max_len as usize - 1);
    unsafe {
        std::ptr::copy_nonoverlapping(bytes.as_ptr(), buf as *mut u8, copy_len);
        *buf.add(copy_len) = 0;
    }
    copy_len as i32
}

3.2 VM 内存安全

// 所有 Host Function 内存访问必须通过封装接口
impl VitroVM {
    fn load_i32(&self, addr: u32) -> Result<i32, Trap> {
        let end = addr.checked_add(4)
            .filter(|&end| end <= self.memory.len() as u32 && addr >= k_null_trap_size)
            .ok_or_else(|| Trap::BoundsError(addr))?;
        Ok(read_i32_le(&self.memory[addr as usize..end as usize]))
    }
}

// 禁止裸指针算术
// ❌ memory[p1] = val;  // p1 为负时缓冲区下溢
// ✅ self.store_i32(addr, val)?;

3.3 reset() 必须清理全部运行时状态

impl VitroVM {
    fn reset(&mut self) {
        self.code.clear();
        self.stack.clear();
        self.call_stack.clear();
        self.func_table.clear();        // 不要忘记!
        self.func_names.clear();        // 不要忘记!
        self.host_funcs.clear();
        self.symbols.clear();
        self.vis_event_lines.clear();   // 不要忘记!
        self.vis_event_queue.clear();   // 不要忘记!
        self.breakpoints.clear();       // 不要忘记!
        self.snapshot_vars.clear();
        self.memory.fill(0);
        self.step_count = 0;
        self.heap_offset = k_heap_start;
        // ... 其他状态归零
    }
}

3.4 VM 线性内存是唯一的 1MB 堆:堆分配必须有界隔离

VM 的堆不是宿主 allocator,而是1MB 线性内存中的一段(k_heap_start 起);宿主侧只保留 region 表(记账),不保留用户数据指针。由此产生两条硬约束:

内存布局与有界隔离图(由 go run ./scripts/gen_svg 生成,对账本节与堆有界隔离决议):

1MB 线性内存布局与堆有界隔离

  1. 堆上限即线性内存上限——用户程序 malloc 到 1MB 墙时触发教学 trap("你的程序分配超过内存上限"),不允许穿透到宿主堆;
  2. 释放必须进有界隔离区——free 不立即归还地址,而是标记后进入 FIFO 隔离区;隔离预算为堆上限的 1/4 = 256KB,超预算时按 FIFO 驱逐最老块复用(first-fit)。
场景 期望行为 定性
churn(分配-释放循环) 隔离区稳态在预算内,驱逐复用,无限可跑 合法程序,不误伤
leak(只分配不释放) leak 块不进隔离区,bump 推进直到撞 1MB 墙 教学信号(泄漏报告 + 耗尽诊断)
UAF 检测窗口 = 最近 256KB 的 free 历史 教学检测
Double-Free 隔离窗口内地址不复用,双 free 必检出 教学检测
  • 设计决议全文:堆有界隔离决议.md(2026-09-11 拍板并实施;含验收清单 §6;MoonBit 侧同构落点 = vitro/engine/memory 的 MemoryMap bump + 隔离堆 + first-fit 驱逐)
  • 会话可配置:隔离预算可按会话调参(Rust 冻结区 capi vitro_set_quarantine_budget / vitro_get_quarantine_budget,默认 256KB;MoonBit 侧会话配置随 S7 session 片落形)
  • 诚实记录(已知差异):隔离窗口外的 UAF 可能漏检——若 free(p) 与错误使用之间 churn 已超过 256KB,*p 会落在已复用块上,只表现为"读到别人的值"而非 UAF 诊断(与 ASAN quarantine 行为一致)
  • 协议侧映射:指针快照的 Freed 状态判定依赖该隔离区(见 docs/spec/STEP_PAYLOAD_SCHEMA_V0_1.md §3.1 说明)
  • 记账侧风险:region 表本身是宿主内存。极端 leak + 长步数场景下 region 记录数会增长(每 malloc 至少数个 VM 步,默认 100k 步上限下约数万条记录、数 MB 宿主内存),由步数保险丝 set_max_steps 与 region 表封顶共同约束

四、代码审查 Checklist

4.1 PR 审查时必须逐项确认

前端(Flutter/Dart)自查项已随切割移除,移交社区(见 §2)。

Rust 后端:

  • 所有权检查: 是否存在裸指针解引用未包裹 unsafe?unsafe 块是否最小化?
  • C API 生命周期: 返回的裸指针是否有明确的生命周期文档注释
  • Reset/clear: 新添加的容器字段是否在 reset()/clear() 中处理
  • 溢出检查: addr + size、new_offset 等是否使用 checked_add
  • 跨语言边界: C API / wasm 导出是否返回内部指针/引用?
  • 堆语义: 新增的分配/释放路径是否统一走隔离区出口(allocate_raw / release_to_quarantine),而非绕过记账直接改指针?
  • 线性内存上限: 新路径是否仍受 1MB 墙约束(不得引入宿主堆旁路)?

4.2 高风险变更额外审查

以下变更类型必须双人审查:

  1. 新增 C API 函数(含 wasm 导出)
  2. 修改 VitroVM::reset() 或内存管理逻辑
  3. 修改堆分配/释放语义(bump 指针、隔离区、region 表)
  4. 新增 unsafe 块或裸指针操作

五、静态分析与工具链

5.1 Dart/Flutter 静态分析(已随切割移出)

原 analysis_options.yaml(strict-casts / strict-raw-types / missing_required_param / missing_return / unused_import / avoid_print 等规则)随 CideFlutter/ 于 2026-09-11 移出本仓库,当前仓库不存在该配置文件;这些规则属社区前端职责(历史资产,已迁出)。

5.2 Rust 分析器(Clippy——已随删区退役)

# 历史口径(Rust 区删区后无对象;MoonBit 侧等价 = moon 工具链检查 + surface/pkg_deps 静态闸)
cd native && cargo clippy --all-targets --all-features -- -D warnings

关键规则:

规则 说明
clippy::unwrap_used 禁止在库代码中使用 .unwrap()
clippy::expect_used 限制 .expect() 的使用
clippy::missing_safety_doc unsafe 函数必须有安全文档
clippy::cast_lossless 避免有损类型转换

5.3 预提交检查脚本(历史注记——已随删区删除)

scripts/check-memory-safety.ps1(扫描 Rust 源码内存安全反模式)为"存在但未接线"的工具(2026-09-11 核实全仓零消费),已随 2026-10-05 删区批物理删除;历史形态见 git 历史。MoonBit 侧等价静态闸 = moonbit_surface / pkg_deps / source_hygiene / mbti_sync 族。


六、测试策略

说明:§6.1 与 §6.3 的示例是 C# 前端时代(Phase 3 之前)的遗留代码,不是当前技术栈(现为 Rust + 三出口),仅作"Dispose 路径应被测试"的思路参照,保留为历史资产;§6.2 的 C ABI 压力测试思路仍然有效。

6.1 单元测试:Dispose 路径覆盖(历史示例 · C# 前端时代)

[Test]
public void CompilerService_Dispose_IsIdempotent()
{
    var svc = new CompilerService();
    svc.Dispose();
    svc.Dispose();  // 不应抛异常
    Assert.IsTrue(svc.IsDisposed);
}

[Test]
public void MainViewModel_Dispose_CancelsPendingFlash()
{
    var vm = new MainViewModel();
    vm.RunCodeAsync();  // 启动某些异步任务
    vm.Dispose();       // 应取消所有任务
    // 验证无异常
}

6.2 Stress Test:重复编译-运行-Destroy(思路有效)

TEST(MemoryStress, CompileRunDestroy_1000Times) {
    for (int i = 0; i < 1000; ++i) {
        auto* s = vitro_session_create();
        vitro_compile(s, "int main() { return 0; }");
        vitro_run(s);
        vitro_session_destroy(s);
    }
    // 进程 RSS 不应持续增长
}

这是当前仍然适用的压力测试思路。原等价防线(native/tests/host_contract_tests.rs + fuzz_stress_test)已随删区退役——现役承接 = scripts/host_contract_map(冻结名册对 MoonBit 锚三态对账,条数见该脚本 rust_contract_names.json)+ moon test 内 fuzz 不变量自检 + clang_direct 直拍。wasm 出口的等价压力测试尚未建立(本仓库不含 wasm 侧用例驱动),如实记为缺口。

6.3 监控指标(历史示例 · C# 前端时代)

以下示例随 C# 前端时代结束而失效,不作为当前实现依据,仅保留其"应观测峰值内存与释放状态"的意图:

// 历史示例(当前技术栈无此代码)
Debug.WriteLine($"[VITRO_MEM] MainViewModel disposed. Compiler disposed: {_compiler?.IsDisposed}");
Debug.WriteLine($"[VITRO_MEM] CompilerService disposed. Session: {_session}");
Debug.WriteLine($"[VITRO_MEM] Process exit. PeakWorkingSet: {Process.GetCurrentProcess().PeakWorkingSet64 / 1024}KB");

当前对应的可观测手段:cmd/serve 的 JSON-lines 会话帧(MoonBit 侧现役)、vitro step 的执行摘要等。⚠️ 尚无内存/堆统计的协议导出——原 capi 计划项 vitro_get_heap_stats_json 已随 U2 拍板(2026-09-19)裁不做(capi 出口随删区整体裁撤);MoonBit 侧堆统计出口随 S8 教学智能片裁定(memory.regions 三段式地图已可作观测通道)。


七、相关文档索引

文档 说明
架构设计.md 总体架构设计
构建指南.md 构建指南
堆有界隔离决议.md 堆内存 Bump 分配 + 有界隔离决议(256KB 隔离预算)
STEP_PAYLOAD_SCHEMA_V0_1.md 三出口载荷 schema(指针四状态等)
后端定位与白箱计划.md 前端切割与三出口路线
ARCHIVE_INCIDENT_2026_04_26_MEMORY_LEAK.md 历史泄漏事件记录与修复详情

本文档由 Kimi Code CLI 维护。每次发现新的内存安全问题时,必须同步更新本文档的"代码审查 Checklist"和"相关文档索引"。

2026-09-11 修订:完成前端切割后的现状对齐(适用范围改纯后端仓、前端契约压缩移交、补 wasm 线性内存与堆有界隔离、注明预提交脚本零消费引用)。