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):
- 用
tailscale serve --http=80 --bg 3003 将 Hub 暴露到 tailnet
- 从另一台设备(平板/手机)打开
http://<device>.<tailnet>.ts.net
- 页面正常加载,历史消息、thread 列表全部正常显示
- 从主机侧(
localhost:3003)或通过 agent 在同一 thread 产生新消息
- 观察平板:新消息不出现,且界面无任何异常提示
- 手动刷新页面 → 内容立即补齐(证明 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:136 — isOriginAllowed(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
- 最小改动:在 Hub 中增加连接状态指示器,socket 未连接时显示可见标识。前端已有 transport 事件可用(
packages/web/src/hooks/useSocket.ts:1027 已监听 upgrade),具备接入基础。
- 区分错误类型:对
code:4 / Origin not allowed 这类不可恢复的失败,应停止无限重试并给出可操作的错误信息(提示检查 FRONTEND_URL 配置),而非与"临时网络抖动"同等处理。
- 可选:断线期间标记内容为"可能过期",恢复连接后自动补齐增量。
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
Summary
当 Socket.IO 连接无法建立时,Hub 前端没有任何可见提示,而是静默地继续展示页面加载那一刻的快照。用户无从得知自己看到的是过期内容,只能通过"两块屏幕对不上"偶然发现,并且极易误判为数据丢失或数据分叉。
本 issue 的重点不是某一种断连原因,而是 UI 层缺少连接状态反馈这一缺陷本身。
Impact
严重性评估:P2。数据无损,刷新页面即可恢复,纯 localhost 场景几乎不触发。但影响用户对数据的信任,且跨设备场景下必然复现。
Steps to reproduce
以下路径为实测验证(macOS + Tailscale Serve):
tailscale serve --http=80 --bg 3003将 Hub 暴露到 tailnethttp://<device>.<tailnet>.ts.netlocalhost:3003)或通过 agent 在同一 thread 产生新消息底层原因是 WebSocket 握手被 Origin 校验拒绝,实测对照:
更通用的触发条件(未逐一实测,属合理推断):API 进程重启、网络切换、设备休眠唤醒、反代未正确转发 upgrade 等,任何导致 socket 长时间无法建连的情况都应表现出相同的静默症状。
Expected vs Actual
Expected:当实时通道未连接时,UI 应明确告知用户,至少满足其一:
Actual:无任何提示。UI 静默展示过期快照,用户只能靠跨设备比对偶然察觉,并倾向于误判为数据问题。
Root cause(代码位置)
Origin 校验发生在 Socket.IO 的
allowRequest钩子:packages/api/src/infrastructure/websocket/SocketManager.ts:136—isOriginAllowed(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
packages/web/src/hooks/useSocket.ts:1027已监听upgrade),具备接入基础。code:4 / Origin not allowed这类不可恢复的失败,应停止无限重试并给出可操作的错误信息(提示检查FRONTEND_URL配置),而非与"临时网络抖动"同等处理。Environment
e0c11043dAPI_SERVER_HOST保持默认127.0.0.1,通过 Tailscale Serve 反代访问Related