# 72 · 实例被回收/关闭后,回到会话页面自动唤醒并重建连接 - 日期:2026-09-12 - 触发:用户「用户回到 dsh 会话页面,假如连接已经断开、进程被回收或关闭,可以自动恢复进程建立连接,**现在还是需要手动刷新才行**,能否实现」 - 结论一句话:**能,已上线** —— 根因是注入的自愈脚本**只认 401**、不认实例回收时代理返回的 `404 not_running`,且**没有「用户回到页面」这个时机**的探测入口。补上识别与探测后,用户切回标签页 / 点回窗口即自动唤醒,并跳回**带新 token** 的会话地址。 - 状态:✅ **已完成并部署**(2026-09-12 16:55,服务器 commit **`b23e386`**) - 关联:档案 49(过渡页)/50(注入脚本)/51(401 透明重放)/59(启动动画)/24 + 8(回收与 last-wins)/05(跨域 CORS 白名单) --- ## 一、现状:为什么"必须手动刷新" 链路里其实**已有**一套恢复机制,但只在**特定时机**生效: | 已有机制 | 触发时机 | 覆盖 | |---|---|---| | 代理 302 → `wake.html`(档案 49) | **导航请求**(GET + `Accept: text/html`)撞到 `not_running` | ✅ 手动刷新/点链接 = 能自动拉起 | | `wake.html` 内部调 `/api/dsh/enter` | 过渡页加载后 | ✅ 自动 launch + 跳回带 token 地址 | | 注入脚本 `SESSION_RECOVERY_JS`(档案 50) | **401**(实例被回收重建、launch token 轮换) | ✅ `expire()` → `location.reload()` | | 代理 401 透明重放(档案 51) | 服务端侧 | ✅ XHR/SSE 重放 | **缺口**:用户**已经打开着**会话页面、实例随后被回收/关闭时 —— 1. 页面上的 XHR/`fetch` 打到实例 → 代理返回 **`404 {error:'not_running'}`**(`proxy.ts` 非导航分支),而注入脚本 `hit()` **只判 401** → 静默失败; 2. SSE 流静默断掉,页面看起来只是"卡住/不动"; 3. 没有任何"用户回到页面"的探测点 → **只能用户自己按 F5**(F5 是导航请求,才走得到 `wake.html`)。 ## 二、方案(三个探测点 + 一条恢复路径,零新增后端端点) **探测(什么时候判断"实例没了")** | 时机 | 手段 | |---|---| | ① 回到页面 | `visibilitychange`(变可见) / `window.focus` / `pageshow`(`persisted`) → 主动探活 | | ② 请求失败 | `hit()` 扩展:`404` + 响应体含 `not_running`(与既有 401 判定并列) | **探活**:`GET <门户域>/api/dsh/status` → 平台返回 `running: alive(main.status)`;**`running === false` 即判定不可用**。跨域调用依据:`server.ts` 的 CORS 白名单允许 baseDomain 及其子域 + `Allow-Credentials`(档案 05 PoC-2 ② 已验证过的既有能力)。 **恢复**:跳门户 `/wake.html` → 它内部调 `POST /api/dsh/enter`(会 launch 实例)→ 跳回**带新 token** 的实例地址。 > **为什么不是 `location.reload()`**:实例被回收重建后 **launch token 已轮换**,`reload()` 会带着**旧 token** 再撞一次 401 → 再走 `expire()` → 再 reload。用户看到的就是"**要刷新两次**"。走 `enter` 一步到位。这也是原有 401 路径的现实短板(本次**未改动**它,避免影响已验证的路径)。 ## 三、实现(全部在 `src/supervisor/proxy.ts` 的注入脚本内) | 改动 | 内容 | |---|---| | 新增 `portalOrigin()` | 由子域算出门户域(`a.b.com` → `b.com`;无子域时退回 `location.origin`,兼容本机/IP 直连) | | 新增 `probe()` | 调 `/api/dsh/status`(`credentials: 'include'`);**15 秒节流**;跨域/网络异常**一律静默**,绝不误判 | | 新增 `recover(reason)` | 显示覆盖层(复用既有 pending 浮层样式)→ 600ms 后 `location.replace(portalOrigin() + '/wake.html')`;带 `recovering` 闸门防重入 | | `hit(u, s)` → `hit(u, s, readBody)` | 除 401 外,识别 `404` + 体含 `not_running`;`fetch` 用 `r.clone().text()` 读体(**不消费原响应**),`XHR` 读 `responseText` | | 事件绑定 | `document.visibilitychange`(visible)/`window.focus`/`window.pageshow`(`persisted`) | **未新增**:后端端点(复用 `/api/dsh/status` 与 `/api/dsh/enter`)、DB 字段、依赖;**未改动**:401 既有路径、导航 302 路径、`wake.html`。 ## 四、验证 | 项 | 方式 | 结果 | |---|---|---| | 编译 | `npm run typecheck` / `npm run build` | ✅ 通过 | | **注入脚本语法** | 从编译产物提取 `SESSION_RECOVERY_JS` / `SESSION_ASSIST_JS` → `new Function(code)` | ✅ 语法正确(6604 / 8823 字符) | | **新能力落位** | 6 项关键字检查(portalOrigin / recover→wake.html / status 探活 / not_running 识别 / visibilitychange / pageshow) | ✅ 全部命中 | | 服务 | `systemctl is-active` + 门户 `curl` | ✅ `active` / HTTP **200** | **端到端(浏览器)需用户验一次**:把实例闲置到被回收(或手动 `systemctl stop dsh-*`),切回该标签页 —— 期望出现「实例已休眠,正在自动唤醒…」覆盖层并自动跳回会话,**不需要按 F5**。 ## 五、边界(有意不做) 1. **用户一直盯着页面、且页面不发任何请求** → 没有探测点。但实例被回收时 SSE 会断,dsh 前端自身的重连请求会成为触发源(被代理判成 `not_running` → 被 `hit()` 捕获);且 `visibilitychange` 覆盖了"用户回来"这个主场景。**未加周期性轮询**(每用户每分钟一次 status 的负载换不来相应的收益)。 2. **不做"自动刷新后恢复用户输入"** —— 实例被回收的前提是**长时间不活跃**,此刻输入丢失风险低;且抓取 dsh DOM 输入态的做法会随官方前端升级而碎(脆弱性 > 收益)。 3. 本档只解决"回到页面自动恢复";**实例启动耗时的体感**由档案 59 的加载动画承担。 ## 六、红线遵守 - **R8**:重启 `dshs` → 断当时 **1 个**在线实例(guest `100002`)约 4 秒;执行前已与用户确认节奏("现在可以改了吗"的授权模式)。 - **R2**:纯注入脚本,**不改官方 dsh 主程序与缓存**;不落盘。 - **R7**:只改 1 个源文件(+ 1 个 `.bak-t06-*` 备份作回滚点)。 - 未 push。 ## 七、回滚 ```bash cd /opt/dshs && cp -a src/supervisor/proxy.ts.bak-t06- src/supervisor/proxy.ts && npm run build && systemctl restart dshs ``` 或 `git revert b23e386`。