# 50-会话过期自愈:实例 HTML 注入「重连」脚本(2026-09-11 落地) ## 背景 档案 49 修好了「实例被空闲回收后,请求失败在传输层」的问题(反代自动拉起 + 等待就绪), 但用户实测仍报: ``` client api: session/prompt failed: transport failure for /api/session/prompt: HTTP 401 (gateway/internal) ``` **根因(代码级确认)**:`proxy.ts` 里那段 401 处理**只对"浏览器导航"生效**: ```ts if (upRes.statusCode === 401 && request.raw.method === 'GET' && String(request.headers.accept ?? '').includes('text/html') && freshAuthUrl !== undefined) { ...302 到当前实例的新 token... } reply.raw.writeHead(upRes.statusCode ?? 502, headers); upRes.pipe(reply.raw) // ← XHR 一律原样透传 ``` 完整因果链: 1. 实例空闲被回收 → 2. 用户回到**已打开的页面**继续对话,前端发 XHR,**带着旧实例的 `dsh-auth-*` cookie** → 3. 档案 49 的修复把实例**拉起**了(这一步生效)→ 4. 新实例发现 token 是**旧 authority/端口的** → **401** → 5. 401 处理不管 XHR → 透传给前端 → dsh 显示 `transport failure` → 6. 用户手动刷新(=导航)才命中 401 处理、302 到新 token。 **→ 档案 49 消灭了"传输失败",但没消灭"认证失败"。** ## 方案取舍(用户选 C 先看效果) | 方案 | 做法 | 评价 | |---|---|---| | **B** | 反代透明重放:XHR 401 时用 `freshAuthUrl()` 取新 token → 换新 cookie → **重放该请求** | 体验最好(前端无感),但要写 cookie 重放(中等复杂度)→ **留待后续** | | **C(本次采用)** | 在反代返回的 **`text/html`** 里注入平台脚本 → 检测 `/api/*` 返回 **401** → 显示覆盖层动画 + `location.reload()`;reload 会走**已经工作**的"导航 401 → 302 新 token"链路自动恢复 | 简单、复用既有机制,且满足用户要的"当前页面给反馈" | ## 实现(`src/supervisor/proxy.ts`) 1. 新增模块级常量 **`SESSION_RECOVERY_JS`**(1480 字节,ES5 安全、全程 try/catch、幂等 `window.__dshRecover`): - hook `window.fetch` 与 `XMLHttpRequest.prototype.open/send`(`loadend` 回调) - 命中条件:**状态 401 且 URL 含 `/api/`** → 弹覆盖层(转圈 + 「会话已过期(实例被回收重建),正在重新连接…」)→ **900ms 后 `location.reload()`** 2. `injectRecovery(html)`:把 `` 插到 `` 之前(无 `` 则追加)。 3. `proxyHttp` 的响应写出改为:**仅当 `content-type: text/html` 且无 `content-encoding` 且状态非 204/304** 时—— **缓冲正文 → 注入 → 重算 `content-length` → 写出**;其余情况**原样 `pipe`**(零影响)。 若因 `content-encoding` 跳过注入,会往 stderr 打 `[inject-recovery] skip:`(便于发现"没注入"的原因)。 4. **不落盘、不改官方文件**(符合 R2:只改写平台反代自身的响应内容)。 ## 验证记录(真实反代链路,R4 模板测试) | 检查 | 结果 | |---|---| | `enter` 起实例 + `curl -L`(跟随 302)+ cookie jar 取 HTML | **24059 字节**(正常) | | 含 `__dshRecover` | ✅ 已注入 | | 含 `loadend` 钩子 | ✅ | | **`` 出现次数** | **1** | | 注入脚本是否在 `` 之前 | ✅ | | 提取注入脚本(以 `__dshRecover` 为中心向外取 ``),换成"以 `__dshRecover` 为中心向外扩张"后通过。**注入本身一直是对的。** ## 仍未做 - **B(透明重放)**:终态方案 —— XHR 401 时自动换新 token 重放,用户连 reload 都不需要。属后续增强。 - **WS(`app.server.on upgrade`)**:实例未运行时仍 `socket.destroy()`;dsh 前端会自行重连。若要"等就绪再建隧道"需在 upgrade 回调做同样等待(未做)。 - **真实浏览器端的行为验证**:本次只验证了"注入正确 + 语法正确 + 结构正确";覆盖层与自动 reload 的**交互效果需在浏览器里实测**(起实例 → 停实例 → 在旧页面发消息 → 期望看到覆盖层并自动恢复)。 ## 回滚 ``` cp src/supervisor/proxy.ts.bak- src/supervisor/proxy.ts && npm run build && systemctl restart dshs ``` --- ## 事后修正(2026-09-11 18:4x):注入对**浏览器无效**——上游 gzip 导致被跳过 **用户实测反馈**:「并没有效果,还是报刚才截图的错误」。排查后确认**是我的 bug**,且我此前的验证是**假阳性**。 ### 根因 dsh 实例(上游)对**带 `Accept-Encoding` 的请求**会 **gzip 压缩 HTML**: | 请求 | 上游响应头 | 注入 | |---|---|---| | 不带 `Accept-Encoding`(**我最初的 curl 验证方式**) | `content-encoding: (无)` | ✅ 已注入 | | **带浏览器式 `Accept-Encoding: gzip, deflate, br`** | **`content-encoding: gzip`** | **❌ 被跳过** ← 浏览器就是这种 | 我的代码在 `content-encoding` 存在时**主动跳过**注入(为避免破坏已编码内容)→ 浏览器拿到的页面**没有自愈脚本** → C 对真实用户完全无效。代理 stderr 里正好有 `[inject-recovery] skip` 记录,与推断一致。 ### 修法 `proxyHttp` 里:**只要客户端接受 HTML,就把转发给上游的 `accept-encoding` 覆盖为 `identity`** (预先算好 `upHeaders` 再传入 `httpRequest`)→ 上游回未压缩 HTML → 注入成功。 体积影响可忽略(HTML ~24KB);**边缘 nginx 仍会对浏览器 gzip**,用户侧无感知;静态资源/SSE 的压缩行为不受影响。 ### 验证(浏览器等价:`curl --compressed`) | 检查 | 结果 | |---|---| | 响应 `content-encoding` | `gzip`(**由边缘 nginx 压缩**,非上游) | | 解压后正文 | **24059 字节** | | 含 `__dshRecover` / `loadend` | **✅ / ✅** | | **代理 `[inject-recovery] skip` 记录(本次)** | **0 条** ✅(修正生效,上游不再压缩) | | 注入脚本 `node --check` | ✅ 通过 | | 注入位置 | ✅ 在 `` 之前 | ### 第二个教训:**测试方法的假阳性 → 假阴性** - **假阳性**(第一版验证):curl **不带** `Accept-Encoding` → 拿到未压缩 HTML → 通过 → 误以为"浏览器也行"。 - **假阴性**(复验时):`curl -H 'Accept-Encoding: gzip'` 时 **curl 默认不解压** → 我读到的是压缩字节 → `includes('__dshRecover')` 必然为 false → 误判"修复无效"。 - **正确姿势**:模拟浏览器必须用 **`curl --compressed`**(既发 `Accept-Encoding`,**又自动解压**)。 → **凡"注入/改写响应"类改动,验证必须覆盖"浏览器式请求头 + 解压后正文"两个条件**。