系列十三引入了通用 Approval Gate,系列十六已经让 Channel 侧用 Approve + Respond 完成了审批闭环。但 Terminal 这条路还停在一个危险实现上:Approve 内部另起 goroutine 去 ReadString,一旦用户在审批等待时按 Ctrl-C,调用链返回了,读 stdin 的 goroutine 却可能还活着。
本文对应 go-tiny-claw 提交:
1 2
| 04608aa fix: 消除终端审批在 Ctrl-C 后的 stdin 读泄漏 7ebceab refactor: 引入 TerminalSession 对齐 Channel 装配
|
它补上的是路线图阶段七里被点名的缺口之一:资源生命周期与可靠取消中的「Approval 输入 Goroutine 是否泄漏」。
一、问题从哪里来
系列十三里的终端 Handler 大致是这样写的:
1 2 3 4 5 6 7 8 9 10 11 12 13
| inputCh := make(chan string, 1)
go func() { line, _ := h.reader.ReadString('\n') inputCh <- strings.TrimSpace(line) }()
select { case <-ctx.Done(): return "", ctx.Err() case input := <-inputCh: }
|
这段代码能工作,是因为它同时在等两件事:
但 ReadString 本身不知道 Context。Ctrl-C 只会取消 runCtx,让 select 走到 ctx.Done() 并返回;后台那条 go 仍然堵在 stdin 上。
于是会出现:
1 2 3 4 5 6 7 8 9
| Approve 返回 context.Canceled ↓ 任务结束,REPL 回到下一轮 ↓ REPL 再次 ReadString ↓ 泄漏的审批 goroutine 也在 ReadString ↓ 两个读者抢同一个 stdin
|
用户下一行输入可能被泄漏的 goroutine 吃掉,表现为「按了没反应」或输入错乱。更重要的是:每次取消都可能留下一个永远卡住的 goroutine。
系列十三已经写过这个缺口:
1 2 3
| ReadString 不能直接被 Context 取消。 中断发生时,Handler 可以返回, 但后台读取 Goroutine 仍可能阻塞。
|
本篇就是把这句话真正落地。
二、为什么 Channel 没有这个问题
ChannelApprovalHandler 从一开始就没有在 Approve 里读输入:
1 2 3 4 5 6
| select { case <-ctx.Done(): return "", ctx.Err() case decision := <-response: return decision, nil }
|
输入来自外部协议消息。ChannelSession 读到 MessageApprovalResponse 后再调用:
1
| s.approval.Respond(message.RequestID, decision)
|
取消时只需要注销 pending、结束 Approve,没有不可取消的阻塞 IO 挂在 Handler 内部。
所以正确模型不是「让 ReadString 支持 cancel」,而是:
1 2 3
| Approve:只等待 Decision / Context Respond:由唯一输入所有者注入 Decision stdin / WebSocket:只允许一个读者
|
Terminal 应该复用同一套语义,而不是另搞一套可读但会泄漏的实现。
三、把 TerminalApproval 改成 Approve / Respond
终端审批从 internal/approval 挪到 internal/cli。原因和 Channel 对称:
1 2 3 4 5 6 7 8
| internal/approval └── Gate / Policy / Grant / Handler 接口
internal/cli └── Terminal 渠道适配:审批 UI + stdin 分流
internal/channel └── WebSocket / TCP 渠道适配
|
新的 TerminalApprovalHandler 不再持有 bufio.Reader,也不再 go ReadString:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| type TerminalApprovalHandler struct { out io.Writer
mu sync.Mutex response chan approval.Decision }
func (h *TerminalApprovalHandler) Approve( ctx context.Context, request approval.Request, ) (approval.Decision, error) { response := make(chan approval.Decision, 1)
h.mu.Lock() if h.response != nil { h.mu.Unlock() return "", ErrApprovalAlreadyPending } h.response = response h.mu.Unlock()
defer func() { h.mu.Lock() h.response = nil h.mu.Unlock() }()
fmt.Fprintf(h.out, "\n需要确认执行工具: %s\n", request.ToolCall.Name) fmt.Fprintf(h.out, "参数: %s\n", request.ToolCall.Arguments) fmt.Fprint(h.out, "[y]允许本次 [a]本会话允许 [n]拒绝: ")
select { case <-ctx.Done(): return "", ctx.Err() case decision := <-response: return decision, nil } }
|
Respond 只负责往唯一槽位投递决策:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| func (h *TerminalApprovalHandler) Respond(decision approval.Decision) error { h.mu.Lock() response := h.response h.mu.Unlock()
if response == nil { return ErrApprovalRequestNotFound }
select { case response <- decision: return nil default: return ErrApprovalAlreadyResolved } }
|
这里用单个 response 而不是 map[requestID]chan,是因为终端交互本身是串行的:同一时刻用户只能回答一个问题,Gate 也会串行 Check。Channel 继续用 map,是因为协议消息自带 RequestID,按 ID 寻址更自然。
清理责任仍然在 Approve 的 defer 上:无论成功还是取消,离开 Approve 都会清空 pending。Respond 不负责 delete。
四、REPL 必须成为 stdin 的唯一读者
若只改 Handler,却仍然在审批时另起 ReadString,泄漏只是换了个地方。
因此 REPL 要同时承担两件事:
- 空闲时读下一句 Prompt
- 审批等待时读
y/a/n 并调用 Respond
这要求主循环不能在 task.Wait() 上堵死。旧写法是:
1 2 3 4 5
| Start ↓ Wait ← 主循环卡在这里 ↓ 回到 claw>
|
任务运行期间没人读 stdin,所以旧 Handler 才不得不自己读。新写法对齐 ChannelSession:
1 2 3 4 5
| Start 后立刻返回 ↓ go { Wait; 清理 active; 打印终态 } ↓ 主循环继续 ReadString
|
分流逻辑变成:
1 2 3 4
| ReadString ├── HasPending() → Respond(y/a/n) ├── 有 active Task → 提示任务进行中 └── 空闲 → /help、/clear 或 Start 新任务
|
对应代码骨架:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| if r.approval != nil && r.approval.HasPending() { decision, ok := parseApprovalDecision(prompt) if !ok { fmt.Fprintln(r.out, "请输入 y / a / n") continue } _ = r.approval.Respond(decision) continue }
if !r.isIdle() { fmt.Fprintln(r.out, "当前任务正在执行,请等待任务完成。") continue }
|
并且只在空闲时打印提示符:
1 2 3
| if r.isIdle() { fmt.Fprint(r.out, "\nclaw>") }
|
这样审批提示前不会再多打一个 claw>。
Ctrl-C 之后的语义变为:
1 2 3 4 5 6 7 8 9
| ctx 取消 ↓ Approve 从 select 返回 ↓ defer 清空 pending ↓ 没有残留 ReadString goroutine ↓ REPL 仍是唯一 stdin 读者
|
五、为什么 main 里曾经要传两次 Handler
Gate 需要的是:
1
| Approve(ctx, request) (Decision, error)
|
REPL 需要的是:
1 2
| HasPending() bool Respond(decision) error
|
同一个对象,两个角色。Channel 看起来「只传一次」,是因为 ChannelSession 在构造函数内部完成了两次注入:
1 2 3 4 5 6 7 8 9
| channelApproval, _ := NewChannelApprovalHandler(eventSink)
bundle, _ := manager.Create(id, runtimepkg.RuntimeOptions{ ApprovalHandler: channelApproval, })
return &ChannelSession{ approval: channelApproval, }
|
Terminal 之前没有对等的 Session,装配落在 main,所以会出现:
1 2
| ApprovalHandler: approvalHandler, NewREPL(..., approvalHandler)
|
这不是创建了两个 Handler,而是装配归属放错了层。
六、引入 TerminalSession
为了和 Channel 对齐,新增 internal/cli/terminal_session.go:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| func NewTerminalSession( id string, manager *runtimepkg.Manager, reader *bufio.Reader, out io.Writer, ) (*TerminalSession, error) { approvalHandler, err := NewTerminalApprovalHandler(out) if err != nil { return nil, err }
runtimeBundle, err := manager.Create(id, runtimepkg.RuntimeOptions{ ApprovalHandler: approvalHandler, Reporter: reporter.NewTerminalReporter(out), }) if err != nil { return nil, err }
repl := NewREPL( reader, out, runtimeBundle.Runtime, runtimeBundle.Reporter, approvalHandler, )
return &TerminalSession{ id: id, manager: manager, runtime: runtimeBundle.Runtime, reporter: runtimeBundle.Reporter, approval: approvalHandler, repl: repl, }, nil }
|
cmd/claw/main.go 收成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| session, err := cli.NewTerminalSession( "terminal_default", manager, bufio.NewReader(os.Stdin), os.Stdout, ) defer session.Close()
go func() { for range signals { session.Interrupt() } }()
session.Run(context.Background())
|
当前 Run / Interrupt 先委托给已有 REPL,目标不是立刻消掉 REPL,而是先把装配边界摆正:
1 2
| claw_server → ChannelSession claw → TerminalSession
|
Close 使用 sync.Once,避免重复 Destroy:
1 2 3 4 5 6 7 8 9 10 11
| func (s *TerminalSession) Close() error { s.closeOnce.Do(func() { err := s.manager.Destroy(s.id) if err != nil && !errors.Is(err, runtimepkg.ErrRuntimeNotFound) { s.closeErr = err return } s.closeErr = nil }) return s.closeErr }
|
若将来在 Create 成功之后还有可能失败的步骤,失败路径应立刻 manager.Destroy(id),防止半成品 Runtime 留在 Manager 里。
七、这一步完成后的分层
Terminal 渠道现在可以画成:
1 2 3 4 5 6 7 8 9 10
| stdin / stdout / Ctrl-C ↓ TerminalSession ├── TerminalApprovalHandler Approve / Respond ├── TerminalReporter └── REPL 唯一 ReadString ↓ Runtime / Task ↓ AgentEngine / Approval Gate
|
和 Channel 对比:
1 2 3 4 5 6 7 8 9 10
| WebSocket / TCP ↓ ChannelSession ├── ChannelApprovalHandler Approve / Respond ├── JSONReporter └── MessageReader 唯一读连接 ↓ Runtime / Task ↓ AgentEngine / Approval Gate
|
两边差异只在输入源和展示方式;审批等待模型已经统一。
八、还没做完的部分
这次修掉的是「取消后 stdin 读泄漏」和「Terminal 装配不对齐」。阶段七到生产级还有几件事:
1 2 3 4 5 6
| 取消后半截结果是否误写 Session 工具并发上限和事件 Channel 背压 Provider / bash 子进程退出与泄漏测试 Race Detector 覆盖审批取消路径 任务结束后 claw> 提示符可能晚一拍刷新 /exit 时是否应先取消 active Task
|
再往后才是路线图阶段八及以后:
1 2 3 4 5
| Grant 持久化与参数级最小权限 Provider 超时、重试和预算 Session 持久化与任务恢复 MCP / A2A 评测、性能和部署治理
|
总结
终端审批泄漏的本质,不是「少写了一个 cancel」,而是把不可取消的 stdin 读塞进了本应只等待 Decision 的 Approve。
修复路径也很明确:
1 2 3 4 5 6 7
| 复用 Channel 的 Approve / Respond ↓ REPL 独占 stdin 并负责分流 ↓ 任务异步 Wait,主循环永不因审批停读 ↓ TerminalSession 收拢装配,与 ChannelSession 对称
|
到这里,Terminal 和 Channel 在审批与会话边界上已经站在同一套模型上。下一步可以继续补阶段七的其余可靠性项,再进入权限持久化和 Session 恢复。