diff --git a/dsh-server-docs/04-调整方案/141-实例子域冷启动窗口代理裸断连接致502.md b/dsh-server-docs/04-调整方案/141-实例子域冷启动窗口代理裸断连接致502.md new file mode 100644 index 0000000..e9e382f --- /dev/null +++ b/dsh-server-docs/04-调整方案/141-实例子域冷启动窗口代理裸断连接致502.md @@ -0,0 +1,70 @@ +# 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 / 域名 / 实例挂了 diff --git a/src/supervisor/proxy.ts b/src/supervisor/proxy.ts index f863a7e..badfd7f 100644 --- a/src/supervisor/proxy.ts +++ b/src/supervisor/proxy.ts @@ -244,6 +244,43 @@ async function resolveSubdomainAccess( return { endpoint, userId: session.user.id } } +/** + * 2026-09-19(档案 141):**实例冷启动窗口内不许裸断连接**。 + * + * 实测事故(admin 首次用新域 `admin.ai1net.com` 进场): + * 16:06:52 `POST /api/dsh/enter`(耗时 11.1 s)拉起实例,scope 16:06:52 建立; + * 16:07:04(12 s 后)用户 GET `/` —— 此时实例**进程已在、端口已分配**但 dsh 尚未开始监听, + * 本文件的转发重试两次都失败 ⇒ 旧代码 `reply.raw.destroy()` **不写任何响应** + * ⇒ 边缘 nginx 只能记 `upstream prematurely closed connection while reading response header` + * 并回 **502**(平台 journal 里该请求只有 `incoming request`、没有 `request completed`, + * 正是"平台接了却没回"的铁证),浏览器看到的是一张无信息的 502 页。 + * + * 处置:响应头尚未发出时回 **503 + Retry-After**(导航请求给一页会自刷新的极简 HTML), + * 与档案 49「实例未就绪要给过渡/可重试信号」的既有口径一致;只有**已开始写响应**时才允许 destroy。 + */ +function replyUpstreamUnavailable(request: FastifyRequest, reply: FastifyReply): void { + if (reply.raw.headersSent || reply.raw.writableEnded) { + reply.raw.destroy() + return + } + const isNav = + request.raw.method === 'GET' && String(request.headers.accept ?? '').includes('text/html') + const common = { 'retry-after': '2', 'cache-control': 'no-store' } + if (isNav) { + reply.raw.writeHead(503, { ...common, 'content-type': 'text/html; charset=utf-8' }) + reply.raw.end( + '
正在唤醒你的工作区,页面将在 2 秒后自动重试。
' + + '', + ) + return + } + reply.raw.writeHead(503, { ...common, 'content-type': 'application/json' }) + reply.raw.end('{"error":"instance_starting"}') +} + function proxyHttp( request: FastifyRequest, reply: FastifyReply, @@ -511,12 +548,13 @@ function proxyHttp( reply.raw.writeHead(status, headers) reply.raw.end(out) }) - upRes.on('error', () => reply.raw.destroy()) + upRes.on('error', () => replyUpstreamUnavailable(request, reply)) }, ) upstream.on('error', () => { if (connRetry) { - reply.raw.destroy() + // 冷启动窗口:实例端口已分配但还没监听 ⇒ 回 503(不是裸断连接,见 helper 注释)。 + replyUpstreamUnavailable(request, reply) return } // A stale keep-alive socket or a Pod that just restarted: drop the pool,