1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
+ ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
67 lines
5.0 KiB
Markdown
67 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 '...'`。**
|