# 稳定性设计:远程桌面链路 与 升级链路

> 本文记录这两条「命脉链路」的**现行设计、每个决策背后的原因、以及故意不做的事**。
> 改动这两条链路前必读;若要推翻某个决策,先读懂它对应的学费(见 [P0复盘.md](P0复盘.md))。
> 通用不变式见 [架构守则.md](架构守则.md),发版检查见 [发布门禁.md](发布门禁.md)。

## 一、远程桌面链路

```
浏览器(noVNC iframe) ──ws──> 面板(http-proxy, 注入凭据) ──ws──> 实例容器 nginx:3000 ──> websockify ──> Xvnc(KasmVNC)
```

### 关键设计决策(为什么是现在这样)

| 决策 | 原因 |
|---|---|
| **重连一律整页重载**,绝不页内重挂 iframe | 新旧两条 ws 短暂并存会概率性把 Xvnc 卡死(需重启容器才能恢复)。修过 4 次卡死后确立(`13ec0bd→b0511f5`),**不要退回去** |
| **502 返回自动重连页**(3s×10 次→手动重试) | 纯文本 502 会被 iframe 判为"已加载",用户卡在黑屏死页。实例重启/升级/自愈期间的短暂不可用应自动恢复 |
| **代理双侧 TCP keepalive(30s)** | 客户端断网/切网(WiFi→4G、NAS 休眠)时 TCP 不会通知,半开死连接可挂数小时,对 KasmVNC 是幽灵会话占坑——与新连接并存正是 Xvnc 卡死诱因 |
| **看门狗 soft/hard 双阈值内存自愈** | KasmVNC/Xvnc 长跑泄漏(实测 24h 可到 ~9GiB)。soft(默认 1500MB)只在无活跃会话时柔和重启;hard(默认 2500MB)无条件重启防 OOM |
| **VNC 响应性探测默认关闭** | 宿主级 CPU/IO 争用(如同机 docker build)会让探测超时、误杀健康实例。需要时 `WOC_WATCHDOG_HEALTH_FAILS=N` 开启 |
| **用户自助「卡死自愈」+ 3 分钟限流** | Xvnc ws 接收器卡死时 nginx 仍能出静态页(看起来在线),只有重启容器能救;限流防重启风暴 |
| **`[vnc]` 三段连接日志**(尝试/上游101/关闭时长) | 与容器内 KasmVNC 日志按时间对齐,可判定卡在"面板→实例"还是实例内部 |
| **控制权心跳(2.5s 节流)** | 兼做"是否有人在用"信号,供 soft 自愈决定时机 |
| **X 环境:xsettingsd `Xft/DPI 98304`** | XSETTINGS 单位是 DPI×1024;写错 = 所有 Chromium 内核(微信 CEF)GPU 崩溃(史上最大 P0,见复盘) |

### 故意不做的事(反复想清楚了)

- **"新连接踢掉旧连接"(单会话强制)**:noVNC 开着 `reconnect=true`,两台设备会互踢打架(PC 重连踢手机、手机重连踢 PC,无限战争)。整页重载 + keepalive 已覆盖主要场景。
- **页内软重连**:见上,双 ws 并存卡死 Xvnc。
- **激进的健康探测**:误杀比漏杀更伤(用户正用着被重启)。

### 已知单点(接受的风险)

链路可用性 = KasmVNC 单进程可用性:Xvnc 卡死无法进程内恢复,只能重启容器。缓解 = 卡死自愈 + 看门狗;根治需换流媒体栈(selkies/WebRTC),收益不抵风险,暂不做。

## 二、升级链路(三条)

### 共同不变式

1. **恢复类操作幂等(R10)**:重启/卡死自愈/看门狗自愈/重置设备ID 一律 `runInstance(inst, { keepImage: true })`——沿用当前镜像重建,绝不因本地 `:latest` 被拉新过就隐式换版。换镜像**只**发生在显式「升级」。
2. **长任务异步(R4)**:凡内部含拉镜像的接口,立即返回 + 后台执行 + 轮询进度。同步等待会被反代 ~60s 掐断 → 误报失败 → 用户重试 → 并发重建。
3. **资源有界(R3)**:实例容器建时注入 `LogConfig 20m×2`;升级完成回收悬空镜像;拉取带 5 分钟无进度停滞超时(`WOC_PULL_STALL_MIN`)。

### ① 面板自更新(watchtower 式)

流程:拉新镜像(带停滞超时)→ 派生 helper 容器(新镜像+docker.sock)→ helper 停删旧面板 → 按**声明式配置**重建(env = 新镜像 baked + compose 运行时差量;**绝不克隆旧容器的 Hostname 等运行时值**,那是 v1.2.6 全员 502 的根因)→ 健康检查稳定 8s → 成功;失败自动回滚旧镜像。

- **版本锚定(R1)**:优先拉「更新检查」宣告的具体版本 tag(如 `1.3.1`),失败回退 `:latest`——保证"更新到 v1.3.1"拿到的就是 v1.3.1。
- `updateInFlight` 10 分钟自动复位(helper 静默失败后用户可重试)。
- 最坏兜底:helper 也挂 → 用户 `docker compose up -d` 即恢复。

### ② 单实例升级

`POST /instances/:id/upgrade` → 登记 `upgradingIds` 立即返回 → 后台 拉取→重建(保留数据卷)→ 移出登记。前端轮询 `upgrade-status.upgradingIds`,按"升级后是否仍落后"给结论。与一键升级互斥(409)。**保留原运行状态**:原本停止的实例升级后停回去。

### ③ 一键升级全部

顺序必须是:**先拉镜像 → 再判定落后清单 → 逐个重建**(反过来会漏掉"本来等于旧最新版"的实例)。镜像只拉一次;进度(`n/total · 当前实例`)从 `upgrade-status.upgradeAll` 轮询;页面刷新后自动恢复轮询;完成后回收悬空镜像。

### 「可升级」检测(双信号)

- **本地信号**:容器镜像 id ≠ 本地 `:latest` id(逐实例,驱动"可升级"角标)。
- **远端信号**:registry manifest **HEAD** digest ≠ 本地 RepoDigests(30 分钟缓存、后台刷新、离线/自构建返回未知不打扰)。**没有它,用户更新完面板后本地镜像还是旧的,升级引导永远不出现**(设计盲区,已踩过)。

### 用户心智:更新面板 ≠ 更新实例

二者是不同镜像/容器。所有涉及实例镜像的修复,发布说明必须显式提示"请升级实例";面板在「关于」与「管理」两处主动提示落后实例数。
