# 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- src/supervisor/proxy.ts && npm run build && systemctl restart dshs ``` (wake.html 为惰性文件,回滚 proxy 后无入口,可留存) ## 事故 / 踩坑 ⚠️ **本轮第 5 次撞上 ssh 单引号**:档案正文里的 `A')` 提前闭合了外层 `ssh '...'` 的引号,整条命令直接语法错误、什么都没执行。 → **再次验证硬性要求**:**任何含引号/括号/反引号的文本(尤其文档正文)一律"本地 Write → scp → 远端执行",禁止 inline heredoc 塞进 `ssh '...'`。**