跳到主要内容

数据截至 (上游 commit afe54827dd65)

04 · 双形态运行与 workspace 生命周期

本章讲什么: Crush 同时支持「一个进程干完所有事」和「TUI 只是个瘦客户端, 真正干活的是常驻后台 server」两种形态,以及后者带来的一整套连接管理问题。


1. 一个接口,两种实现

分叉点

crush 启动

CRUSH_CLIENT_SERVER 为真?
┌──────┴───────┐
│否 │是
▼ ▼
setupLocalWorkspace setupClientServerWorkspace
进程内建 app.App 确保 server 在跑,建 HTTP 客户端
│ │
▼ ▼
AppWorkspace ClientWorkspace
└──────┬───────┘

workspace.Workspace(前端只认这个)

代码:internal/cmd/root.go:296 useClientServer:322 setupWorkspace

默认是本地形态——C/S 是环境变量显式开启的实验路径。 这点对读代码的人很重要:别以为 internal/server 是主路径。

接口有多大

workspace.Workspaceinternal/workspace/workspace.go:117)是一个覆盖面很广的接口: 会话、消息、agent 运行与取消、权限、问答、文件追踪、LSP、MCP、配置写入、skills…… 粗看像个「上帝接口」,但它承担的职责是明确的: 把「本地还是远程」这件事从所有 UI 代码里彻底消掉


2. 服务端形态:一次连接要做的事

crush 启动(C/S 模式)

① 解析 host URL(默认 unix socket)

② socket 不存在 → 拉起一个 detached server 进程
│ 存在 → 校验版本是否匹配

③ POST /v1/workspaces {path, dataDir, yolo, channels, env, version}
│ 服务端返回 workspace ID + 该目录的配置

④ GET /v1/workspaces/{id}/events (SSE 长连接)
│ 这条流同时是"我还在"的心跳

TUI 把收到的事件转成 tea.Msg 喂给 Bubble Tea

对应 internal/cmd/root.go:449 connectToServerinternal/server/server.go:152 installHandler(约 70 个路由)、 internal/server/proto.go:281 handleGetWorkspaceEvents

传输选择

默认走 Unix socket(Windows 上是命名管道),因此 URL 里的 host 是个占位符 api.crush.localhostinternal/client/client.go:23 DummyHost)。 socket 路径长度还专门做了限制检查——Unix 域套接字路径有 104 字节上限 (internal/server/server.go:25 maxUnixSocketPathLen)。


3. Workspace 生命周期:三个宽限期

这是整个 C/S 设计里最花心思的部分。核心矛盾是: 「客户端断开」和「客户端临时抖了一下」在服务端看起来一模一样。

Crush 的答案是给三个不同的时刻各配一个宽限期(internal/backend/backend.go:47-71):

常量默认管什么不这么做会怎样
DefaultCreateGrace30s建完 workspace 后必须在此窗口内开 SSE 流建了不用的 workspace 永远泄漏
DefaultDetachGrace10s最后一条 SSE 流掉线后,claim 还能撑多久网络抖一下,客户端重连时 workspace ID 已不存在
DefaultIdleShutdownDelay60s最后一个 workspace 释放后,server 还活多久关一个会话立刻开下一个,会撞上正在退出的 server

只有后两个能用环境变量改。 Backend 建出来的时候,三个字段的取值方式不一样 (internal/backend/backend.go:268-270):

createGrace: DefaultCreateGrace, // :268 直接赋值
lingerDelay: idleShutdownDelayFromEnv(), // :269 → CRUSH_SERVER_IDLE_TIMEOUT
detachGrace: durationFromEnv("CRUSH_SERVER_DETACH_GRACE", DefaultDetachGrace), // :270

durationFromEnv 读的是整数秒,解析不了就退回默认值,且 0 是合法输入 (internal/backend/backend.go:280-290)——对这两个窗口来说 0 各有意义: idle 为 0 是「立刻关服务」,detach 为 0 是「流一断就拆」。

DefaultCreateGrace 没有对应的环境变量,全仓也搜不到 CRUSH_SERVER_CREATE_GRACE。 它被写成包级变量而不是常量,理由源码注释写得很清楚:Exposed as a package variable so tests can shorten it——是给测试改短用的,不是给用户调的旋钮

引用计数用的是「事件流」

注意 DefaultDetachGrace 的注释:SSE 流就是客户端的引用计数凭证The stream is the client's refcount claim)。这解释了为什么 handleGetWorkspaceEvents 里的顺序如此讲究:

① 先订阅事件 broker
② 再 AttachClient(增加流计数)
③ 再写 200 + flush 头

注释写明了理由:先订阅保证「一旦客户端看起来已 attach,之后发布的事件必定送达, 而不是掉在一个还没注册好的流上」(internal/server/proto.go:288-292)。 第 3 步的立即 flush 也有讲究——安静的 workspace 不会让客户端一直卡在首个 RoundTrip 上。

同一目录只有一个 workspace

CreateWorkspace 用解析后的绝对路径做键去重(internal/backend/backend.go:345): 第二个客户端连同一个目录,拿到的是同一个 workspace,只是多注册一个 client。 先来的配置生效,后来者的差异只打日志(logFirstWinsMismatch)。

唯一的例外是 channels:它是显式 opt-in 的能力,不一致就直接报错 (ErrChannelOptInMismatch),而不是悄悄共享。

那个最难缠的竞态

pending 计数(internal/backend/backend.go:96-105 字段注释)解决的是这个场景:

时刻 t0: 最后一个 workspace 被拆掉 → 准备关 server
时刻 t0: 另一个客户端正在建新 workspace(慢路径:读配置、开 DB)
← 此时它还没进 workspace map
如果只看 map 是否为空 → server 会在新 workspace 出生的瞬间自杀

解法是「已提交进入慢路径」也算数:拆除逻辑必须同时看到 map 为空 且 pending == 0 才关服务。

还有对称的一面:客户端可能连上一个已经决定要退出的 server。 服务端的这个决定是不可撤销的,所以客户端的正确反应不是报错,而是 等 socket 消失 → 拉起新 server → 重试(最多 3 次, internal/cmd/root.go:514 maxStaleServerRetries:525 createWorkspaceOnLiveServer)。


4. 断线之后:客户端怎么自愈

客户端侧定义了三种「链路坏了」的错误,语义各不相同 (internal/workspace/workspace.go:30-49):

错误含义客户端反应
ErrServerUnreachable连不上 server重试
ErrWorkspaceGoneserver 在,但不认识这个 workspace ID后台重新注册 workspace
ErrStreamClosed流断了重连,并且必须重新同步——断开期间的事件永久丢失

第三条是关键认知:事件流没有回放机制,所以重连后必须重新拉状态,不能假装无事发生。

连接状态变化会作为 ConnectionEvent 送进 TUI(internal/workspace/workspace.go:68), 带一个 Stuck 标志:反复恢复失败时置位,UI 从「短暂提示」升级为「持续错误」, 但重试循环本身不会放弃。


5. 事件怎么跨进程

进程内的事件是 Go 类型(pubsub.Event[T],T 可能是消息、权限请求、LSP 事件……)。 跨进程要变成 JSON,于是有一层显式的翻译(internal/server/events.go:27 wrapEvent):

内部事件 pubsub.Event[permission.PermissionRequest]

wrapEvent 按类型分派

proto 版本(带 JSON tag)+ 一个 PayloadType 判别字段

data: {"type":"permission_request",...}\n\n ← SSE 行

客户端按 PayloadType 反解成 tea.Msg

无法表示的事件类型直接丢弃并打 debug 日志,而不是伪造一个近似事件 (internal/server/events.go:41-45EventChannelMessage 的处理, 注释原文:Drop it instead of fabricating a state_changed event)—— 这是个很克制的选择。

两种发布语义

pubsub.Broker 刻意提供两个发布方法(internal/pubsub/broker.go 包注释):

方法语义用在哪
Publish尽力而为,订阅者缓冲满就丢流式 token 这种「只关心最新态」的高频事件
PublishMustDeliver有界阻塞(每订阅者 50ms 上限,broker.go:45完成、错误、取消这类不能被静默合并掉的终结事件

每订阅者缓冲 4096(internal/pubsub/broker.go:40 bufferSize), 注释说明这是按「一次长回答约每 token 一个更新事件」估的。 两个丢弃计数器都暴露出来,方便观测饱和。


6. TUI 侧简述

TUI 基于 Bubble Tea v2(charm.land/bubbletea/v2),主模型是 internal/ui/model/ui.go:182UI 结构体。Workspace.Subscribe(program) 把事件流 泵进 Bubble Tea 的消息循环(internal/workspace/client_workspace.go:792)。

值得单独一提的是流式 markdown 渲染的增量缓存internal/ui/chat/streaming_markdown.go):

  • 问题:每来一个 token 就整篇重渲染 markdown,长回答会卡。
  • 思路:找一个可证明「没有任何 markdown 结构处于打开状态」的安全边界 (空行之后、代码围栏配对、不在列表/表格/引用块中),把边界之前的渲染结果缓存下来, 之后每次只渲染尾部。
  • 安全性:注释明说「两次渲染拼接一般不等于整篇渲染一次」,所以边界判定故意保守, 一有疑问就退回全量渲染并丢弃缓存。

这是个很好的「性能优化必须带正确性退路」的样本。


7. 代码地图

主题文件路径符号名
形态选择internal/cmd/root.gouseClientServersetupWorkspaceconnectToServer
陈旧 server 重试internal/cmd/root.gocreateWorkspaceOnLiveServermaxStaleServerRetriesreplaceExitingServer
前端门面internal/workspace/workspace.goWorkspaceConnectionEvent
远程实现internal/workspace/client_workspace.goClientWorkspacerunSubscriptionrecoverWorkspace
本地实现internal/workspace/app_workspace.goNewAppWorkspace
HTTP 路由表internal/server/server.goServer.installHandler
SSE 事件端点internal/server/proto.gohandleGetWorkspaceEvents
事件跨进程翻译internal/server/events.gowrapEvent
workspace 生命周期internal/backend/backend.goCreateWorkspaceAttachClientdetachStreamteardownscheduleShutdownIfIdleLocked
宽限期常量与环境变量internal/backend/backend.goDefaultCreateGraceDefaultDetachGraceDefaultIdleShutdownDelaydurationFromEnvidleShutdownDelayFromEnv
HTTP 客户端internal/client/client.goNewClientdialerRetireClient
事件广播internal/pubsub/broker.goPublishPublishMustDeliverbufferSize
流式 markdown 缓存internal/ui/chat/streaming_markdown.gostreamingMarkdown.Render