「啊鸡学习 Node FFI」系列(三):上一篇是啊鸡学习 Node FFI 之《线程与回调篇》,本篇是系列收尾。
先看 JavaScript 想做的事:
const device = openDevice(onEvent);
const ab = new ArrayBuffer(8);
const tx = Buffer.from(ab, 2, 4);
tx.set([0x10, 0x20, 0x30, 0x40]);
device.send(tx);
await device.close();
只有三步:打开设备、发送 4 bytes、关闭设备。Native bridge 背后却同时有 JavaScript view、V8 BackingStore、SDK handle、callback function pointer、foreign thread 和 libuv handle;只要其中一个对象比预期多活或少活一会儿,就可能变成泄漏、double free 或 callback-after-free。
本文跟一组具名对象走完这段生命周期:JavaScript wrapper device#1、8-byte ArrayBuffer ab#1、其中 offset 2 / length 4 的 view tx#1、SDK handle h#7、native control block bridge#1、callback shell reg#1、libuv handle notify#1、JavaScript callback ref listenerRef#1,以及一次事件副本 payload#17。地址均为示意值。
对应的教学 SDK:
typedef struct sdk_handle sdk_handle;
typedef void (*event_cb)(
const uint8_t* data,
size_t length,
void* user_data
);
int sdk_open(sdk_handle** out);
int sdk_register_event(sdk_handle* handle, event_cb callback, void* user_data);
int sdk_send(sdk_handle* handle, const uint8_t* data, size_t length);
int sdk_start(sdk_handle* handle);
void sdk_stop(sdk_handle* handle);
void sdk_unregister_event(sdk_handle* handle);
void sdk_close(sdk_handle* handle);
本篇的契约(真实 SDK 要逐项查证):
sdk_open()返回成功才保证*out已初始化;sdk_send()只借用输入 bytes 到返回,不保存 pointer;- callback 的
data只活到本次返回; sdk_stop()同步停 producer;sdk_unregister_event()同步移除 registration,返回时已选中的调用要么已取消、要么已进入 callback 并计数,此后没有新入口,已进入的仍要等它们退出;sdk_close()是成功sdk_open()的唯一匹配释放。
1. sdk_open(&h) 改写了哪个 cell
sdk_handle* h = NULL; // hCell#1
int status = sdk_open(&h);
调用前后(地址为示意值):
调用前:hCell#1 @ 0x1100 保存 NULL
调用时:sdk_open(0x1100)
成功后:hCell#1 @ 0x1100 保存 0x7000 —— h#7 的位置
这里有三种不同的 cell:sdk_handle 是对象本身;sdk_handle* h 是保存对象地址的 cell;sdk_handle** out 接收的是这个 cell 的地址。C 仍是按值传参,sdk_open(&h) 复制给 callee 的值是 0x1100,所以 *out = handle 能写回 hCell#1;若 callee 只写 out = other,改到的只是自己的局部副本。

Pointer depth 只说“沿几次地址到达目标”,不携带 ownership。hCell#1 保存了 h#7,不代表 addon 能对 h#7 调 free()——契约指定的匹配操作是 sdk_close(h#7)。
2. tx#1 是一段 view,不是“归 C 的内存”
开篇代码创建了 8 bytes 的 ab#1,再让 tx#1 只覆盖中间 4 bytes:
ab#1: 00 00 10 20 30 40 00 00
└── tx#1 ──┘
offset = 2, length = 4
tx#1 不是一块独立 allocation:它携带 type、offset 和 length,经 ArrayBuffer wrapper 到达真正承载 bytes 的 V8 BackingStore。

Native Addon 取地址时,得到的是这段 view 的借用:
void* bytes;
size_t length;
napi_get_buffer_info(env, tx, &bytes, &length);
// bytes == base + 2,length == 4
sdk_send(h, bytes, length);
契约下 sdk_send() 返回后 SDK 不再访问这段地址,borrow 到此结束;addon 从未取得 BackingStore 的 ownership,也不能自行释放它。
几种 Buffer.from 构造要分清:
| 操作 | 字节关系 |
|---|---|
Buffer.from(arrayBuffer, offset, length) | alias,与其他 view 看见同一 BackingStore 的写入 |
Buffer.from(bufferOrUint8Array) | byte copy,两边拥有独立字节 |
Buffer.from(otherTypedArray) | element conversion;复制 raw bytes 应使用 Buffer.copyBytesFrom() |
transfer / detach、SharedArrayBuffer 共享、external Buffer 各有自己的规则,不要从 Buffer 外观推断所有权。如果 SDK 要在 sdk_send() 返回后继续使用 bytes,就不能靠“Buffer 还活着”带过:要么复制快照,要么完整移交 ownership,要么加同步协议共享——省下的复制越多,要证明的东西也越多。

本例 sdk_send 用短同步 borrow,callback 用 copy snapshot,把 SDK bytes 和 JavaScript bytes 切成两个容易验证的生命周期。另外,strong napi_ref 不能替代这些协议:它只保证 JavaScript 值不被 GC 回收,不阻止 detach 或并发修改,也不给 foreign thread 增加 Node-API 权限。
3. payload#17 怎样越过 callback 的借用期
上一篇的结论在这里直接用:SDK thread 调进 function pointer 后,handler 仍在那条线程上,借来的 bytes 只活到返回。本例的 event_cb 是编译进专用 Addon 的 native adapter(不是运行时生成的 libffi trampoline),每个 device 单独持有 reg#1 这个稳定 shell。它的做法:先把 bytes 复制成 payload#17,存进一个 latest-state pending slot,再用 notify#1 这只 uv_async_t 门铃唤醒 owning loop:
void event_cb(
const uint8_t* data,
size_t length,
void* user_data
) {
auto* reg = static_cast<Registration*>(user_data); // reg#1
RegistrationGuard guard(reg); // 构造时 active_callbacks++,析构时 --
if (guard.bridge() == nullptr) return;
Payload* payload = copy_bytes(data, length); // payload#17
if (payload == nullptr) return;
if (!publish_latest(guard.bridge(), payload)) {
free(payload); // ownership 尚未转移
return;
}
int send_status = uv_async_send(&guard.bridge()->notify);
if (send_status < 0) {
record_wakeup_failure(guard.bridge(), send_status); // 只记状态
}
}
publish 成功后,slot 就是 payload#17 的 owner(若替换了旧值,替换时恰好释放旧 payload 一次);uv_async_send() 失败也不能把 ownership 拿回来——payload 留在 slot 里,等下一次成功唤醒或关闭时统一处理,本例不承诺自动重试。Owning loop 醒来后取走 payload、复制成 JavaScript Buffer、释放原生副本,再调用 listenerRef#1 指向的 onEvent(rx#17)。
listenerRef#1 只让 JavaScript callback 保持可达,不保活 SDK 的 bytes,也不参与线程同步。到这里,把出场的对象记进同一张账:
| 对象 | 当前 owner | 借用者 | “无人再访问”的证据 | 匹配释放 |
|---|---|---|---|---|
h#7 | bridge#1 | SDK worker | SDK quiescence | sdk_close(h#7) |
reg#1 callback shell | bridge#1 | SDK 通过 user_data 借用 | quiescence 且 active callbacks 为零 | shell release |
0x9000 event bytes | SDK | 当前 callback | callback 返回 | addon 不释放 |
payload#17 | producer → slot → loop | 无 | take 或 shutdown drain | free(payload#17) |
tx#1 的 BackingStore | V8 / JavaScript | sdk_send() | 同步调用返回 | addon 不释放 |
notify#1 | bridge#1 | libuv loop | uv_close callback | 归还 handle token |
listenerRef#1 | ordinary-ref registry entry | owner loop | exactly-once delete 成功 | 归还 entry token |

账本里有三种不同的“计数”,不能互相替代:napi_ref 记录 JavaScript reachability,active_callbacks 记录已进入 shell 的调用,native lifetime token 保证 control block 最终才被释放。
4. closing 检查为什么挡不住 callback-after-free
假设关闭方只写了:
if (reg->active_callbacks == 0) {
free(reg);
}
下面这个交错仍然会出错:
SDK thread 已取得 function pointer 和 user_data = reg#1,
但尚未进入 reg#1,也尚未 active++
close thread 读到 active_callbacks == 0
close thread free(reg#1)
SDK thread 调进编译好的 event_cb,解引用 reg#1 → use-after-free
局部 counter 看不到“已取得 pointer、尚未进入 guard”的调用。释放 callback domain 需要两份独立证明:SDK quiescence(不会再有新入口,包括已取到 pointer 还没进来的)AND active_callbacks == 0(已进入的全部退出)。join() 或 timeout 都不能替代这两条——被等待的线程得先不再依赖 loop、队列或锁,join 才能推进;SDK 若给不出可验证的退出屏障,宁可放进可重启的子进程。

这是 UAF,不是 deadlock:reg#1 已释放,但 SDK thread 仍有一条访问路径。
5. 从 OPEN 到最终释放的关闭路径
先只沿显式 device#1.close() 走;GC 与 Environment teardown 是另一条时钟,下一节再说。关闭只需要三个可读状态加一次终止事件:
OPEN:接受业务调用,callback 可以取得业务 state;CLOSING:拒绝新业务,正在停 SDK、清空 callback / payload、关闭 addon-owned async resources;CLOSED:h#7、callback、payload、notify#1等业务资源全部结束,只剩稳定 control block;- 最后一个持有者把 token 计数减到 0,执行唯一一次 pure-native free——这一刻 control block 已不存在,所以没有可读的
RECLAIMED状态。
显式 close 的时间线:
- coordinator 以 CAS 把
OPEN改成CLOSING,重复 close 合并到同一结果,并拒绝新的send与 callback publication; sdk_stop()/sdk_unregister_event()建立 entry fence;- 等
reg#1.active_callbacks == 0,证明已进入的 callback 全部退出,再断开并释放reg#1; - 清空 pending payload,调用
sdk_close(h#7); - 在 owner loop 发起
uv_close(¬ify#1, ...),等 close callback 证明 handle 真正结束; - SDK、callback、payload、handle 全部结束后发布
CLOSED。
await device#1.close() 在 CLOSED 时 resolve。它不等 wrapper GC,也不等 control block 的最终 free——否则显式 close 反而要等一个还可能被 JavaScript 变量持有的 device#1。

图中蓝框最后一行 native_tokens == 0 属于最终回收的门槛,不是 CLOSED 的前提。
让“最后一个持有者”成立的关键是 token:任何可能晚到、还要访问 bridge#1 的对象(notify#1 的 close callback、ref registry、cleanup hook)都要先领一枚 token 再发布自己的地址,coordinator 从开始关闭前就持有自己的 token,直到在同一把 lifetime 锁下发布 CLOSED 后才归还;所有 token 都在这把锁下归还,只有 state == CLOSED && native_tokens == 0 才能 final free,此后不再有合法新 owner。规则四句:发布地址前先领 token;交接时新 owner 先领、旧 owner 后还;进入 CLOSING 后不允许无主发布;计数归零不可复活。

图 7 放大第 2–3 步的门槛:SDK quiescence 关闭新入口,active_callbacks == 0 排空已进入者,两个证明都满足才允许断开并释放 reg#1——还不允许 free bridge#1。图中的 trampoline 泳道属于通用 FFI closure 变体;本例编译进 Addon 的是 event_cb 代码;reg#1 是逐 device 持有并按 registration 生命周期释放的数据 shell。
6. 坑还有很多:GC、Worker 退出与 Environment teardown
显式 close 之外还有另一条时钟:wrapper 被 GC、Worker terminate()、进程退出。这时没人调 device#1.close(),Node 靠 finalizer 和 async cleanup hook 兜底,而它们之间没有可靠的先后顺序。工程上把两类事分开:
- 业务关闭(停 SDK、等 quiescence、清 payload、关 handle)只由显式 close,或 teardown 会等待完成的 async cleanup hook 驱动;
- 独立清理(删 ref、删 TSFN associated data)可以晚于
CLOSED,各自只归还自己那枚 token。
Finalizer 拿到的权限有限:basic finalizer 只能做一小部分 cleanup,普通 finalizer 也不能因为“最先看见 OPEN”就去执行 stop / join 这类 teardown-critical 工作,只能请求已在运行的关闭流程。

这套协议的完整规则(ordinary/special ref、cleanup hook、post callback 各自的 token 归还)值得单独写一篇,这里只留结论:每个 token 有唯一的实际归还者,最后一次归还只做不调用 Node-API 的 pure-native free。另外,若 SDK 可能永久 hang 或 native crash,进程隔离加 parent watchdog 仍是最后防线——Worker 隔离不了同进程的 segmentation fault。
版本上留个提醒:Node.js issue #65100 报告 Worker / Environment teardown 时 TSFN crash,在 24.15.0、24.16.0、24.19.0 复现;截至本文核对,修复 PR 仍未合入。涉及 teardown 的代码要在目标版本上单独验证。
7. 怎么验证这套契约
不要只跑一次 open → callback → close。给每个不变量配一个能推翻它的实验:在 CLOSED 后核对 payload 守恒(created = delivered + rejected + replaced + cleaned);让 callback#18 在 guard 里暂停,看 release 是否推迟到 active 归零;显式 close 后继续持有 device#1,确认业务已关而 control block 仍被保活;在受控 fixture 上用 ASan 抓 UAF、TSan 抓 race。危险实验只放可丢弃子进程,用自己编译的最小 fixture 和合成数据。
收尾只留四个问题,审查任何 bridge 时依次问:
- 每个 pointer cell 保存什么?匹配的 release 是谁?
- 当前字节关系是 alias、copy 还是 borrow?谁能 free?
- 谁证明不再有新 callback?谁证明已进入的全部退出?
CLOSED的前提是什么?最后一次 free 是否 exactly once?
有一格答不上来,就还没证明这块内存可以在此时被访问或释放。
References
- Node.js: Borrowed ArrayBuffer and Buffer pointers
- Node.js: Buffer 构造函数的字节语义
- Node.js: napi_ref 的生命周期与删除
- V8: BackingStore lifetime and ownership
- libuv: uv_close completion and handle lifetime
- Node.js: Thread-safe functions
- Node.js: basic finalizer API restrictions
- Node.js: Environment cleanup hooks
- Node.js issue #65100: TSFN teardown crash
- Node.js PR #65967: TSFN finalization re-entry fix
Discussion
留言与讨论
想法、补充和不同意见都欢迎。