play

怎么跑、怎么配、怎么部署 在开发栏;这里只讲它由什么组成、数据怎么流。

全景

三层,每层只依赖下一层的契约:
浏览器(静态页)
   │  HTTP: /status
   │  WebSocket: submit / cancel / lsp_* / 二进制产物帧
   ▼
playground 服务(axum 进程,不含 rustc)
   │  docker build / run / exec
   ▼
docker
   ├─ 镜像:工具链 + 预热依赖 + /opt/ra/ws
   ├─ 任务容器:一次性,挂入源码,跑 cargo build
   └─ RA 容器:常驻,跑 rust-analyzer
浏览器不做任何编译或分析:语法高亮、诊断、补全全部来自容器里的 rust-analyzer;编译产物来自任务容器。

组件

playground 进程

playground/src/main.rs 是装配点:探工具 → 读配置 → 抢数据目录锁 → 构造下面这些组件 → 起三个后台循环(烘焙、GC、RA 补池/回收)→ 起 HTTP。
模块职责
config全部环境变量与默认值;空串等价于未设置。
server::http路由、CORS、/healthz /status /admin/rebuild;AppState 在这里定义。
server::wsWebSocket 连接的生命周期:握手校验、消息分发、把任务输出转成 ServerMsg。
server::session_gate会话级占用表:同一个 IP 同时只允许一条活连接;占用随连接的 Drop 归还。
protocol线协议的全部枚举与常量;TARGETS 是目标的唯一真源。
queue并发额度 + 每 IP 一个 + 排队上限;额度靠 Ticket 的 Drop 归还。
job任务目录布局 <data>/tasks/<id>/{src/lib.rs, meta.json};每次新写源码。
runner把 cargo build 放进一次性容器;分帧读日志、超时、判 verdict。
bake烘焙状态机;单飞、折叠 pending、失败保旧。
image_builderdocker build 的参数与日志;tag/label 与镜像对齐。
image镜像引用计数(ImageGuard),GC 据此避开正在用的镜像。
gc / gc_loop磁盘水位闸、镜像与 builder cache 清理、过期任务目录。
repogit rev-parse / git pull --ff-only;webhook 路径用。
lsp::*RA 会话池、LSP 帧、JSON-RPC 分流、语义 token 与诊断。

浏览器

文件职责
play/index.typ页面骨架:工具条、编辑器挂载点、右栏选项卡与日志面板。
play/script.js连后端、发 submit、收消息、UI 状态(目标选择、日志、拖动条、样例载入、日志跳转)。
play/lsp-client.jsCodeMirror 与 LSP 的薄桥:语义高亮、诊断波浪线、补全弹窗、去抖与在途替换。
play/style.css布局与主题变量;控件尺寸统一走 --sl-input-height-medium。
lib/shoelace.typ<sl-select> / <sl-button> / <sl-divider> 的封装与按需注册。

协议

WebSocket

一条连接长开,同时最多跑一个编译任务;断开即取消。
方向消息含义
客户端 → 服务端submit{source, target?};开始一次编译。
客户端 → 服务端cancel取消这一单;服务端回 cancelled。
客户端 → 服务端lsp_open取一个 RA 会话;幂等。
客户端 → 服务端lsp{message};原样 JSON-RPC 报文。除握手与 didOpen / didChange 外全部转发。
服务端 → 客户端hello连接后第一条:协议版本、镜像 commit、目标表、lsp_supported、lsp_max_source_bytes(代码分析的源码体量上限,字节)。
服务端 → 客户端queued排队位次与当前队列长度。
服务端 → 客户端started容器开始跑;带 task 与 image_commit。
服务端 → 客户端log{task, stream, text};stdout / stderr 分开。
服务端 → 客户端finished成功;带产物字节数。产物紧跟其后以二进制帧发过来。
服务端 → 客户端failed失败;exit_code 可空,message 是给用户看的。
服务端 → 客户端cancelledabort 本身静默,必须显式回这一条。
服务端 → 客户端error请求被拒:not_ready / ip_busy / queue_full / session_busy / bad_request / internal。
服务端 → 客户端lsp{message};RA 的原始报文。管道断开时发一条合成的 exit 通知(params.reason),前端据此清高亮与诊断。
服务端 → 客户端lsp_ready{file, uri};会话已取得,前端拿 uri 填 textDocument.uri。
两条不变量:两条限制挂在不同的层:

HTTP

路由用途
/healthz烘焙就绪返回 200,否则 503 并带日志尾巴。
/statusserving 镜像、烘焙状态、队列、git HEAD、pull 失败原因。
/admin/rebuild触发烘焙:GitHub webhook(HMAC)或 Bearer token;都没配就 403。
/ws升级为 WebSocket;Origin 白名单在这里查。

一次编译

submit
  → validate(空 / 超 1 MiB / 未知 target 都在占额度之前拒掉)
  → queue.try_acquire(ip)          # 每 IP 一个;满了回 ip_busy / queue_full
  → registry.acquire(serving_tag)  # 先握住镜像引用,防 GC 中途删掉
  → 排队则回 queued,spawn 的任务等 ticket.wait_started()
  → 写 tasks/<id>/src/lib.rs       # 新写文件,mtime 必须是“现在”
  → docker run 一次性容器           # 无 --rm,挂 src 只读
  → 读 stdout 8 字节帧 / stderr 裸行 → log
  → docker cp <容器>:<路径> -      # tar 走 stdout,产物**不落宿主盘**
  → verdict(exit_code, artifact)   # 退出码 0 且产物取到了才算成功
  → finished + 二进制产物帧 | failed | cancelled
两个关键判断:容器离开正常路径时(用户取消、进程被杀)由 ContainerGuard::Drop 补一发 docker rm -f —— 任务容器不带 --rm,一个死循环容器否则会一直占住 CPU 和内存,还会留下一个容器条目。

镜像生命周期

启动 → startup_plan
        ├─ 本机有带 commit label 的镜像 → adopt,不烘
        └─ 没有(或 FORCE_REBAKE)      → request 烘焙

Baker: Idle ─bake→ Baking ─ok→ Ready(serving)
                       └─fail→ Ready(旧 serving) + baking.state = failed
镜像身份由两个值组成:tag 是 镜像:短sha[-dirty],label 是全长 sha。GC 按 label 找,启动复用按 label 读 commit,二者必须同时对上才能算同一份镜像。几条设计约束:

RA 会话

浏览器编辑器
   │ lsp(除握手外原样转发)
   ▼
server::ws ──▶ LspService ──▶ Pool ──▶ Session ──▶ docker exec ──▶ rust-analyzer
   ▲               │             │          │
   └── lsp(含合成的 exit)──── Event ◀─────┘
镜像里另烘一份分析工作区 /opt/ra/ws:只有 demo / lib / lib-internal,没有 core。core 依赖 rustc_private,RA 解析它要把整个编译器 sysroot 拖进来,冷启动从 11 秒涨到 84 秒。会话创建:拿 serving tag → docker run -d 待命容器 → docker exec -i rust-analyzer → initialize → didOpen(占位) → 服务端用自己的 id 拉一次 textDocument/diagnostic,拿到响应即就绪。池里的会话跨连接复用,交棒前由服务端补 didClose,所以浏览器的 didOpen 永远合法。服务端只拦六种消息:initialize 用缓存的响应按浏览器 id 回放;initialized 与 shutdown / exit 吞掉;didOpen / didChange 先把全文写进容器再转发。其余请求、通知、响应原样双向转发。关闭信号是一条合成的 exit 通知(params.reason 带原因):RA 管道断开时会话不可再用,服务端往 lsp 信封里塞一条 exit,前端收到即清高亮、诊断并 reject 挂起的请求。不用 shutdown —— 它是带 id 的请求,会诱发一次无意义的应答。池只做状态机:空闲数不超过下限(默认 1)、取用时用会话自己的探针查活、坏的就销毁重造、空闲超 TTL 由回收循环 docker rm -f。语义 token 与诊断都是前端主动拉的:initialize 响应里的 legend 由前端用来把索引翻成名字;诊断走 textDocument/diagnostic,unchanged 保留上一份,同时兼容推送式 publishDiagnostics。服务端不解析这两类结果。待命阶段没有浏览器,RA 的请求得有人应答:reader 发现某帧带 id 而 broadcast 没有订阅者时替它回 result: null,否则 RA 会一直等、会话永远不就绪。有订阅者时服务端不抢答 —— 抢答会和浏览器的响应撞成两条同 id 的响应。

前端

后端地址不归页面管:BACKENDS 写死在 script.js,每次加载打乱、逐个尝试(4 秒超时),连上就停。页面里不给看也不给改。连接不是常开的:页面把「用户操作」放宽到整页(keydown / pointerdown / wheel,加上每次发消息),90 秒没有任何操作就主动关掉 WebSocket。服务端把断开当成取消并把 RA 会话还回池子,一个没人看的页面不该一直占着几百 MB 的分析进程;空闲关闭显示中性提示,不标红。排队或编译中的连接不在此列:任务收摊(finished / failed / cancelled / error)之前计时只会顺延,否则用户挂着页面等编译,会被自己这端取消掉。下次打字或点「编译」走既有的自动重连,hello 重建 LSP 会话并整份同步文档。编辑器只借 CodeMirror 的词法层:@codemirror/lang-rust 提供括号匹配等结构信息,不装 syntaxHighlighting;颜色全部来自服务端语义 token。缩进是自写的 indentService(lang-rust 没有提供任何缩进规则)。Tab / Shift+Tab 在 script.js 里自己写,没用 indentWithTab:那个包会拖进第二份 @codemirror/view,facet 值认不出来。有选区时 Tab 给选区覆盖的每一行加一个 indentUnit(空行跳过),Shift+Tab 从行首削掉一个单位;选区终点落在下一行行首时那一行不算。缩进单位走 languageDataAt("indentUnit", pos),退化的情形(页面里不会发生)才回落到常量 INDENT。派发事务时不给 selection:不给的话 CodeMirror 默认把选区跟着 changes 映射,正是这里要的;写成 { ranges, map: true } 反而过不了校验 —— esm.sh 拉回来的 @codemirror/state 与 view 内那份不是同一个实例。查找与多选走官方的 @codemirror/search:search() + searchKeymap 给编辑器装上面板(Mod-f 打开、Enter / F3 / Mod-g 找下一个、Escape 关);Mod-Shift-l 选中全部匹配。searchKeymap 自带的 Mod-d(选中下一个相同词)被页面自己的绑定压在下面:Mod-d 留给删整行(deleteLine),而两条同优先级的 keymap 是先注册的赢,所以页面那条必须排在 keymap.of(searchKeymap) 之前。Mod-Shift-l 是往选区里加段的命令,所以还要开 EditorState.allowMultipleSelections.of(true) —— CM 默认只保留一段,applyTransaction 会用 asSingle() 把多出来的段悄悄丢掉(命令仍返回 true),表现就是按键有反应但选区不变。面板配色在 style.css 里单独写:CM 的 .cm-button 背景是 background-image: linear-gradient(...),只改 background-color 盖不住,得连 background-image: none 一起给。closeBrackets() 只负责输入时自动配对;Backspace 一次删掉整对括号的绑定在 closeBracketsKeymap 里,要单独装,而且必须排在 defaultKeymap 之前 —— Backspace 在那边绑着 deleteCharBackward,keymap 先到先得。高亮与诊断是两个独立 decoration 层,可以叠在同一 range 上:一个字既是函数名又是错误位置时,两套 class 同时生效。诊断整份替换,空数组就是清空。重连重新握手:页面空闲断开后重连会重新 hello → lsp_open,前端销毁旧 LspClient、清空挂起请求,然后对新会话重新 initialize / didOpen。已有 decoration 可以保留到新结果到达 —— 新会话的 token 一定是针对当前文档的,不再需要「lsp_ready 之前丢弃 token」的规则。产物只在两头的内存里:后端用一条二进制帧(裸 .gia 字节,socket 上设了 binaryType = "arraybuffer")把它送过来,前端存进 artifactBytes,「下载」按钮做的事是 Blob + 临时 <a download>。没有下载链接、也没有第二次 HTTP 往返。点「编译」会把上一份产物立刻作废 —— 留着它,一次编译失败之后用户点下载拿到的还是上一次的图,而界面上看不出来。预览在浏览器里解码:收到产物后前端异步取同一个后端的 /proto/asset.proto(路由只 tokio::fs::read 仓库里那份文件,不 embed),用 protobuf.js 解析 schema 并解开 .gia 的 proto 负载。服务端不参与解码。解析完把每张图构建成一个 detach 的 <ui-graph> 模板(与站点其它节点图共用同一套自定义元素),再按需要挂进画布。挂载时机不是「解析完」而是「预览选项卡可见且当前是图视图」:ui-graph 在 connectedCallback 里量节点尺寸,祖先 display:none 时量到的全是 0。切走不卸载,所以平移、缩放、拖动的位置都留着。预览失败只影响预览,下载照常可用。选区与光标由 CodeMirror 自绘(drawSelection()):浏览器原生的光标在选区非空时会被隐藏,拖动或 Shift 选中后看不到落点;自绘层则在选区头部始终画一根光标。自绘选区量的却是字形盒而不是行盒(12px 字约 14px 高,行高为 18px):单行选中时上下各缩进 2px,跨行时 CM 又会把接缝那一侧补到行边界,同一个选区两侧不对称。script.js 的 snapSelectionToLine 在 CM 每次画完后把矩形上下缘取整到等距的行网格上,行与行严丝合缝、也不越过行边界;选区跨多行时中间那块大矩形同样落在网格上,不会被压成一行。选区色因此用不透明混色,避免亚像素重叠处出现深色接缝。补全不排队:300 ms 去抖、2 秒兜底、在途替换 —— 旧请求回来时直接丢弃,否则会和下一次按键撞上,弹窗列出错位的内容。主动唤出补全除了 autocompletion() 自带的 Ctrl-Space,还绑了 Alt-/:Ctrl-Space 在不少用户那是输入法切换,会被系统先吃掉;绑定的是包里的 startCompletion,起不来时返回 false,键照常往后传。补全行里只有名字是可插入文本。RA 把展示信息塞在两个地方:label 末尾(常量的 MAX = 2147483647、旧服务端的 (as Trait))和 detail(类型、泛型参数、宏名)。前者在展示和插入时都剥掉,其中常量值紧跟名字显示(MAX = 2147483647),后者按 kind 归一成类型靠右显示;与名字重复的 detail(枚举成员 A / A、原生类型 i32 / i32)和只是复述名字的 macro_rules! x 直接丢掉。编译日志里的源码位置可点:script.js 从日志文本里识别 --> 路径:行:列,只把路径本身包成按钮;点击后仅当路径以 demo/src/lib.rs 结尾才把编辑器光标移到对应行列并聚焦,其余给一行提示。