computer 仓库中的 capnweb RPC:stub 生命周期与释放契约实战指南
【免费下载链接】computerGive your agent a computer 👾项目地址: https://gitcode.com/GitHub_Trending/computer1/computer
本指南以 .agents/skills/capnweb/SKILL.md 为骨架,系统讲解本仓库中 Durable Object ↔computerd之间 capnweb RPC 的传输形态、对象能力(object-capability)模型,以及最重要的stub 生命周期与释放(disposal)契约。读完本文,你将掌握 caller-disposes 规则、using/try-finally/ 显式Symbol.dispose三种释放模式、.dup()与.map()的正确用法,并能用enableStubTracking()+stubSnapshot()与script/computerd-stub-soak.mjs对 RPC 边界做泄漏验证。
capnweb 在本仓库中的定位
capnweb 是 Durable Object 与computerd之间的 RPC 帧协议(framing)。它是一套对象能力 RPC 系统,具备三个核心特征:
- Promise 流水线(promise pipelining):可以在不等待前一个调用完成的前提下,把结果直接作为参数继续调用,减少往返次数;
- structured-clone 风格的 stub 传输:stub 可以作为参数或返回值跨连接传递;
- 双向调用:连接两端都可以发起调用,而非严格的 client/server 单向模型。
本仓库的线格式(wire format)是长连接 WebSocket 上的文本 JSON。这是唯一被使用的 carrier:capnweb 的 HTTP 批传输(batch transport)无法承载"从调用中返回的流",而本接口上每一次读取本质上都是一个流(change 流、对象字节流、exec 事件流),所以只能走 WebSocket 长连接。
从代码结构看,这正是 packages/rpc/src/client.ts 中
createSyncClient/createWorkspaceClient通过newWebSocketRpcSession(ws)拨号、并由 packages/rpc/src/server.ts 的acceptWebSocketSession(ws, rpc)挂载服务端的根本原因——两端共享同一套 capnweb 会话。
Where things live:代码地图
本仓库把 capnweb 相关的类型、服务端、客户端、驱动与调试面拆在packages/rpc包内,各司其职:
| 文件 | 职责 |
|---|---|
| packages/rpc/src/interface.ts | 类型化的线协议面。WorkspaceRPC是根 stub,组合SyncRPC与ShellRPC。新增方法先改这里。 |
| packages/rpc/src/server.ts | 以Database为后端的具体实现。Durable Object 与容器内 computerd 共同导入它。 |
| packages/rpc/src/client.ts | 基于 WebSocket carrier 的类型化 stub。Durable Object 使用deferred transport,使 stub 可以在 WebSocket upgrade 完成之前就创建。 |
| packages/rpc/src/sync-driver.ts | pullOnce、pushOnce、tick。包装流式方法并在内部处理释放。 |
| packages/rpc/src/debug.ts | enableStubTracking、stubSnapshot,用于泄漏排查。 |
| docs/08_capnweb_interface.md | 线协议的设计意图与完整接口说明。 |
| docs/11_lifecycle.md | 本仓库的 stub 释放契约与跨生命周期矩阵。 |
其中 packages/rpc/src/interface.ts 定义了组合根接口:
export interface WorkspaceRPC { sync: SyncRPC; shell: ShellRPC; }线面上只暴露一个稳定根 stub,两个半区(同步面sync、进程监督面shell)保持内部可分离、可独立测试。服务端实现 packages/rpc/src/server.ts 用getter暴露sync/shell,这是因为 capnweb 的RpcTarget拒绝遍历普通实例属性(会抛出 "instance properties cannot be accessed over RPC"),getter 在分发路径上看起来像方法,可以被正常穿透。
心智模型:stub 是能力,不是可回收的引用
一个 stub 就是一份能力(capability):持有它,就意味着拥有调用远端对象的权利。capnweb 的 stub不会跨网络被垃圾回收:
- 本地 GC 对远端的对象图没有任何可见性;
- 远端运行时也不知道你是否处于内存压力之下;
- 如果你不显式释放 stub,就会在连接的另一端泄漏资源。
这一点在本仓库比多数 capnweb 部署更关键,因为连接是长连接。短命会话在结束时会把一切释放掉;而 Durable Object 与computerd之间的这条 WebSocket在整个 Workspace 生命周期内保持开启,所以每一个未释放的 stub 都会一直存活到这条连接断开为止。
结合 docs/11_lifecycle.md 的描述:capnweb 会话持有的导出表(export table)、应答表(answer table)、活动流与 socket 引用全部是纯内存的,不跨 isolate 驱逐、OOM 或容器重启存活。泄漏的 stub 会以"导出表条目"的形式长期钉在长连接上,直到会话死亡。
caller-disposes 规则:capnweb 的基本法则
来自 capnweb 规范的根本原则是:
调用方负责释放所有 stub。
具体展开为三条细则:
- 作为参数传入的 stub 仍归调用方所有。被调用方收到的是 RPC 系统在调用完成时自动释放的副本。你可以在调用后立即释放自己的原始 stub——它在发送时已经被复制了。
- 结果中返回的 stub 将所有权转移给调用方。调用方必须释放它们。RPC 系统在确认不会再有流水线调用到达后,会释放被调用方的副本。
- 结果信封(result envelope)总是带 disposer,即使你认为它不包含 stub。无论如何都释放它——未来某个 API 变更可能加入 stub,而你的调用方会因此静默泄漏。
为什么信封也要释放:驱动层的实证
packages/rpc/src/sync-driver.ts 中的maybeDispose是这条规则在驱动层的落地:
// Best-effort dispose of a capnweb result envelope. Real envelopes // expose [Symbol.dispose]; the test fakes return plain objects, so // the symbol may be absent. function maybeDispose(value: unknown): void { const d = (value as { [Symbol.dispose]?: () => void } | null | undefined)?.[Symbol.dispose]; if (typeof d === "function") d.call(value); }fetchChanges的返回信封里同时含有currentCursor、appliedPushCursor与stream三个字段,其中stream本身是一个导出表条目中的流 stub。pullOnce在 packages/rpc/src/sync-driver.ts 用try/finally保证无论正常排空、提前返回、跨端不变量触发还是批内抛错,信封都会被释放,从而连带拆除内层流 stub、释放远端迭代器。
How to dispose:三种释放模式
按偏好顺序排列:
// 1. `using` 声明——stub 是作用域局部时首选。 using result = await client.sync.fetchChanges({ sinceRev }); for await (const entry of result.stream) { // ... } // 块退出时 result 自动释放。// 2. try/finally——当 `using` 不可用或作用域别扭时。 const result = await client.sync.fetchChanges({ sinceRev }); try { for await (const entry of result.stream) { // ... } } finally { result[Symbol.dispose](); }// 3. 显式释放——当所有权跨边界转移时。 const stub = api.getThing(); // ... 把 `stub` 传给别处 ... stub[Symbol.dispose]();using依赖 TypeScript 5.2+ 的显式资源管理(Explicit Resource Management)提案。它把"作用域结束即释放"内建进语言,是驱动代码和直接流式调用方最不容易出错的选择。
Repo-specific disposal contract:本仓库的释放契约
在通用规则之上,本仓库还有四条明确约定(见 packages/rpc/README.md 与 docs/11_lifecycle.md):
- 驱动(driver)拥有自己的 stub。
pullOnce/pushOnce会释放它们接触到的每一个信封。走驱动的代码无需考虑释放问题。驱动不拥有定时器——调用方决定何时调用pullOnce()与pushOnce()(生产环境是轮询循环,测试里是手动tick()以保证收敛确定性)。 - 直接流式调用拥有自己的结果。如果你直接伸手到
client.sync/client.shell调用fetchChanges、fetchObjects、shell.exec、shell.getExec,你就拥有那个信封。用using绑定或在finally里释放。信封上的流是"正文";排空流并不会释放信封本身。 - 关闭会释放根。
createSyncClient/createWorkspaceClient在调用client.close()时释放根 stub。不要自己拆除底层 WebSocket,让close()级联完成。在 packages/rpc/src/client.ts 中可以看到:close()先对根 stub 调用Symbol.dispose(让 RPC 层在传输死亡前发送干净的 abort 帧),再关闭 socket,最后用 200ms 超时兜底避免 await 永远挂起——整个过程幂等。 - 不要为了看一眼而 await 一个 stub。await 一个
RpcPromise会解析它;如果它解析出 stub,你从此就拥有那个 stub,必须负责释放。
跨端不变量:释放之外的对称检查
pullOnce还在驱动层实现了跨端水印不变量检查(见 packages/rpc/src/sync-driver.ts):当appliedPushCursor.rev < localPushRev或currentCursor < after(远端日志比我们记忆的更短,典型场景是 WebSocket 存活期间 computerd 进程重启)时,把分歧的游标重置为 0、取消在途流、重试一次;第二次分歧则视为真正的协议破坏,直接抛错而非无限循环。这与reconcileWatermarks(连接时调用)共同构成了"协议为断流重试而设计"的耐久性底座。
Duplicating stubs with.dup()
如果你需要把 stub 传给一个会释放它的地方,同时又想在本端继续使用,调用stub.dup()。底层目标会一直存活,直到每一份副本都被释放。
.dup()同样适用于 stub 或 promise 的属性。这是在不额外往返的前提下,抓取一个 stub 形态属性的惯用法:
// 立即抓取 `authedApi` 作为 stub,无需 await。 using authedApi = api.authenticate(token).dup(); // 立刻用于流水线调用。 const userId = await authedApi.getUserId();Listening for disposal on the server:服务端监听释放
一个RpcTarget可以声明Symbol.dispose方法。capnweb 会在每一个指向该 target 的 stub 都被释放后调用它一次:
class SessionTarget extends RpcTarget { // ... [Symbol.dispose]() { // Release any per-session resources. } }如果同一个 target 被多次传给 RPC,你会得到每次 stub 对应一次dispose 调用。要把多次释放折叠成一次,用new RpcStub(target)包装一次,然后到处传递这个 stub 即可。
本仓库的 packages/rpc/src/server.ts 正是这么做的:SyncRPCServer、ShellRPCServer、WorkspaceRPCServer都在构造函数里调用trackStub(this),在[Symbol.dispose]()里调用untrackStub(this),与 packages/rpc/src/debug.ts 的泄漏计数器挂钩。
Listening for disconnect:监听断连
stub.onRpcBroken(cb)在 stub 变得不可用时触发——典型场景是底层连接断开,或对 promise 而言是 promise 被拒绝。回调执行后,该 stub 上的每一个方法调用都会抛错。
packages/computer已经把onRpcBroken折叠进了 Workspace 的 closed promise;复用那套管道,而不是另接一套并行监听器。结合 docs/11_lifecycle.md 的说明:会话死亡在 Workspace 后端边界处理,close 事件、capnwebonRpcBroken回调、容器退出或分类后的传输错误都会移除并关闭对应 handle,安全可重放的操作会自动重连一次。
Promise pipelining:一个 RpcPromise 也是 stub
一个RpcPromise同时也是其最终结果的 stub。除非你确实需要本地值,否则不要 await 它:
// 三次调用,一次往返。 using authed = api.authenticate(token).dup(); const profile = await api.getUserProfile(authed.getUserId());你可以把RpcPromise作为参数传给另一个 RPC。capnweb 会在接收方交付调用前,用解析后的值替换它。
需要注意:对 stub 或 promise 做属性访问返回的RpcPromise没有自己的 disposer——你必须释放它来源的那个 stub 或 promise。属性可以作为参数或返回值传递,但这永远不会导致任何东西被隐式释放。
这正是 deferred transport 的价值所在(见 packages/rpc/src/client.ts):在 WebSocket 达到 OPEN 之前,stub 上的首次调用会排队,upgrade 一完成就冲刷出去——stub 可以在连接建立前就创建,流水线因此从一开始就可用。
The magic.map():一次往返内跑同步回调
.map()在 promise 的远端解析值上运行一个同步回调,且只需一次往返:
const idsPromise = api.listUserIds(); const names = await idsPromise.map(id => [id, api.getUserName(id)]);限制条件:
- 回调必须是同步的(不能
await); - 回调会先在本地以记录模式(record mode)运行一次,因此除 RPC 调用外不得有任何副作用;
- 回调捕获的任何 stub 都会随记录一起发送给对端。把被捕获的 stub 视为已暴露给对端;只使用源自同一对端的 stub。
Streams are first-class:流是一等公民
ReadableStream<T>是 capnweb 的常规值。线协议绝不内联 blob 字节——变更流携带内容寻址的(hash, size)记录,接收方通过hasObjects/pushObjects回叫获取缺失子集。新增 RPC 时,优先流式传输而非单个大载荷。
从 packages/rpc/src/interface.ts 可以印证对象传输的方向性设计:fetchObjects(容器 → DO)与pushObjects(DO → 容器)都接受ReadableStream<{ hash, bytes }>而非数组,这样发送方可以在 wire 发送过程中交错读取对象,把峰值内存保持在有界范围内。ChangeEntry由 packages/dofs/src/sync/changes.ts 定义。
当你收到一个流式结果时,信封拥有这个流。排空流并不会释放信封;释放信封才会释放其中包含的每一个 stub。pullOnce用PULL_BATCH_SIZE = 256分批排空流(packages/rpc/src/sync-driver.ts),每批处理完立即释放,保证pullOnce的峰值内存是O(PULL_BATCH_SIZE)而非O(stream)。
反向背压:exec 事件流
shell.exec/shell.getExec返回的ReadableStream<ExecEvent>把消费端背压一直传播到被派生进程的内核管道(见 docs/08_capnweb_interface.md):消费者停止拉取,runner 就停止read()子进程的 stdout/stderr,内核管道填满后子进程阻塞在write上——话痨命令会像在普通 shell 里遇到慢速tee或less一样自我调节。ExecEvent的每个帧都携带每 id 单调递增的seq(packages/rpc/src/interface.ts),断线后可用getExec({ id, after: seq })从已知点续播。
Do / Don't:本仓库的 RPC 守则
Do:
- 在 packages/rpc/src/interface.ts 的
WorkspaceRPC上先定义新方法,再在任一端实现。 - 把 stub 当作能力对待。除非你确实想授予访问权,否则不要把 stub 传出会话边界。
- 能用
pullOnce/pushOnce驱动同步轮次就用它们。 - 对直接流式调用的每个 await 结果信封声明
using。 - 即使你认为结果信封不包含 stub,也释放它——为未来做预防是廉价的。
- 需要一份能活过被调用方自动释放的 stub 副本时,调用
.dup()而不是 await 两次。 - 改动任何 RPC 生命周期相关代码时运行泄漏 harness(
script/computerd-stub-soak.mjs),并检查session.getStats()是否有漂移。
Don't:
- 不要发送二进制 WebSocket 帧。线格式是文本 JSON。
- 不要直接拆除底层 carrier。始终走
client.close(),让根 stub 先释放。 - 不要只改一侧就改动
WorkspaceRPC接口——线契约是共享的。 - 不要声明 TypeScript
private方法并以为它不会暴露给 RPC。用#前缀名才能在运行时真正私有。 - 不要指望垃圾回收清理 stub。它不会。
共享线契约与错误码
接口没有版本协商(docs/08_capnweb_interface.md),请求/响应形状变更属于硬性线破坏,需要 Durable Object 与 computerd 锁步发布。线上的错误携带结构化代码(packages/rpc/src/interface.ts),调用方不必做字符串匹配:
| Code | Meaning |
|---|---|
ENOENT | 接收方路径不存在,或getExec/disposeExec引用了未知 id |
EUNKNOWN_HASH | fetchObjects引用了接收方无记录的 hash |
EEXEC_BUSY | exec使用了正在运行的 id |
ELOG_TRUNCATED | getExec续播点早于保留日志 |
ESHUTDOWN | (保留)服务端正在关闭,重启后重连 |
EAUTH | (保留)握手认证失败 |
EPROTOCOL | (保留)线帧或版本不匹配 |
Testing for leaks:如何验证没有泄漏
三种手段层层递进:
- 单元级:在测试中使用
enableStubTracking()+stubSnapshot(),断言一次本该清理干净的往返后没有任何 stub 存活。 - 浸泡级:用
script/computerd-stub-soak.mjs对释放敏感的改动做边界浸泡测试;它读取session.getStats()来检测漂移(drift)。 - 服务端级:
packages/rpc与packages/computer中的服务端 RPC 行为测试直接跑在真实Database与真实驱动 helper 之上。
packages/rpc/src/debug.ts 展示了计数器的开关机制:通过CAPNWEB_TRACK_STUBS=1环境变量、globalThis.CAPNWEB_TRACK_STUBS覆盖,或编程式enableStubTracking()(用于 workerd 这类 env 变量不落到process.env的运行环境)。计数按类维护,构造时trackStub、dispose 时untrackStub,并用WeakSet去重——capnweb 对共享 target 可能多次调用[Symbol.dispose],重复释放会被忽略。它是测量而非强制:snapshot()返回每类存活数,浸泡脚本断言"静默期之后每个计数器都归零";任何非零值都意味着泄漏。
computerd在开启该标志时还会在GET /__computerd/stubs暴露快照(见 packages/rpc/README.md),配合 packages/computer/tests/stub-soak.test.ts 的 workerd 浸泡测试,可以在持续负载下证明无无界增长。
进一步阅读
- docs/08_capnweb_interface.md — 线协议设计意图:
SyncRPC/ShellRPC全接口、push/fetch 语义、往返次数表、exec 背压与流回放、错误模型与可观测性钩子。 - docs/11_lifecycle.md — 本仓库的 stub 释放契约,以及 DO / 容器 / capnweb 会话三个生命周期层的完整交互矩阵与休眠(hibernation)展望。
- packages/rpc/README.md —
@cloudflare/computer-rpc包的四个入口(线类型、server、client、driver、debug)与释放、调试面的使用说明。 - packages/rpc/src/interface.ts — 线契约的单一事实来源。
- packages/rpc/src/sync-driver.ts —
pullOnce/pushOnce/tick/reconcileWatermarks的完整实现,是"驱动拥有 stub"与跨端水印不变量检查的权威参考。
如需更完整的 wire 契约上下文,请继续阅读 docs/02_sync_protocol.md(push/fetch 轮次如何组合)与 docs/07_injected_service.md(承载这些 RPC 的 computerd 服务端与引导序列)。
【免费下载链接】computerGive your agent a computer 👾项目地址: https://gitcode.com/GitHub_Trending/computer1/computer
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考