From 7ec79314bb3333e03b4bf40ffabc2184a560c2db Mon Sep 17 00:00:00 2001 From: Gloridust Date: Mon, 6 Jul 2026 23:37:48 +0800 Subject: [PATCH] =?UTF-8?q?docs(dev):=20=E6=9E=B6=E6=9E=84=E5=AE=88?= =?UTF-8?q?=E5=88=99=20+=20=E5=8F=91=E5=B8=83=E9=97=A8=E7=A6=81=20+=20P0?= =?UTF-8?q?=E5=A4=8D=E7=9B=98=E7=B0=BF=20=E2=80=94=E2=80=94=20=E6=8A=8A?= =?UTF-8?q?=E5=8E=86=E6=AC=A1=20P0=20=E7=9A=84=E5=AD=A6=E8=B4=B9=E5=9B=BA?= =?UTF-8?q?=E5=8C=96=E6=88=90=E7=A1=AC=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从 v1.2.6 自更新黑屏、v1.2.9~v1.3.1 DPI 单位事故、磁盘暴涨、升级卡死等真实 P0 中 蒸馏出 9 条架构不变式(R1~R9):版本锚定可回滚、声明式自重建、资源必有边界、 长任务异步化、面板×实例兼容矩阵、根因=解释全部症状+A/B实证、AI PR 审语义、 发布节奏(特性不当天发)、冒烟测用户动作。发布门禁给出逐条可执行的命令清单 (含微信 CEF 等价探针、升级路径测试);P0 复盘簿记录每次事故的流程性成因。 Co-Authored-By: Claude Opus 4.8 (1M context) --- doc/dev/P0复盘.md | 34 +++++++++++++++++ doc/dev/发布门禁.md | 91 +++++++++++++++++++++++++++++++++++++++++++++ doc/dev/架构守则.md | 84 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 doc/dev/P0复盘.md create mode 100644 doc/dev/发布门禁.md create mode 100644 doc/dev/架构守则.md diff --git a/doc/dev/P0复盘.md b/doc/dev/P0复盘.md new file mode 100644 index 0000000..0bcd637 --- /dev/null +++ b/doc/dev/P0复盘.md @@ -0,0 +1,34 @@ +# P0 复盘簿 + +> 每次 P0 修完在这里记一笔:症状 → 根因 → 为什么会发生(流程层面) → 对应守则。 +> 目的不是追责,是让同一类错误只交一次学费。新条目追加在最上面。 + +## 2026-07 · XSETTINGS DPI 单位错误(史上最大,v1.2.9~v1.3.1) + +- **症状**:微信点公众号/附件/小程序窗口秒关或白屏(amd64/arm64 都中);Chromium 实例整体黑屏崩溃循环;issue #111 及大量群反馈。 +- **根因**:#101 引入的 xsettingsd 配置写 `Xft/DPI 96`,而 XSETTINGS 规范单位是 **DPI×1024**(应为 98304)。所有 Chromium 内核应用把 DPI 读成 0.09 → 缩放因子≈0 → 显示变换矩阵不可逆(`transform.cc NOTREACHED`)→ GPU/viz 进程连崩 → 窗口/应用退出。 +- **连带**:崩溃风暴 × docker 日志无上限 × 崩溃转储无 TTL × 悬空镜像不回收 → 用户磁盘被吃(「1TB 没了」);同步升级接口在受限网络下表现为「一键升级卡死」。 +- **为什么会发生**:AI 生成 PR 的配置数值未对规范验证(R7);冒烟只测了「微信启动」没测「点开公众号」(R9);第一次修复(v1.3.1 的 `--force-device-scale-factor=1`)用只解释部分症状的理论当根因发布(R6);四个版本挤在 48h 发布,事故窗口内变更太多难以二分(R8)。 +- **修复**:`94b591e`(单位改 98304 两处写入点 + 日志上限 + 转储 TTL + 悬空镜像回收 + 升级异步化)。 +- **守则**:R3 / R4 / R6 / R7 / R8 / R9。 + +## 2026-06-25 · 面板自更新后全员 502 黑屏(v1.2.6 修复) + +- **症状**:用面板内「一键更新」升级的用户,更新后所有实例远程桌面黑屏、「桌面服务暂不可用」;直接 `docker pull` 的用户无恙。 +- **根因**:自更新克隆旧容器配置时把 `Hostname`(= 旧容器短 id)一并克隆;新面板容器的 hostname 于是指向一个已删除的容器 → `ensureNetwork()` 用 `getContainer(hostname())` 自我定位 404 → 返回 null → 新建实例落到默认 bridge 网络 → 面板按容器名反代解析不到 → 502。 +- **为什么会发生**:自身状态靠「克隆运行时环境」而非声明式重建;自我定位单点依赖 hostname 无兜底。 +- **修复**:`7e21aca`(重建不再 pin Hostname;ensureNetwork 增加按容器名 `woc-panel` 兜底)。 +- **守则**:R2。 + +## 2026-06~07 · 桌面连接/重连卡死连环修(4 连击) + +- **症状**:切实例 A→B→A 卡死、重连后 Xvnc 卡死、输入模式切换不生效等,先后修了 4 次(`13ec0bd`→`855997f`→`7fecc09`→`b0511f5`)。 +- **根因**:noVNC/iframe 的连接生命周期管理散落多处,每次只修当时复现的那条路径。 +- **为什么会发生**:补丁式响应,没有对「连接状态机」做一次性的不变式设计;每个补丁都没解释之前补丁为何不够(R6 的弱版本)。 +- **守则**:R6;同类问题第三次出现时必须停下来做整体设计,而不是继续打第四个补丁。 + +## 2026-06 · 深色模式 portal 方案发布后撤回 + +- **症状**:portal 实时切换深色在极简容器里 session 总线不稳,微信也不跟随;发布(`335b839`)后撤回(`ba32cc9`)。 +- **为什么会发生**:方案依赖容器里并不可靠的桌面基础设施,发布前未在目标环境充分验证。 +- **守则**:R9(验证用户动作,而非「代码跑通」)。 diff --git a/doc/dev/发布门禁.md b/doc/dev/发布门禁.md new file mode 100644 index 0000000..df10dc8 --- /dev/null +++ b/doc/dev/发布门禁.md @@ -0,0 +1,91 @@ +# 发布门禁(逐项执行,全绿才能 release) + +> 配套 [架构守则.md](架构守则.md)。本清单是**可执行命令**,不是原则复述;每次发版逐条跑,贴结果。 +> 铁律:**绝不发布未经测试的正式 release**(远程桌面回归的教训)。 + +## 0. 仓库状态 + +```bash +# 同步盘(飞牛/iCloud)可能弄出冲突副本、回滚文件内容——先验明正身 +find . -path ./node_modules -prune -o -name '*冲突副本*' -print # 必须为空 +git status --short # 无意外改动 +git log --oneline -3 # HEAD 是你以为的那个 +``` + +## 1. 构建(本地) + +```bash +# --provenance=false 必须带:Docker 29+containerd 下缺它会让实例重建仍用旧镜像(踩过) +docker build --provenance=false --sbom=false -t ghcr.io/gloridust/wechat-on-cloud:latest ./docker +docker build --provenance=false --sbom=false --build-arg WOC_VERSION=dev-$(git rev-parse --short HEAD) \ + -t ghcr.io/gloridust/woc-panel:latest ./panel +cd panel/server && npx tsc --noEmit && cd ../web && npx tsc --noEmit && npm run build +``` + +## 2. 实例镜像探针(每条都跑) + +```bash +docker rm -f woc-gate 2>/dev/null +docker run -d --name woc-gate --security-opt seccomp=unconfined --shm-size=1g \ + -e PUID=1000 -e PGID=1000 -e WOC_APP_TYPE=chromium ghcr.io/gloridust/wechat-on-cloud:latest +sleep 25 + +# ① XSETTINGS DPI 单位(96 DPI = 98304;写 96 会让一切 CEF 崩溃——史上最大 P0) +docker exec woc-gate bash -c 'DISPLAY=:1 dump_xsettings | grep "Xft/DPI 98304"' || echo "❌ DPI" + +# ② 微信 CEF 等价探针:裸 chromium、不带任何 scale flag、存活 8s +docker exec woc-gate bash -c ' + rm -rf /tmp/probe; DISPLAY=:1 chromium --no-sandbox --disable-gpu --user-data-dir=/tmp/probe about:blank >/dev/null 2>&1 & + p=$!; sleep 8; kill -0 $p 2>/dev/null && echo "✓ CEF探针存活" || echo "❌ CEF探针崩溃"; kill -9 $p 2>/dev/null' + +# ③ 崩溃循环检测:干净窗口 30s 内退出次数必须为 0 +sleep 30; docker logs --since 30s woc-gate 2>&1 | grep -c '已退出' # 期望 0 + +# ④ 微信模式走到"等待安装" +docker rm -f woc-gate-wx 2>/dev/null +docker run -d --name woc-gate-wx --security-opt seccomp=unconfined --shm-size=1g \ + -e PUID=1000 -e PGID=1000 -e WOC_APP_TYPE=wechat ghcr.io/gloridust/wechat-on-cloud:latest +sleep 25; docker logs woc-gate-wx 2>&1 | grep -E "尚未安装|启动 微信" # 必须命中 + +docker rm -f woc-gate woc-gate-wx +``` + +## 3. 升级路径测试(用户做的是升级,不是全新安装) + +拿一个**上一 release 镜像**跑起来的实例(带已有数据卷),用面板「升级实例」原地升到新镜像,然后: + +- 应用正常启动、无重启循环; +- 若涉及微信:进入桌面手动点开一篇公众号文章/一个附件,窗口能打开(DPI 事故正是死在这一步); +- 面板对该实例的「可升级」角标消失。 + +## 4. 面板部署验证 + +```bash +docker compose up -d && sleep 4 +curl -s -o /dev/null -w "%{http_code}\n" http://localhost:36080/ # 200 +``` + +- 登录 → 进入一个实例 → 桌面出画面; +- 「管理」页加载正常(升级横幅/角标状态正确); +- 新建的容器 `docker inspect --format '{{.HostConfig.LogConfig}}'` 含 max-size(R3)。 + +## 5. 发布 + +```bash +git push origin main +gh release create vX.Y.Z --target main --title "..." --notes "..." +``` + +release notes 必须写清: + +1. **谁受影响、什么症状**(用户的语言,不是代码的语言); +2. **要不要升级实例**——更新面板 ≠ 更新实例,每次都要显式说; +3. **回滚指引**(R1):出问题如何回到上一版。 + +发布后:确认 CI(release.yml)双架构构建成功,再在相关 issue 回复(回复前经用户确认)。 + +## 6. P0 热修的特别约束 + +- diff 最小化:只含修复,不夹带任何特性; +- 理论必须解释**全部**已观测症状,且有 A/B 实证(R6),否则只能作为「缓解」发布并如实标注; +- 修完在 [P0复盘.md](P0复盘.md) 记一笔:症状 → 根因 → 为什么会发生 → 哪条守则能防它(没有就加一条)。 diff --git a/doc/dev/架构守则.md b/doc/dev/架构守则.md new file mode 100644 index 0000000..0149a90 --- /dev/null +++ b/doc/dev/架构守则.md @@ -0,0 +1,84 @@ +# 架构守则(不变式) + +> 本文是从真实 P0 事故里蒸馏出的**硬性约束**,不是风格建议。每条都标注了它的「学费」——哪次事故教会我们的。 +> 违反任何一条的 PR/发布,默认打回;要破例,必须在提交说明里写清为什么本条不适用。 +> 事故经过详见 [P0复盘.md](P0复盘.md);发版执行步骤见 [发布门禁.md](发布门禁.md)。 + +## R1 一切依赖必须锚定,一切发布必须可回滚 + +- 实例镜像每个 release 都打版本 tag(`vX.Y.Z`),`:latest` 只是别名,**绝不能是用户唯一能拿到的东西**。 +- base 镜像逐步 pin 到 digest;apt 装的关键包(chromium 等)版本写进构建日志,让「这次镜像里是什么」可追溯。 +- 面板应记录每个实例运行的镜像版本;「升级」= 升到具体版本,并支持回退上一版。 +- release notes 必须包含回滚指引。**发不出回滚指引的变更,就是还没准备好发布。** + +学费:v1.3.0 重建镜像时 apt 静默拉到新 Chromium,叠加 DPI bug 全面崩溃;用户喊「还是 1.2.7 稳」——但 `:latest` 已被覆盖,**想回也回不去**。 + +## R2 自身状态声明式重建,禁止克隆运行时环境 + +面板自更新、实例重建,一律从**声明的配置**(代码里的 createOpts)重新生成,绝不 inspect 旧容器再克隆其字段。运行时衍生值(Hostname、IP、自动生成的 MAC……)克隆出来就是过期的。 + +学费:v1.2.6 自更新克隆了旧容器 Hostname(= 已删除容器的 id)→ `ensureNetwork()` 找不到自己 → 新实例落到错误网络 → 全员 502 黑屏。 + +## R3 任何可写路径必须有边界 + +- 面板创建的每个容器必须带 `LogConfig`(json-file 上限)。 +- 每个缓存/转储/临时目录必须有 TTL 或容量上限(微信 crashinfo、chromium 缓存……)。 +- 升级/重建后必须回收悬空镜像。 +- **新功能引入新的可写路径时,PR 里必须写明它的边界是什么。** 没写 = 打回。 + +学费:崩溃循环每 2s 刷错 × docker 日志默认无上限 × 崩溃转储无限堆积 × 旧镜像不回收 → 群晖用户「一下子 1TB 没了」→ 磁盘满 → 升级也卡死,恶性循环。 + +## R4 预期超过 5 秒的操作一律异步 + +长任务(拉镜像、批量升级、自更新)必须:立即返回 → 后台执行 → 提供进度查询。同步 HTTP 等待在受限网络下(NAS、被墙)必然表现为「卡死」。凡是内部含网络 IO 循环的接口,按最坏网络(每步都等到超时)估时。 + +学费:一键升级同步等全程、且每实例各拉一次镜像,受限网络下每次拉取都要等满 5 分钟停滞超时 → 用户反馈「完全无法更新实例,一直卡死」。 + +## R5 面板与实例是独立发布单元,兼容矩阵必须显式处理 + +面板(新/旧)×实例镜像(新/旧)×数据卷(新/旧)的组合**全部真实存在于用户环境**。因此: + +- 面板调用实例内工具前必须能力探测(`assertHasTool` 模式),探测失败给出「请先升级实例」的人话,而不是让用户看退出码 127。 +- 实例镜像必须兼容旧数据卷(路径迁移写成幂等脚本)。 +- 涉及实例镜像的修复,发布说明必须显著提示「需升级实例」——**更新面板 ≠ 更新实例**,用户不知道这一点。 + +学费:壁纸功能对旧镜像实例报「退出码 127」;#101 把配置写到 `/home/abc` 而实例 HOME 是 `/config`。 + +## R6 根因的标准:解释 100% 的症状 + A/B 实证 + +- 一个理论若只解释部分症状(「Chromium 崩但微信也崩了没解释」),它就不是根因,**最多作为缓解措施发布,且必须标注为缓解**。 +- 修复上线前必须有 A/B 复现:坏配置 → 复现崩溃;改一处 → 崩溃消失。「改了之后好像好了」不算。 +- 多个 Chromium 内核应用同时异常时,先查共享环境(XSETTINGS/DPI/X server/共享库),再怀疑单个应用版本。 + +学费:v1.3.1 用「SwiftShader/shader 缓存」理论发了 `--force-device-scale-factor=1`——恰好治住 Chromium 的症状,但微信 CEF 加不了 flag,真根因(XSETTINGS 的 Xft/DPI 单位是 DPI×1024,写 96 = 0.09 DPI)第二天在微信身上继续爆炸。 + +## R7 外部 / AI 生成 PR:审语义,不只审形状 + +代码「看起来对」不等于对。逐项核: + +- **数值的单位**(本项目血案:Xft/DPI 是 DPI×1024); +- **路径的真实性**(HOME 是 `/config` 不是 `/home/abc`); +- 写死的镜像源/域名(TUNA mirror 会弄坏海外 CI); +- 用户输入拼进 shell 的注入面; +- 依赖/base 镜像的变更是否必要。 + +学费:#101 一个 PR 同时踩中前四项,其中单位错误演化成史上最大 P0。 + +## R8 发布节奏:特性不当天发,P0 热修最小化 + +- **特性合并与发布之间至少隔一晚**,在本地/自用环境烘烤。一天多个 release 只允许出现在 P0 热修场景。 +- P0 热修 = 最小 diff:只含修复本身,不夹带特性(夹带的特性没经历烘烤,是下一个 P0)。 +- 每次发布前过一遍 [发布门禁.md](发布门禁.md),没有例外。 + +学费:v1.2.8→v1.3.1 四个版本挤在 48 小时,合并的社区 PR、壁纸/字体、GPU 直通、会话持久化全部同窗发布——事故爆发时无法二分是哪个变更引入,排查成本 ×N。 + +## R9 冒烟必须覆盖「用户动作」,不是「进程启动」 + +「应用启动了」≠「应用可用」。实例镜像的固定探针(写在发布门禁里): + +- 裸 `chromium`(**不带任何 scale flag**)在 `:1` 上存活 8s——这是微信内嵌 CEF 的等价探针,微信本体没法在 CI 里登录,但它的浏览器内核读的和裸 chromium 是同一套环境; +- `dump_xsettings` 校验 `Xft/DPI 98304`; +- 微信模式 autostart 走到「等待安装」且 30s 无重启循环; +- **升级路径测试:拿上一个 release 的容器+数据卷,原地升级到新镜像,应用仍可用**——用户做的是升级,不是全新安装,而我们过去只测了后者。 + +学费:DPI bug 逃过了「微信启动正常」的冒烟,因为聊天主窗口是原生的,崩的是「点开公众号」这个动作。