play
全景
浏览器(静态页)
│ HTTP: /status
│ WebSocket: submit / cancel / lsp_* / 二进制产物帧
▼
playground 服务(axum 进程,不含 rustc)
│ docker build / run / exec
▼
docker
├─ 镜像:工具链 + 预热依赖 + /opt/ra/ws
├─ 任务容器:一次性,挂入源码,跑 cargo build
└─ RA 容器:常驻,跑 rust-analyzerrust-analyzer;编译产物来自任务容器。组件
playground 进程
playground/src/main.rs 是装配点:探工具 → 读配置 → 抢数据目录锁 → 构造下面这些组件 → 起三个后台循环(烘焙、GC、RA 补池/回收)→ 起 HTTP。| 模块 | 职责 |
config | 全部环境变量与默认值;空串等价于未设置。 |
server::http | 路由、CORS、/healthz /status /admin/rebuild;AppState 在这里定义。 |
server::ws | WebSocket 连接的生命周期:握手校验、消息分发、把任务输出转成 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_builder | docker build 的参数与日志;tag/label 与镜像对齐。 |
image | 镜像引用计数(ImageGuard),GC 据此避开正在用的镜像。 |
gc / gc_loop | 磁盘水位闸、镜像与 builder cache 清理、过期任务目录。 |
repo | git rev-parse / git pull --ff-only;webhook 路径用。 |
lsp::* | RA 会话池、LSP 帧、JSON-RPC 分流、语义 token 与诊断。 |
浏览器
| 文件 | 职责 |
play/index.typ | 页面骨架:工具条、编辑器挂载点、右栏选项卡与日志面板。 |
play/script.js | 连后端、发 submit、收消息、UI 状态(目标选择、日志、拖动条、样例载入、日志跳转)。 |
play/lsp-client.js | CodeMirror 与 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 是给用户看的。 |
| 服务端 → 客户端 | cancelled | abort 本身静默,必须显式回这一条。 |
| 服务端 → 客户端 | 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。 |
- 一条连接一个任务:
cancel才明确取消谁。任务跑在 spawn 出去的任务里,排队期间读循环不被阻塞。 - 断开 = 取消:任务
abort,Ticket/ImageGuard随之Drop,额度、IP 占用、镜像引用计数一步还清。
- 每 IP 一条连接(
server::session_gate)。编译那条路本来就有「每 IP 一个」(queue),但 RA 那条不经过队列 —— 同一 IP 开 N 个标签页就能起 N 个 RA 容器(每个常驻 839 MB)。所以限制定在连接层:占用随连接的Drop归还。第二条连接握手成功、随即收到一条session_busy然后被关掉 —— 不是 403,否则前端的自动重连会变成死循环。 - 源码体量有闸门。
didOpen/didChange带的全文超过PLAYGROUND_LSP_MAX_SOURCE_BYTES(默认 10 KiB,按字节算)就不写盘、不转发给 RA。前端有同款判断,但那只算快路径 —— 阈值是运维配置,不能只靠浏览器自觉。
HTTP
| 路由 | 用途 |
/healthz | 烘焙就绪返回 200,否则 503 并带日志尾巴。 |
/status | serving 镜像、烘焙状态、队列、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- 退出码 0 但没产物也是失败。cargo 判 fresh、跳过编译时,容器会交出上一次的
.gia—— 那是没有报错的错答案。 - 产物不落宿主盘。容器故意不带
--rm(否则docker cp之前文件系统就被删了),跑完用docker cp <容器>:<路径> -把 tar 从 stdout 读回来解出字节,直接推给前端。清理由ContainerGuard的docker rm -f负责。 - 判定顺序是超时 → 解码 → verdict。被 kill 的容器会在帧中间截断,先判解码会把正常超时误报成“读输出失败”。
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镜像:短sha[-dirty],label 是全长 sha。GC 按 label 找,启动复用按 label 读 commit,二者必须同时对上才能算同一份镜像。- 单飞 + 折叠 pending:同一时刻只烘一份;烘焙中来的请求只留最后一个。
- 失败保旧:main 推了个编不过的 commit,服务继续用旧镜像,
/status里能看到失败状态与日志尾巴。 - 服务进程与镜像里的后端是两份东西:重启不按 HEAD 重烘,实际服务的是哪一版由
hello.image_commit如实报出。 - GC 每 300 秒一轮:任务目录按 TTL 删;镜像保留
keep个,serving、最近一次成功、引用计数非零的永不删;builder cache 按容量裁。 - 烘焙前量水位,量的是 docker 的 data root(镜像与 cache 长在那里),不是数据目录。
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 秒。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 的请求,会诱发一次无意义的应答。docker rm -f。initialize 响应里的 legend 由前端用来把索引翻成名字;诊断走 textDocument/diagnostic,unchanged 保留上一份,同时兼容推送式 publishDiagnostics。服务端不解析这两类结果。id 而 broadcast 没有订阅者时替它回 result: null,否则 RA 会一直等、会话永远不就绪。有订阅者时服务端不抢答 —— 抢答会和浏览器的响应撞成两条同 id 的响应。前端
BACKENDS 写死在 script.js,每次加载打乱、逐个尝试(4 秒超时),连上就停。页面里不给看也不给改。keydown / pointerdown / wheel,加上每次发消息),90 秒没有任何操作就主动关掉 WebSocket。服务端把断开当成取消并把 RA 会话还回池子,一个没人看的页面不该一直占着几百 MB 的分析进程;空闲关闭显示中性提示,不标红。排队或编译中的连接不在此列:任务收摊(finished / failed / cancelled / error)之前计时只会顺延,否则用户挂着页面等编译,会被自己这端取消掉。下次打字或点「编译」走既有的自动重连,hello 重建 LSP 会话并整份同步文档。@codemirror/lang-rust 提供括号匹配等结构信息,不装 syntaxHighlighting;颜色全部来自服务端语义 token。缩进是自写的 indentService(lang-rust 没有提供任何缩进规则)。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 先到先得。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。切走不卸载,所以平移、缩放、拖动的位置都留着。预览失败只影响预览,下载照常可用。drawSelection()):浏览器原生的光标在选区非空时会被隐藏,拖动或 Shift 选中后看不到落点;自绘层则在选区头部始终画一根光标。script.js 的 snapSelectionToLine 在 CM 每次画完后把矩形上下缘取整到等距的行网格上,行与行严丝合缝、也不越过行边界;选区跨多行时中间那块大矩形同样落在网格上,不会被压成一行。选区色因此用不透明混色,避免亚像素重叠处出现深色接缝。autocompletion() 自带的 Ctrl-Space,还绑了 Alt-/:Ctrl-Space 在不少用户那是输入法切换,会被系统先吃掉;绑定的是包里的 startCompletion,起不来时返回 false,键照常往后传。label 末尾(常量的 MAX = 2147483647、旧服务端的 (as Trait))和 detail(类型、泛型参数、宏名)。前者在展示和插入时都剥掉,其中常量值紧跟名字显示(MAX = 2147483647),后者按 kind 归一成类型靠右显示;与名字重复的 detail(枚举成员 A / A、原生类型 i32 / i32)和只是复述名字的 macro_rules! x 直接丢掉。script.js 从日志文本里识别 --> 路径:行:列,只把路径本身包成按钮;点击后仅当路径以 demo/src/lib.rs 结尾才把编辑器光标移到对应行列并聚焦,其余给一行提示。