Skip to content

Bug: 实时通道断开时 UI 静默展示过期数据,用户易误判为数据丢失/分叉 #1268

Description

@TERRYYYC

Summary

当 Socket.IO 连接无法建立时,Hub 前端没有任何可见提示,而是静默地继续展示页面加载那一刻的快照。用户无从得知自己看到的是过期内容,只能通过"两块屏幕对不上"偶然发现,并且极易误判为数据丢失或数据分叉

本 issue 的重点不是某一种断连原因,而是 UI 层缺少连接状态反馈这一缺陷本身。

Impact

  • 静默失败:无错误提示、无降级标识、无重连状态。UI 与"一切正常"完全无法区分。
  • 误导用户怀疑数据层:实际发生的情形是,用户在平板上看到内容与主机不一致,第一反应是「数据有分差」——怀疑数据完整性。实际上服务端数据始终完整(实测 20 条消息一条不差),仅仅是连接断了。让用户不信任数据,对协作工具而言代价高于 bug 本身。
  • 跨设备场景下会反复出现:手机/平板访问、休眠唤醒、网络切换、反向代理/Tailscale 部署等都可能触发。随着非 localhost 使用增多,暴露面持续扩大。
  • socket.io 客户端会无限重试,但 UI 全程不知情:当失败原因是服务端拒绝(如 Origin 校验)时,重试永远不会成功,UI 却始终静默。

严重性评估:P2。数据无损,刷新页面即可恢复,纯 localhost 场景几乎不触发。但影响用户对数据的信任,且跨设备场景下必然复现。

Steps to reproduce

以下路径为实测验证(macOS + Tailscale Serve):

  1. tailscale serve --http=80 --bg 3003 将 Hub 暴露到 tailnet
  2. 从另一台设备(平板/手机)打开 http://<device>.<tailnet>.ts.net
  3. 页面正常加载,历史消息、thread 列表全部正常显示
  4. 从主机侧(localhost:3003)或通过 agent 在同一 thread 产生新消息
  5. 观察平板:新消息不出现,且界面无任何异常提示
  6. 手动刷新页面 → 内容立即补齐(证明 HTTP 通路正常,仅 socket 不通)

底层原因是 WebSocket 握手被 Origin 校验拒绝,实测对照:

# A. 带浏览器真实 Origin(真实设备行为)
$ curl -H "Origin: http://<device>.<tailnet>.ts.net" \
    "http://<device>.<tailnet>.ts.net/socket.io/?EIO=4&transport=polling"
{"code":4,"message":"Origin not allowed"}   → HTTP 403

# B. 不带 Origin(易产生假阳性的测法)
$ curl "http://<device>.<tailnet>.ts.net/socket.io/?EIO=4&transport=polling"
0{"sid":"...","upgrades":["websocket"],...}  → HTTP 200

# C. WebSocket upgrade + Origin
→ HTTP 403

# D. 对照组:localhost Origin
$ curl -H "Origin: http://localhost:3003" \
    "http://127.0.0.1:3004/socket.io/?EIO=4&transport=polling"
→ HTTP 200

更通用的触发条件(未逐一实测,属合理推断):API 进程重启、网络切换、设备休眠唤醒、反代未正确转发 upgrade 等,任何导致 socket 长时间无法建连的情况都应表现出相同的静默症状。

Expected vs Actual

Expected:当实时通道未连接时,UI 应明确告知用户,至少满足其一:

  • 可见的连接状态指示(已连接 / 重连中 / 已断开)
  • 断开时提示"当前内容可能不是最新"并提供刷新入口
  • 重试耗尽或遭遇不可恢复错误(如 403 Origin 拒绝)时给出明确错误,而非无限静默重试

Actual:无任何提示。UI 静默展示过期快照,用户只能靠跨设备比对偶然察觉,并倾向于误判为数据问题。

Root cause(代码位置)

Origin 校验发生在 Socket.IO 的 allowRequest 钩子:

  • packages/api/src/infrastructure/websocket/SocketManager.ts:136isOriginAllowed(origin, corsOrigins),拒绝时返回 {"code":4,"message":"Origin not allowed"}
  • packages/api/src/config/frontend-origin.ts:96-125 — 允许列表构成。非 loopback、非 FRONTEND_URL、非私网(需 CORS_ALLOW_PRIVATE_NETWORK=true)的自定义域名(如 *.ts.net)默认不在其中

服务端拒绝行为本身是符合安全设计的(F156 硬化),本 issue 主张的不是放宽校验,而是前端应把连接失败状态呈现给用户

Suggested fix

  1. 最小改动:在 Hub 中增加连接状态指示器,socket 未连接时显示可见标识。前端已有 transport 事件可用(packages/web/src/hooks/useSocket.ts:1027 已监听 upgrade),具备接入基础。
  2. 区分错误类型:对 code:4 / Origin not allowed 这类不可恢复的失败,应停止无限重试并给出可操作的错误信息(提示检查 FRONTEND_URL 配置),而非与"临时网络抖动"同等处理。
  3. 可选:断线期间标记内容为"可能过期",恢复连接后自动补齐增量。

Environment

  • OS: macOS 26.5.2
  • Node: v24.18.0
  • pnpm: 9.15.4
  • Clowder commit: e0c11043d
  • socket.io / socket.io-client: 4.8.3
  • 部署形态:单用户模式,API_SERVER_HOST 保持默认 127.0.0.1,通过 Tailscale Serve 反代访问

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    acceptedMaintainer accepted: ready for implementation/mergebugSomething isn't workingtriagedMaintainer reviewed, replied, and made an initial triage decision

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions