66 lines
5.0 KiB
Markdown
66 lines
5.0 KiB
Markdown
# 49-实例回收后首次访问的反馈与自愈(2026-09-11 落地)
|
||||
|
|
|
|||
|
|
> **TL;DR**|**结论**:实例被空闲回收后首次访问"没反应":**拉起逻辑早就有,缺的是反馈**。
|
|||
|
|
> **关键**:导航(GET+HTML)→ 立刻 302 到平台过渡页(动画),由过渡页调 `/api/dsh/enter` 完成「拉起+等 token+跳回」;XHR 等服务就绪。
|
|||
|
|
> **状态**:✅ 已落地
|
|||
|
|
|
|||
|
|
## 症状
|
|||
|
|
|
|||
|
|
用户反馈:dsh 主界面一段时间不对话,后台实例被**空闲回收**;下次对话**没有反应**,必须手动刷新。
|
|||
|
|
诉求:感知用户回到页面时自动启动 + 页面给出「进程启动中」的加载动画。
|
|||
|
|
|
|||
|
|
## 根因(比预期乐观:拉起逻辑早就有,缺的是反馈)
|
|||
|
|
|
|||
|
|
| 环节 | 事实 |
|
|||
|
|
|---|---|
|
|||
|
|
| 谁回收 | `orchestrator.ts:70-117` local-mode idle reap:定期扫 `lastActive`,超 `instanceIdleTtlSeconds` 即 `stop(userId)`;`proxy.ts:120` 注释确认"真实用户流量会刷新活跃时间" |
|
|||
|
|
| 谁拉起 | ✅ 早就有:`proxy.ts` 的 `not_running` 分支会 `supervisor.launch(...)`;`POST /api/dsh/enter` 同样"不在则 launch + 返回带 token 直达 URL" |
|
|||
|
|
| **真实症状** | **不是没拉起,而是拉起要 5–20 秒,这期间浏览器零反馈**(服务端阻塞等 launch token)→ 用户以为卡死 → 手动刷新时实例已就绪 → 看似"刷新才好" |
|
|||
|
|
| 关键架构 | `alotbuy.com.conf` 的 `server_name` 含 `*.alotbuy.com` → **所有用户子域都先 `proxy_pass 127.0.0.1:3080`(门户)**,再由门户 `proxy.ts` 转发到实例 → **nginx 的 `error_page` 用不到**,正确位置是 `proxy.ts` |
|
|||
|
|
|
|||
|
|
## 用户的关键质疑与修正
|
|||
|
|
|
|||
|
|
> 「为什么要跳转到过渡页,直接在当前页面播放加载动画不是更好?」
|
|||
|
|
|
|||
|
|
**质疑成立** —— 原方案只覆盖次要场景。两个场景必须分开处理:
|
|||
|
|
|
|||
|
|
| 场景 | 表现 | 正解 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| **① 页面已打开**,实例被回收,用户继续对话(XHR/WS) | 页面内静默失败 = 用户说的"没反应" | **不是"显示动画",而是"别让请求失败"**:等实例就绪后继续转发 —— 请求最终成功,dsh 自身 loading 即反馈,用户无感 |
|
|||
|
|
| **② 刷新 / 重开页面**(整页导航) | 导航被拦 | 立刻 302 到平台自有过渡页(含动画),由过渡页完成拉起并跳回 |
|
|||
|
|
|
|||
|
|
## 实现(`src/supervisor/proxy.ts` 的 `not_running` 分支)
|
|||
|
|
|
|||
|
|
1. **导航请求**(GET + `Accept: text/html`)→ 立刻 302 到 `/wake.html?next=<原 URL>`,**不在此分支 launch**(拉起交给过渡页,单点可重试)→ 消除"服务端阻塞 20 秒白屏"。
|
|||
|
|
2. **非导航请求**(XHR/API)→ **等实例就绪后继续转发**:`launch()`(并发时退避到 `waitForLaunchTokenForUser`)→ 重新 `resolveSubdomainAccess` → 走正常 `proxyHttp` 转发。真起不来才 `503 + Retry-After: 3`。
|
|||
|
|
3. 新增 `web/wake.html`(4591B,**门户同源**,cookie 天然可用、无需 CORS):加载动画 + 自动 `POST /api/dsh/enter` 拿直达 URL → `location.replace`;失败自动重试 3 次 + 重试按钮;`next` 只允许 `*.alotbuy.com`(防开放重定向)。**零后端新增接口**(复用既有 enter)。
|
|||
|
|
|
|||
|
|
## 验证记录(真实反代链路)
|
|||
|
|
|
|||
|
|
| 用例 | 结果 |
|
|||
|
|
|---|---|
|
|||
|
|
| A) 导航(`Accept: text/html`)到 `guest.alotbuy.com` | **41 ms** 返回 **302** → `https://alotbuy.com/wake.html?next=…` ✅ 且**实例 scope 仍 0 个**(导航分支未拉起) |
|
|||
|
|
| A2) 过渡页可取 | 4591 字节,含 `wk-spin`(动画)与 `/api/dsh/enter` 调用 ✅ |
|
|||
|
|
| B) XHR(`Accept: application/json`) | **5057 ms** 返回 **401**(**不是 503**)→ 说明走了"等待 + 转发";期间**实例 scope 变 1 个** ✅ 拉起成功 |
|
|||
|
|
| 清理 | 测试实例 scope 已 stop;临时 guest 会话已删(`user_agent=poc-wake-xhr`);共享层 0 项 |
|
|||
|
|
|
|||
|
|
> 测试遵守 R4:**DB 直插 guest 临时会话**,不经登录接口、用完即删。
|
|||
|
|
|
|||
|
|
## 未做(可选增强)
|
|||
|
|
|
|||
|
|
- **页面内覆盖层动画**:场景 ① 已由"等待就绪"从根上解决(dsh 自身 loading 即反馈),故未做"注入官方 SPA 响应"方案 —— 收益有限、风险与维护成本高,且更接近 R2 边界。
|
|||
|
|
- **WS(`app.server.on upgrade`)**:实例未运行时仍 `socket.destroy()`;dsh 前端会自行重连,等实例就绪即可恢复。要连 WS 也"等就绪再建隧道",需在 upgrade 回调做同样等待(未做)。
|
|||
|
|
- 空闲 TTL 调优:`instanceIdleTtlSeconds`(属性能取舍,与 R5 无关)。
|
|||
|
|
|
|||
|
|
## 回滚
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
cp src/supervisor/proxy.ts.bak-<ts> src/supervisor/proxy.ts && npm run build && systemctl restart dshs
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
(wake.html 为惰性文件,回滚 proxy 后无入口,可留存)
|
|||
|
|
|
|||
|
|
## 事故 / 踩坑
|
|||
|
|
|
|||
|
|
⚠️ **本轮第 5 次撞上 ssh 单引号**:档案正文里的 `A')` 提前闭合了外层 `ssh '...'` 的引号,整条命令直接语法错误、什么都没执行。
|
|||
|
|
→ **再次验证硬性要求**:**任何含引号/括号/反引号的文本(尤其文档正文)一律"本地 Write → scp → 远端执行",禁止 inline heredoc 塞进 `ssh '...'`。**
|