5.6 取消与错误处理
本页分析版本earendil-works/pi@c13ffe12026-07-30本章解决什么问题:前面五章走的都是「一切顺利」的路。真实使用中有两条同样常见的路:用户按下 ESC 想停下来,以及模型服务方返回 429、连接中途断开、上下文超出窗口。本章把这两条异常路径从起点追到终点,并回答一个容易被忽略的问题——中断之后,消息历史里到底留下了什么。 前置知识:2.7 事件、回调与取消(AbortController)(本章是那张概念图的源码版)、5.3 一次 Tool Call 的完整循环、5.4 流式事件如何传播到界面。 学习目标:读完本章后你能
- 说出一次 ESC 从终端按键到
fetch与工具执行之间的每一环,并指出 AbortController 是在哪一行创建的;- 解释 Pi 的 Agent Loop 为什么几乎不检查
signal.aborted,却仍然能停下来;- 区分 Pi 里三层不同粒度的重试,说出各自的判据、退避公式与预算;
- 说清上下文溢出为什么被从「可重试错误」里单独摘出去;
- 亲手跑通 pi-ai 与 pi-agent-core 里与取消、重试相关的真实测试。
建立直觉:异常不是「抛出去」,而是「带回来」
写普通程序时,出错的默认做法是 throw:异常沿着调用栈往上冒,谁想处理谁 catch。但 Agent Harness(Agent 运行框架)里有一个硬性约束让这套做法行不通——一次请求的结果必须被写进消息历史。如果模型服务方返回 429 的那一刻,异常直接把整个 Agent Loop(Agent 循环)炸掉,那么这一轮的部分文本、已经消耗的 token、已经跑完的工具结果就全丢了;界面上只剩一个红色堆栈,会话文件里什么也没有。
所以 Pi 走了另一条路:失败被编码成一条正常的 assistant 消息,stopReason 取值 "error" 或 "aborted",errorMessage 带上原文。这不是某个文件的实现细节,而是写在类型定义注释里的契约(源码事实):
// packages/agent/src/types.ts:18-32(节选)
/**
* Contract:
* - Must not throw or return a rejected promise for request/model/runtime failures.
* - Failures must be encoded in the returned stream via protocol events and a
* final AssistantMessage with stopReason "error" or "aborted" and errorMessage.
*/
export type StreamFn = (
model: Model<Api>,
context: Context,
options?: SimpleStreamOptions,
) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;理解了这条契约,本章两条路径就都好读了:取消和出错走的是同一条通道,区别只在 stopReason 的取值,以及之后谁来决定「要不要再来一次」。
路径一:ESC 按下之后发生了什么
起点:一个按键分派到四种含义
交互模式下,ESC 并不总是「取消」。CustomEditor.handleInput 在把按键交给通用编辑逻辑之前,先匹配 app.interrupt 动作(默认绑定就是 escape,见 packages/coding-agent/src/core/keybindings.ts:66),且只有在自动补全菜单没弹出时才交给 onEscape(packages/coding-agent/src/modes/interactive/components/custom-editor.ts:45-57)。而 onEscape 是一个会被动态改绑的回调:
还有两个临时改绑点值得记住(源码事实):压缩期间 onEscape 被换成 session.abortCompaction()(interactive-mode.ts:3106-3109),自动重试的退避等待期间被换成 session.abortRetry()(interactive-mode.ts:3157-3159),事后各自恢复。也就是说「ESC 取消什么」取决于按下的那一刻会话处于哪个阶段——这是一个纯 UI 层的决策,下面的核心层对此一无所知。
真实采集的启动界面里,选择器底部那行提示 ↑↓ navigate enter select escape/ctrl+c cancel(真实采集,见 research/cli-captures/pi-tui-startup.txt)正是同一套按键约定在覆盖层上的表现。
中间:AbortController 在哪一行诞生
流式输出中按 ESC,会走到 restoreQueuedMessagesToEditor(interactive-mode.ts:4021-4040):它先把 steering 与 follow-up 队列里排队的文本拼回编辑器(用户不会白打字),然后调用 this.agent.abort()。注意这里的 this.agent 是 this.session.agent(interactive-mode.ts:460-462),即 pi-agent-core 的 Agent 实例,而不是 AgentSession。
abort这个 controller 不是长期存在的,而是每次运行现造一个:runWithLifecycle 在 agent.ts:476 处 new AbortController(),存进 activeRun,并把 abortController.signal 作为参数交给执行体(agent.ts:488)。执行体就是 runAgentLoop(...) 调用,signal 是它的第 5 个参数(agent.ts:402-411)。运行结束时 finishRun() 把 activeRun 清空(agent.ts:514-520),下一轮再造新的——所以上一轮的取消绝不会误伤下一轮。
终点一:signal 进入网络请求
runLoop 把同一条 signal 一路透传,最终交给 StreamFn:
streamFunction在 pi-ai 一侧,signal 有两个作用点。一是交给 Provider(模型服务提供方)SDK 的请求选项(packages/ai/src/api/anthropic-messages.ts:554-566),让底层 fetch 直接断开连接;二是在服务器发送事件(SSE,Server-Sent Events)的读取循环里每轮检查一次(anthropic-messages.ts:398-400),保证即使连接层没能立刻断开,解析循环也会主动抛出。两者抛出的异常最终都落到同一个 catch:
stopReason终点二:signal 进入工具执行
工具那一侧,signal 出现在三个位置(源码事实):beforeToolCall 钩子的第 2 参(agent-loop.ts:619-628)、afterToolCall 钩子的第 2 参(agent-loop.ts:720-731),以及最关键的 tool.execute 第 3 参:
// packages/agent/src/agent-loop.ts:675-693(节选,executePreparedToolCall 内)
const result = await prepared.tool.execute(
prepared.toolCall.id,
prepared.args as never,
signal,
(partialResult) => {
// …(省略:把中间进度包成 tool_execution_update 事件)
},
);工具拿到的第三个参数就是这条 signal。工具内部是否响应它,完全取决于工具自己的实现——abort 是请求,不是强杀。
除了传下去,Agent Loop 自己也查:prepareToolCall 在钩子前后各查一次 signal?.aborted,命中就直接返回一条 "Operation aborted" 的错误工具结果而不执行(agent-loop.ts:629-635 与 644-650);串行执行在每个工具跑完后 break(agent-loop.ts:478-480),并行执行在准备阶段 break(agent-loop.ts:516-518、535-537)。
这里有一个后果需要单独记住:break 意味着剩下的 tool call 连一条工具结果都不会产生。一条 assistant 消息声明了 3 个 tool call,取消之后历史里可能只有 1 条 toolResult。这个「残缺」由谁收拾,下一节回答。
循环怎么知道该停了
一个反直觉的事实:runLoop 在 turn 边界没有任何 signal.aborted 判断(源码事实——agent-loop.ts:224-260 这段从 turn_end 到轮询 steering 队列,全程不查 signal)。真正的终止判据只有一个:
stopReason从源码结构看,如果 ESC 恰好按在工具批次执行中途,流程是这样的:工具批次 break → 回填已有的工具结果 → 发 turn_end → 内层循环条件仍然成立 → 再发起一次流式请求,而这次请求带着一条已经 aborted 的 signal,适配层立刻走 anthropic-messages.ts:766 把 stopReason 置为 aborted → 第 196 行退出。也就是说取消在最坏情况下会多产生一条内容为空的 aborted 消息。这是分析解释(我没有找到直接覆盖这条路径的具名测试,尚未确认真实运行中这条空消息是否总会出现)。
图 5.6-1 一次 ESC 在 Pi 里的真实传播链
阅读顺序:从上到下。这是 [2.7 的概念图 2.7-2](/foundations/events-and-abort) 换成真实文件与行号后的样子,请重点对比两处差异:① 概念图里「agent.abort」是一个抽象节点,实际它上面还有 UI 层的四路分派,下面还有「每轮新建 controller」这一步;② 概念图里三个订阅者是并列的终点,实际两条支路最终都汇合到 `agent-loop.ts:196` 这同一个出口——signal 负责让正在进行的工作停下,让**循环**停下的是那条 aborted 消息。
取消之后,消息历史里留下什么
分两个层面看,答案不一样。
会话文件层面:全部留下。 AgentSession._handleAgentEvent 对每个 message_end 都调 sessionManager.appendMessage(packages/coding-agent/src/core/agent-session.ts:625-642),不区分 stopReason。所以那条 aborted 的 assistant 消息、以及取消前已经跑完的工具结果,都会写进会话文件,重开会话时你还能看到它们。
发给模型的请求层面:几乎全部丢弃。 每个 Provider 适配器在构造请求体之前都会先过一遍 transformMessages(例如 anthropic-messages.ts:947),而它的第二趟扫描做了两件与本章直接相关的事:
insertSyntheticToolResults那条合成结果长这样(transform-messages.ts:163-180):role: "toolResult"、isError: true、内容是 "No result provided"。上一节说的「3 个 tool call 只有 1 条结果」的残缺,就是在这里被补齐的——它必须补,因为多数 Provider 的接口要求每个 tool call 都有配对结果,否则整个请求 400。
于是就有了一个初看奇怪、细想合理的现象(分析解释):界面上那条红色的「Operation aborted」是给人看的,模型下一轮完全看不见它。模型看到的是「上一条 assistant 消息发起了几个工具调用,其中若干条的结果是 No result provided」,然后从最后一个有效状态继续。
路径二:错误的分诊与三层重试
失败一旦被编码成 stopReason: "error" 的消息,接下来的问题就变成了纯粹的决策题:重试、压缩,还是放弃。Pi 在三个不同高度各放了一层判断,粒度从细到粗。
第一层:HTTP 请求级(pi-ai 内部,用户无感)
retryProviderRequest这一层存在的理由写在它自己的注释里(官方说明,来源文件 packages/ai/src/utils/provider-retry.ts:97-104):OpenAI 与 Anthropic 的官方 SDK 自带重试,但它们的退避计时器不理会请求的 AbortSignal——用户按了 ESC,SDK 还在傻等下一次重试。Pi 的做法是把 SDK 的 maxRetries 强行设为 0(anthropic-messages.ts:557),自己实现一份可被打断的等价逻辑。
判据是 HTTP 语义(provider-retry.ts:23-35):响应头 x-should-retry 优先;否则 408、409、429 与所有 5xx 可重试;status 为 undefined(连接层根本没拿到响应)也算可重试。等待时长优先采用服务器给的 retry-after-ms 或 retry-after 头,都没有才用 min(0.5 * 2^n, 8) 秒并乘上最多 25% 的抖动(provider-retry.ts:51-67)。还有一条防御值得注意:如果服务器要求的等待超过 maxRetryDelayMs(默认 60 秒,provider-retry.ts:1、packages/coding-agent/src/core/settings-manager.ts:838),Pi 直接失败并在错误里写明「服务器要求等 N 秒,超过上限」,而不是真的挂在那里。
第二层:单次 assistant 调用级(用于压缩与分支摘要)
retryAssistantCall(packages/ai/src/utils/retry.ts:162-211)接收一个「产出一条 assistant 消息」的函数,按策略反复调用它。它的判定顺序很值得逐条读(源码事实):
// packages/ai/src/utils/retry.ts:172-196(节选)
for (;;) {
const response = await produce();
// 取消是终态,永不重试
if (response.stopReason === "aborted") { /* …(省略:回调) */ return response; }
// 非 error 即成功
if (response.stopReason !== "error") { /* …(省略:回调) */ return response; }
// 预算用尽,或分类为不可重试 → 直接把最后这条错误交回去
if (attempt >= maxAttempts || !isRetryableAssistantError(response)) { /* …(省略) */ return response; }
attempt++;
const delayMs = policy!.baseDelayMs * 2 ** (attempt - 1);
// …(省略:onRetryScheduled 回调)
try { await sleep(delayMs, signal); } catch (error) { /* …(省略:把退避期间的取消归一化成 aborted 消息) */ }
}最后那个 catch 是个容易被跳过、但很能说明设计取向的细节:退避睡眠被 signal 打断时,它不把异常抛出去,而是返回 { ...response, stopReason: "aborted", errorMessage: undefined }(retry.ts:205)。调用方因此不需要关心「用户是在请求中途取消的,还是在两次重试之间取消的」,拿到的都是同一种形状的 aborted 消息。
这一层目前的使用者是压缩与分支摘要(packages/agent/src/harness/compaction/compaction.ts:132、packages/coding-agent/src/core/compaction/compaction.ts:580),注释说明其动机是「一次瞬时的流中断不应该让整个压缩操作失败」(官方说明,来源文件 packages/coding-agent/src/core/compaction/compaction.ts:557-560)。
分类器:两张正则表
第一、二层共用同一个分类函数:
isRetryableAssistantError两张表的内容本身就是一份 Provider 故障图鉴(retry.ts:7-24 与 26-89)。黑名单收的是花钱也解决不了、重试只会浪费时间的确定性失败:insufficient_quota、quota exceeded、billing、Monthly usage limit reached。白名单收的是瞬时故障,覆盖四类:HTTP 与服务端(overloaded、rate.?limit、429、500—504、524)、网络与代理(fetch failed、ENOTFOUND、EAI_AGAIN、socket hang up、connection.?refused)、流提前结束(ended without、stream ended before message_stop)、以及服务方明文写的重试指引(you can retry your request、try your request again)。
顺序不能反:Bedrock 的限流文案里同时含有「too many」这类词,也含有账单语义的措辞,先排除后匹配才能避免误判——overflow.ts:74-78 里的 NON_OVERFLOW_PATTERNS 用的是同一套思路。
上下文溢出:被单独摘出去的一类
isContextOverflow为什么要单独摘出来?因为溢出重试一万次也是溢出——它是确定性失败,正确的处理是压缩上下文再来。coding-agent 因此把它挡在重试判断的第一行:
// packages/coding-agent/src/core/agent-session.ts:2635-2639
private _isRetryableError(message: AssistantMessage): boolean {
// Context overflow is handled by compaction, not retry.
if (isContextOverflow(message, this.model?.contextWindow ?? 0)) return false;
return isRetryableAssistantError(message);
}情况 ②③ 特别能说明这个函数为什么要写 160 行:有些 Provider 面对超长输入不报错,而是默默接受(z.ai)或默默截断再返回 finish_reason: length(小米 MiMo)。没有这两条兜底,Pi 会误以为一切正常,然后一次次发出注定被截断的请求。文件头部那份按 Provider 罗列的错误文案清单(overflow.ts:9-35)是我在整个 pi-ai 里见过信息密度最高的注释之一,值得单独读一遍。
第三层:turn 级(coding-agent,用户看得见)
_handlePostAgentRun它的调用者是一个极简的循环(agent-session.ts:1061-1073):
// packages/coding-agent/src/core/agent-session.ts:1061-1067(节选)
private async _runAgentPrompt(messages: AgentMessage | AgentMessage[]): Promise<void> {
this._isAgentRunActive = true;
try {
await this.agent.prompt(messages);
while (await this._handlePostAgentRun()) {
await this.agent.continue();
}
} // …(省略:finally 里清理并发 settled)
}_prepareRetry(agent-session.ts:2676-2726)做四件事:计数并检查预算(默认 maxRetries: 3、baseDelayMs: 2000,即 2 秒、4 秒、8 秒,见 settings-manager.ts:813-818);发 auto_retry_start 事件;把那条错误消息从 agent 运行时状态里删掉(agent-session.ts:2701-2704,注释写明「会话文件里保留作为历史」);最后用一个可被 _retryAbortController 打断的 sleep 完成退避。删消息这一步是 agent.continue() 能成立的前提——continue() 拒绝从 assistant 消息续跑(agent-loop.ts:131-133)。
图 5.6-2 一条失败消息的分诊路径
阅读顺序:从上到下,中间的 `ENC` 是分水岭。请重点关注两处:① 第一层重试完全发生在 pi-ai 内部,界面上看不到任何提示,只有它失败之后错误才会「浮出水面」变成一条消息;② 分诊台先问溢出、后问可重试,这个顺序对应 `agent-session.ts:2637` 那一行注释——溢出是确定性失败,抢在重试判断之前拦下。图中 `_checkCompaction` 分支的细节属于 [6.6 Context 构造与 Compaction](/pi-modules/compaction) 的范围,本章只标出它的入口。
错误怎么变成界面上的红字
事件到了界面就只剩渲染。InteractiveMode.handleEvent 的 message_end 分支(interactive-mode.ts:2995-3033 一段)先给 aborted 消息补一条文案——如果此前发生过重试,文案会是「Aborted after N retry attempts」(interactive-mode.ts:3000-3007)——然后把这条错误同时写进所有还挂着的工具组件(interactive-mode.ts:3014-3020),避免界面上留下几个永远转圈的工具。
真正上色在 AssistantMessageComponent.updateContent:
// packages/coding-agent/src/modes/interactive/components/assistant-message.ts:165-177(节选)
} else if (!hasToolCalls) {
if (message.stopReason === "aborted") {
const abortMessage = /* …(省略:优先用 errorMessage) */ "Operation aborted";
this.contentContainer.addChild(new Text(theme.fg("error", abortMessage), this.outputPad, 0));
} else if (message.stopReason === "error") {
const errorMsg = message.errorMessage || "Unknown error";
this.contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), this.outputPad, 0));
}
}注意 !hasToolCalls 这个条件(源码事实,assistant-message.ts:165):带工具调用的失败消息不在这里显示红字,因为错误已经由上面那段写进各个工具组件了,两边都写会重复。而 stopReason === "length"(输出被截断)不受这个条件限制,永远单独提示一句(assistant-message.ts:153-164)。
重试期间界面的变化则由两个专属事件驱动:auto_retry_start 换上 RetryStatusIndicator 并把 ESC 临时改绑到 abortRetry,auto_retry_end 恢复原 ESC 处理器、清掉指示器,只在最终失败时弹出一行 Retry failed after N attempts(interactive-mode.ts:3154-3180)。
图 5.6-3 一次可重试错误的完整时间线
阅读顺序:从上到下按时间。请关注三处:① 错误消息先经过 `message_end` 落进会话文件,之后才被从运行时状态里删掉,所以「历史里有、请求里没有」;② 重试指示器出现期间 ESC 的含义被临时改成 `abortRetry`,这正是第一节说的动态改绑;③ 重试是靠 `agent.continue()` 重新进入循环,而不是在循环内部打转——对应 `agent-session.ts:1065-1067` 那个 while 循环。图中 `auto_retry_start` 与 `auto_retry_end` 是 AgentSession 自有事件,不属于 pi-agent-core 的 10 种 AgentEvent。
实践任务
目标:确认本章讲的三个判定函数(重试分类、HTTP 重试策略、溢出识别)与 Agent 的取消契约都有测试覆盖,并亲眼看到「需要 API Key 的测试会被自动跳过」这一约定。全程只读,不需要任何 API Key,不要修改 _sources/pi 下的任何文件。
步骤与命令(在 Pi 仓库根目录执行,例如 _sources/pi):
先确认测试文件确实存在:
shls packages/ai/test/retry.test.ts packages/ai/test/provider-retry.test.ts packages/ai/test/overflow.test.ts跑本章第二条路径涉及的三个测试文件:
shnpm test --workspace=@earendil-works/pi-ai -- test/retry.test.ts test/provider-retry.test.ts test/overflow.test.ts跑取消路径的测试(其中包含
should pass the active abort signal to subscribers与should handle abort controller两个用例,见packages/agent/test/agent.test.ts:263与:501):shnpm test --workspace=@earendil-works/pi-agent-core -- test/agent.test.ts对比一个需要真实 Provider 的测试:
shnpm test --workspace=@earendil-works/pi-ai -- test/abort.test.ts最后用一次搜索验证本章的核心论断——「取消的终点只有一个」:
shgrep -rn 'stopReason === "aborted"' packages/agent/src --include="*.ts"
预期现象(以下为本书作者在锁定 commit 上实际执行的结果):第 2 步汇总行为 Test Files 3 passed (3) 与 Tests 37 passed (37);第 3 步为 Test Files 1 passed (1) 与 Tests 20 passed (20);第 4 步为 Test Files 1 skipped (1) 与 Tests 37 skipped (37)——整个文件被跳过,因为它在模块顶层解析 OAuth 令牌,没有凭据就不运行;第 5 步返回 4 行结果,其中 agent-loop.ts:196 就是本章图 5.6-1 的汇合点,另外三行都在 compaction 相关文件里。
如何判断成功:三个 npm test 全部以 0 退出且没有 failed;并且你能回答——第 4 步为什么是 skipped 而不是 failed?(提示:看 packages/ai/test/abort.test.ts:12 的顶层 resolveApiKey,以及各用例的条件跳过写法。)
常见错误:
- 写成
npm test --workspace=@earendil-works/pi-ai -- --run test/retry.test.ts会直接崩,报Expected a single value for option "--run"——因为包内的test脚本已经是vitest --run,再传一次就重复了。 - 忘了
--,npm会把文件名当成自己的参数,结果跑了整个包的测试(耗时很长且大量跳过)。 - 在
packages/ai目录里直接敲npm test:可以跑,但 workspace 依赖需要根目录已经npm install过。
对应源码位置:packages/ai/src/utils/retry.ts:162(重试循环)与 :222(分类器)、packages/ai/src/utils/provider-retry.ts:105(HTTP 层重试)、packages/ai/src/utils/overflow.ts:132(溢出识别)、packages/agent/src/agent.ts:312(取消入口)、packages/agent/src/agent-loop.ts:196(循环出口)。
本章小结
- Pi 的异常路径不靠
throw,靠把失败编码成一条 assistant 消息:stopReason为"error"或"aborted",这是写在StreamFn类型注释里的契约。取消与出错共用同一条通道。 - 取消链:ESC 按键 →
CustomEditor匹配app.interrupt→onEscape(四路分派,且在压缩/重试期间会被临时改绑)→Agent.abort()→ 每轮新建的 AbortController → 同一条 signal 同时进入 streamFn、tool.execute第 3 参、两个工具钩子与事件监听器。 - 让循环真正停下来的不是 signal 检查,而是
agent-loop.ts:196对stopReason的判断——signal 负责让正在进行的工作停,aborted 消息负责让循环停。 - 取消后会话文件里保留完整痕迹,但
transformMessages在构造请求体时会整条跳过 error/aborted 的 assistant 消息,并给落单的 tool call 补"No result provided"合成结果。 - 三层重试各管一段:HTTP 请求级(
retryProviderRequest,替换掉 SDK 那个不理会 AbortSignal 的退避计时器)、单次 assistant 调用级(retryAssistantCall,服务于压缩与摘要)、turn 级(_prepareRetry+agent.continue(),默认 3 次、2/4/8 秒)。 - 上下文溢出被从「可重试」里单独摘出:它是确定性失败,正确处置是压缩;
isContextOverflow还额外识别两种「不报错的溢出」。 - 关键术语:停止原因(stopReason)、AbortController / AbortSignal、可重试错误(retryable error)、指数退避(exponential backoff)、上下文溢出(context overflow)、合成工具结果(synthetic tool result)。
- 关键源码索引:
packages/agent/src/agent.ts:306-314(abort)、:476(controller 创建);packages/agent/src/agent-loop.ts:196-200(循环出口)、:304-312(signal 进 streamFn)、:675-693(signal 进工具)、:478-480与:516-518(批次中断);packages/ai/src/api/anthropic-messages.ts:760-769(异常转数据);packages/ai/src/api/transform-messages.ts:158-197(跳过与补全);packages/ai/src/utils/provider-retry.ts:105-125、packages/ai/src/utils/retry.ts:162-227、packages/ai/src/utils/overflow.ts:132-160;packages/coding-agent/src/core/agent-session.ts:1075-1103(分诊台)、:2676-2726(退避);packages/coding-agent/src/modes/interactive/interactive-mode.ts:2596-2622(ESC 分派)、:3154-3180(重试指示器)。相关测试:packages/ai/test/retry.test.ts、packages/ai/test/provider-retry.test.ts、packages/ai/test/overflow.test.ts、packages/agent/test/agent.test.ts:263与:501、packages/agent/test/harness/agent-harness.test.ts:188。 - 自测问题:① 用户在三个工具并行执行到一半时按下 ESC,下一次发给模型的请求体里能看到这三个工具调用吗?能看到几条工具结果?② 模型返回「prompt is too long: 213462 tokens > 200000 maximum」,Pi 会重试几次?为什么?③ 为什么
retryAssistantCall要把退避睡眠期间的取消转换成一条 aborted 消息,而不是直接抛异常?④Agent.abort()与AgentHarness.abort()在行为上有哪三点不同? - 下一部分预告:第五部分到此走完了一次完整请求的去程、回程与两条异常路。第六部分将换一个切法,按模块纵向深入。本章刻意没有展开的内容:压缩本身的算法与提示词(留给 6.6)、Provider 适配层各家 SDK 的差异(6.2)、扩展如何介入错误处理(7.1),以及 RPC 模式下取消如何跨进程传递(6.10,尚未确认其实现是否复用同一条 signal 链)。