Files
dsh_shenxian/dsh-server-docs/04-调整方案/141-实例子域冷启动窗口代理裸断连接致502.md
T
admin 1242d07db0 fix(实例代理): 冷启动窗口转发失败改为 503,消除实例子域 502(档案 141)
根因:proxyHttp() 在转发两次失败后 reply.raw.destroy(),不写任何响应头与 body,边缘 nginx 只能回 502。
现象:admin 首次用 admin.ai1net.com 进场即 502 —— /api/dsh/enter 拉起实例后 12 秒访问,实例进程已在、端口已分配但 dsh 尚未监听。
改法:新增 replyUpstreamUnavailable(),响应头未发出时回 503 + Retry-After(导航请求给会自动重试的极简 HTML),仅 headersSent/writableEnded 才允许 destroy。
实测:门户 200 / admin.ai1net.com 401 / 实例端口 401 / 启动日志 0 error / verify-inject 全绿。
2026-09-19 16:18:41 +08:00

71 lines
4.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 141-实例子域冷启动窗口内代理裸断连接致 502(2026-09-19 修复)
> **一句话**:实例已拉起、端口已分配,但 dsh 还没开始监听的那十几秒里,平台代理转发两次都失败后
> **不写任何响应就销毁连接** ⇒ 边缘 nginx 只能回 **502**(浏览器拿到一张无信息的错误页)。
> 现改为回 **503 + Retry-After**(导航请求给一页会自动重试的极简 HTML)。
## 现象(用户报障)
「admin 账号登录服务器报错 **502**」——admin 登录门户成功,随后打开自己的工作区
`https://admin.ai1net.com/` 时页面 **502**。
## 取证链(全部实测)
| # | 证据 | 内容 |
|---|---|---|
| 1 | nginx error log | `2026/09/19 16:07:04 [error] upstream prematurely closed connection while reading response header from upstream, request: "GET / HTTP/2.0", upstream: "http://127.0.0.1:3080/", host: "admin.ai1net.com"`;16:07:05 `GET /favicon.ico` 同错 |
| 2 | nginx access log | host=`admin.ai1net.com` 全程**只有这 2 条**、**都是 502**(说明是该域首次被访问) |
| 3 | 平台 journal | `req-1tx` `GET / (host=admin.ai1net.com)` **只有 `incoming request`、没有 `request completed`** ⇒ 平台接了请求但**没写出任何响应** |
| 4 | 触发时序 | `16:06:52 POST /api/auth/login` 200(80 ms) → `16:06:52 POST /api/dsh/enter` **200 但耗时 11.1 s** → scope `dsh-114801-70e1d05b.scope` `ActiveEnterTimestamp=16:06:52` → **16:07:04** 用户访问(= 实例启动后 **12 秒**) |
| 5 | 现状自愈 | `curl 127.0.0.1:20000` = **401**(dsh 正常态)⇒ 实例就绪后自行恢复;期间同机 curl 带 `Host: admin.ai1net.com` 打 3080 一律 401,**无法复现 502** |
⚠️ 该 502 **不是**平台进程挂了(`dshs` 一直 active、`NRestarts=0`、3080 在听、日志全是 200),
也**不是**域名/nginx 配置问题 —— 别往这三个方向查。
## 根因(代码级)
`src/supervisor/proxy.ts` → `proxyHttp()`:
- `upstream.on('error')`:首次失败 → 丢弃 keep-alive 池并**重试一次**;**第二次失败(`connRetry === true`)→ `reply.raw.destroy()`**(旧 517–520 行)
- 同类:`upRes.on('error', () => reply.raw.destroy())`(旧 514 行)
⇒ 两处都是**不发响应头、不写 body,直接销毁 socket**,客户端一侧只能由边缘代理兜底成 502。
**为什么没走"实例未运行"的过渡页分支**:`resolveSubdomainAccess()` 用
`app.supervisor.endpointFor(userId)` 判活(**不读 `dsh_instances.status`**)。实例记录/端口已存在 ⇒
返回 `endpoint`,于是**进的是转发分支**而不是 `404 not_running → 302 /wake.html`(档案 49 那条)——
用户既拿不到过渡页、也拿不到 503,只有 502。
## 修法(最小改动,只动错误分支)
新增模块级 `replyUpstreamUnavailable(request, reply)`:
- 响应头**尚未发出** ⇒ 导航请求(GET + `accept: text/html`)回
**503 + `Retry-After: 2` + 会自动 `location.reload()` 的极简 HTML**;
非导航回 **503 + JSON `{"error":"instance_starting"}`**(与既有 652 行口径一致)
- 已 `headersSent` / `writableEnded` ⇒ 维持 `destroy()`(不能在半截响应上再写头)
调用点:`upstream.on('error')` 的 `connRetry` 分支、`upRes.on('error')`。
## 部署与回滚
| 项 | 内容 |
|---|---|
| 送法 | 本机 `tsc -p tsconfig.json` → `scp lib/supervisor/proxy.js` → `systemctl restart dshs`(**只送编译产物**) |
| 送前核对 | 已 `diff` 线上文件 vs 本地新产物 = **仅本次 43 行差异**(证明线上 = 仓库 HEAD 的产物) |
| 备份 | `/opt/dsh/backups/proxy.js.pre141-20260919-161444`(37350 B) |
| 回滚 | `cp` 该备份回 `/opt/dshs/lib/supervisor/proxy.js` → `systemctl restart dshs` |
| 验收 | `is-active dshs`=active | 门户 `Host: ai1net.com` = **200** | `admin.ai1net.com` = **401** | `127.0.0.1:20000` = **401** | 启动日志 **0 error** | `verify-inject.cjs` 全绿 |
## 遗留(未修,另行立项)
1. 🟡 `dsh_instances` 里 **admin 行 `status=stopped`、`pid`/`port` 为空**,而 47 上它的 scope 一直在跑
⇒ 控制面视图与实际进程不一致。本次修复不依赖该字段(`endpointFor` 另有来源),故不影响;
但是否诱发**重复 `launch`**需单独立项核。
2. ⚪ WebSocket `upgrade` 分支的 `socket.destroy()`(684 行,未认证 / 未知 host)属**预期**行为,未动。
## 关联
- **档案 49**:实例未就绪 → 过渡页 `/wake.html` 的既有设计(本档案是它在"实例已存在但未监听"这一盲区的补漏)
- `PLAYBOOK-实例与插件坑.md §18`:同为「**报错形态误导排查方向**」族 —— 报 502 时先查"平台有没有回响应",别先怀疑 nginx / 域名 / 实例挂了