# S3：跨包句柄、生命周期与运行时落地

S3 把 S2 的"能通过类型检查的接口"变成"能真的跑一次跨语言调用"的绑定。判定标准同样只有一条，并且是可重复执行的：

```bash
bash scripts/e2e.sh   # 语料 → stub → 可调用模块 → wasm guest → Python → 10 万次调用
```

2026-11-06 实测：生成的 `parse` / `valid` / `Json.stringify` / `ParseError` 全部调通真实的 MoonBit guest；10 万次跨语言调用后 guest 内存**逐字节不变**，句柄表**归零**，脚本以 0 退出。

---

## 一、跨包 stub 导入

S2 的 `.pyi` 是自洽的：任何跨包类型都降级为 `Any`，这样每个文件可以独立检查。代价是真实语料里 **4812 处 `foreign`**——一个包引用了隔壁包的 `Json`，Python 侧看到的却是 `Any`。

S3 用 S1 建好的世界索引（包 → 类型+元数、别名 → 包路径、`<宿主包>::<类型>` → 方法）把这条边补上：

| 指标 | S2 | S3 |
|---|---|---|
| `degraded: foreign` | 4812 | **605** |
| `degraded: unknown` | 890 | **255** |
| 生成模块间的导入边 | 0 | **566**（276 个模块有出边） |
| `mypy --strict` | 0 报错 | 0 报错 |

剩下的 605 处不是没做到，而是**真的不可解**：包导入了不在本次语料里的包。

### 依赖环

MoonBit 的包图里环是常态（`json` 引用 `builtin` 的 `Json`，`builtin` 又声明 `Json::stringify` 这种由 `json` 定义的方法）。两种输出用两种办法处理：

- **`.pyi`**：`from . import <stem> as _<stem>`，直接写。Python 从不执行 `.pyi`，类型检查器自己解环。
- **`.py`**：顶层导入放进 `if TYPE_CHECKING:`，函数体里再按需导入一次。模块级导入在互相引用时必然失败；注解在 `from __future__ import annotations` 下是字符串，运行期不需要那个名字。

### 方法落在谁的类上

`Json::stringify` 由 `moonbitlang/core/json` 声明，`Json` 这个类却由 `moonbitlang/core/builtin` 发出。世界索引让方法能落到接收者真正的宿主类上，符号名仍然是声明方的路径：

```python
# moonbitlang__core__builtin.py
def stringify(self, /, *, escape_slash: Optional[bool] = None, ...) -> str:
    return _runtime.default().request("moonbitlang/core/json::Json::stringify", [self, ...])
```

这条字符串和 guest 的 dispatch 表里那一条**逐字符相同**，因为两边都从同一个 `<包>::<类型>::<方法>` 规则生成。

---

## 二、句柄表与生命周期

一个 MoonBit 值从不复制进 Python：Python 对象里只有一句整数，值本体留在 MoonBit 堆上。这让身份、可变性、递归结构都活着，代价是两侧都要维护 `id -> object` 的映射。

### id 布局

```text
id = (slot << 10) | generation      generation >= 1
```

- **表持有值**：句柄存续期间表握着引用，MoonBit 的回收器不会动它；释放即丢引用。
- **每个 id 带世代**：槽位会复用，没有世代计数器的话，一个旧 id 会静默地指到别人身上——悬挂句柄以"数据错了"的形式出现。有了计数器，旧 id 解析失败，宿主拿到的是一个显式错误。
- **世代 0 保留**：宿主的"没有对象"就是 0（`weakref.finalize` 跳过、`close()` 直接返回），所以活句柄不能是 0。
- **世代用尽即退槽**：一个槽位服务 1023 次之后退役，永不回到空闲列表。见第四节。

20 位槽位 = 一百万活对象；1024 个槽位一轮 = 一个槽位 1023 条命。

### Python 侧

`_runtime.py` 里的基类 `MoonObject`：

| 入口 | 语义 |
|---|---|
| `close()` | 立即释放；幂等（`close()` 和终结器会赛跑） |
| `with doc:` | 退出时 `close()` |
| 什么都不做 | 对象被回收时 `weakref.finalize` 入队，满 64 个批量释放一次 |
| `handle_id` | 释放后为 `0`，可直接用来断言 |

不用 `__del__`：它可能在解释器关闭期间运行，那时它需要的运行时可能已经没了。`weakref.finalize` 在那个时刻是安全的，也不可能让对象复活。

批量释放不是省事，是必须的：一次释放就是一次跨语言调用，逐个释放会让"丢弃对象"比"调用方法"还贵。

---

## 三、运行时接入

guest 是 `moon build --target wasm --release` 产出的普通 wasm 模块，导出 6 个函数——这就是整个 ABI 面：

```text
mb_in_reserve(n) -> ptr     增长输入缓冲并返回对象指针
mb_stage() -> ptr           响应缓冲的对象指针
mb_call(in_len) -> out_len  跑输入缓冲里的那一帧
mb_live_handles() -> count  句柄表里还握着多少对象
mb_probe() -> ptr           带指纹的 Bytes，供宿主编排布局发现
mb_probe_out() -> ptr       带指纹的 FixedArray[Byte]，同上
```

宿主从不假设 MoonBit 的 `Bytes` 把数据放在对象的第几个字节。它在加载时做一次指纹探测，自己量出偏移。工具链哪天改了布局，错误会在这里显式抛出，而不是变成一份被静默损坏的载荷。

帧格式是 `u32 小端符号长度 | 符号 | 载荷`，载荷是 S3 的二进制编解码（`src/abi/codec.mbt`，Python 侧 `_codec.py` 是它的镜像，golden vector 全部逐字节对齐）。

---

## 四、基准

`bench/bench.py`，10 万次调用，Windows / CPython 3.13 / wasmtime：

| 用例 | 总耗时 | 每次 | p50 | 说明 |
|---|---|---|---|---|
| `echo` | 3.44 s | 34.4 µs | 29.1 µs | 协议地板：解一个值、编回去 |
| `valid` | 3.88 s | 38.8 µs | 32.6 µs | 字符串进、布尔出，无对象生命周期 |
| `parse` | 9.38 s | 93.8 µs | 73.7 µs | 真解析 + 句柄 + 显式释放 |
| `parse/gc` | 5.66 s | 56.6 µs | 39.6 µs | 同上，释放交给批量回收器 |

guest 线性内存（KiB），括号内是调用次数：

| 用例 | 1k | 10k | 100k |
|---|---|---|---|
| `echo` | 64 | 64 | 64 |
| `valid` | 64 | 64 | 64 |
| `parse` | 64 | 64 | 64 |
| `parse/gc` | 128 | 128 | 128 |

10 万次调用内存**一次都没涨**，句柄表在每个检查点都回到 0（`parse/gc` 故意留 5 个活对象穿过多个批次，跑完再丢，仍然是 0）。基准以非零退出码回答"漏了没有"，所以它能进 CI。

### 基准抓到的三个 bug

这三个都不是设计问题，是实现问题，而且**都不报错**——只有把曲线画出来才看得见。这是把基准写成断言而不是写成报告的全部理由。

**1. 0 号句柄。** 第一个句柄是 `0 << 10 | 0 == 0`，正是宿主的"没有对象"。于是它既没有注册终结器，`close()` 也直接返回：第一份解析出来的文档永远不会被释放，而 live 计数从头到尾是 1，没有任何异常。修法是世代从 1 开始。

**2. 导出函数的返回值泄漏。** guest 导出的函数返回的任何对象都会多一个引用，而宿主没有办法减回去——它只能调用导出函数，改不了 MoonBit 的引用计数。响应只要是"每次新编码出来的 `Bytes`"，就每次留下一个。实测 41 字节/次，40 万次调用涨到 16 MB，而句柄表一直是空的。

定位靠二分：单独调 `mb_call` 十万次，内存**一个字节都不涨**；把"取响应指针"那一步加回来，立刻开始涨。

修法是响应缓冲改成 `FixedArray[Byte]`——`Bytes` 在这个 core 版本里不可变，没法原地重填，可变数组可以。编码器照旧产出 `Bytes`，`publish` 把它 blit 进宿主已经握着指针的那个数组。同一个对象每次复用，什么都不累积。宿主也不再每次要指针，只在响应比历史最大值更大时刷新（数组只会在不够时重新分配）。顺带把每次调用的往返减少一轮，`echo` 从 46 µs 降到 34 µs。

**3. 世代回绕。** 世代字段十位，同一个槽位第 1024 次释放会绕回 0；对 0 号槽位来说就是 `id == 0`，又是那个"没有对象"。症状是每 1023 次泄漏一份文档，看起来像 `close()` 的 bug，其实是表的问题。修法是槽位服务满 1023 次后退役：停在最后一个世代，永不回到空闲列表。代价是每 1023 个对象退役一个八字节槽位，`retired_count` 把它报出来而不是藏起来。

---

## 五、还没做的

诚实地列出来，因为它们是 S4 的范围：

- **读字段、写字段、构造对象**。`.pyi` 里声明了 `@property` 和 `__init__`，`.py` 里没有绑定——两者都需要一个只有 guest 能导出的符号。宁可少给一个方法，也不给一个"调了才知道不行"的函数。
- **dispatch 表是手写的**。`src/guest_json/guest.mbt` 里的分支对应 `moonbitlang/core/json` 的 4 个函数。S4 从同一张类型图生成它，让绑定的两半永远描述同一个面。
- **泛型在边界上被擦除**。`Box[T]` 的 `T` 进不了句柄；每个具体类型的句柄表现在是一张 `HandleTable[Json]`，靠人工声明。S4 按用到的类型自动生成。
