computer 仓库中的 capnweb RPC:stub 生命周期与释放契约实战指南
2026/9/16 20:44:36 网站建设 项目流程

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,组合SyncRPCShellRPC新增方法先改这里
packages/rpc/src/server.tsDatabase为后端的具体实现。Durable Object 与容器内 computerd 共同导入它。
packages/rpc/src/client.ts基于 WebSocket carrier 的类型化 stub。Durable Object 使用deferred transport,使 stub 可以在 WebSocket upgrade 完成之前就创建。
packages/rpc/src/sync-driver.tspullOncepushOncetick。包装流式方法并在内部处理释放。
packages/rpc/src/debug.tsenableStubTrackingstubSnapshot,用于泄漏排查。
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。

具体展开为三条细则:

  1. 作为参数传入的 stub 仍归调用方所有。被调用方收到的是 RPC 系统在调用完成时自动释放的副本。你可以在调用后立即释放自己的原始 stub——它在发送时已经被复制了。
  2. 结果中返回的 stub 将所有权转移给调用方。调用方必须释放它们。RPC 系统在确认不会再有流水线调用到达后,会释放被调用方的副本。
  3. 结果信封(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的返回信封里同时含有currentCursorappliedPushCursorstream三个字段,其中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调用fetchChangesfetchObjectsshell.execshell.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 < localPushRevcurrentCursor < 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 正是这么做的:SyncRPCServerShellRPCServerWorkspaceRPCServer都在构造函数里调用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。pullOncePULL_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 里遇到慢速teeless一样自我调节。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接口——线契约是共享的。
  • 不要声明 TypeScriptprivate方法并以为它不会暴露给 RPC。用#前缀名才能在运行时真正私有。
  • 不要指望垃圾回收清理 stub。它不会。

共享线契约与错误码

接口没有版本协商(docs/08_capnweb_interface.md),请求/响应形状变更属于硬性线破坏,需要 Durable Object 与 computerd 锁步发布。线上的错误携带结构化代码(packages/rpc/src/interface.ts),调用方不必做字符串匹配:

CodeMeaning
ENOENT接收方路径不存在,或getExec/disposeExec引用了未知 id
EUNKNOWN_HASHfetchObjects引用了接收方无记录的 hash
EEXEC_BUSYexec使用了正在运行的 id
ELOG_TRUNCATEDgetExec续播点早于保留日志
ESHUTDOWN(保留)服务端正在关闭,重启后重连
EAUTH(保留)握手认证失败
EPROTOCOL(保留)线帧或版本不匹配

Testing for leaks:如何验证没有泄漏

三种手段层层递进:

  1. 单元级:在测试中使用enableStubTracking()+stubSnapshot(),断言一次本该清理干净的往返后没有任何 stub 存活。
  2. 浸泡级:用script/computerd-stub-soak.mjs对释放敏感的改动做边界浸泡测试;它读取session.getStats()来检测漂移(drift)。
  3. 服务端级packages/rpcpackages/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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询