diff --git a/src/supervisor/proxy.ts b/src/supervisor/proxy.ts index badfd7f..f4a448e 100644 --- a/src/supervisor/proxy.ts +++ b/src/supervisor/proxy.ts @@ -13,7 +13,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { Agent, request as httpRequest, type IncomingHttpHeaders, type IncomingMessage } from 'node:http' +import { Agent, request as httpRequest, type ClientRequest, type IncomingHttpHeaders, type IncomingMessage } from 'node:http' import { connect } from 'node:net' import { createHash } from 'node:crypto' import { hashSessionToken, parseCookie } from '../web/auth.js' @@ -338,6 +338,35 @@ function proxyHttp( } } + /** + * 客户端提前断开(关页 / 刷新 / 切走)⇒ **必须主动中止上游**(2026-09-21 事故根治)。 + * + * 由来(guest 实例 503 事故):本文件在 `agent: false` 下是**每请求一条独立 TCP**, + * 靠"响应结束"释放。而 dsh 有大量**永不结束**的 SSE / 长响应 ⇒ 客户端一走, + * 上游请求**没人管**:socket 永久 `ESTAB`、`upRes` 把数据灌进已关闭的 `reply`。 + * 在 relay(跨机)形态下,每条这样的连接都**占着中继的 per-port 并发额度** + * (`DEFAULT_MAX_STREAMS_PER_PORT = 64`,纯计数门限、**无空闲回收**)⇒ + * 额度被不可回收地占满后,该实例端口**永远只回 `refused: busy`** ⇒ + * 平台代理拿不到上游 ⇒ 用户侧全是 503;而「打不开 ⇒ 反复刷新 ⇒ 再泄漏」 + * 恰好构成**正反馈**,越刷越死(实测 45 分钟不恢复,重启控制面才清空)。 + * + * 🔴 判据必须用 `reply.raw.writableEnded`:正常完成的请求**也会**触发 `'close'`, + * 那时响应已 `end()` ⇒ 不动作。只有"请求流关了、响应却还没发"才是真断开。 + */ + let liveUpstream: ClientRequest | undefined + let clientGone = false + // ✅ 追加节第 1 条 · 146 §九 正解:判据落在**响应流** `reply.raw` 上。 + // ⛔ 不可用 `request.raw`:GET 请求(无体)在**请求头读完**即 destroy 并 emit 'close' + // ⇒ 几乎立刻触发、而响应尚未写出 ⇒ 误判"客户端提前断开" ⇒ 掐死正常请求 ⇒ 用户侧 499。 + // ⛔ 条件里也不可加 `reply.raw.destroyed`:'close' 触发时响应流通常已 destroyed + // ⇒ 条件恒真 ⇒ 永不置 clientGone ⇒ 泄漏修复静默失效(假绿)。 + reply.raw.on('close', () => { + if (reply.raw.writableEnded) return + clientGone = true + liveUpstream?.destroy() + liveUpstream = undefined + }) + const attempt = (connRetry: boolean, authCookie?: string): void => { // 档案 50 修正(实测定位):**HTML 导航请求必须向上游要 identity**。 // 原因:dsh 实例对带 Accept-Encoding 的请求会 **gzip 压缩 HTML**(浏览器就带), @@ -375,6 +404,11 @@ function proxyHttp( headers: upHeaders, }, (upRes: IncomingMessage) => { + // 客户端已断开 ⇒ 丢弃上游响应,**一个字都不再往 reply 写**(2026-09-21)。 + if (clientGone) { + upRes.resume() + return + } const headers = { ...upRes.headers } const isNav = request.raw.method === 'GET' && String(request.headers.accept ?? '').includes('text/html') @@ -548,10 +582,17 @@ function proxyHttp( reply.raw.writeHead(status, headers) reply.raw.end(out) }) - upRes.on('error', () => replyUpstreamUnavailable(request, reply)) + upRes.on('error', () => { + if (clientGone) return + replyUpstreamUnavailable(request, reply) + }) }, ) + liveUpstream = upstream upstream.on('error', () => { + // 客户端已断开:这个 error 多半就是我们上面主动 destroy 造成的 ⇒ 不重试、不再写响应。 + // 少了这句,每次断开都会**凭空再开一条上游连接** —— 正是额度被占满的正反馈来源。 + if (clientGone) return if (connRetry) { // 冷启动窗口:实例端口已分配但还没监听 ⇒ 回 503(不是裸断连接,见 helper 注释)。 replyUpstreamUnavailable(request, reply) @@ -738,6 +779,12 @@ export async function registerDshProxy(app: FastifyInstance): Promise { }) upstream.on('error', () => socket.destroy()) socket.on('error', () => upstream.destroy()) + // 🔴 对称收尾(2026-09-21 事故根治):`'close'` **不是** `'error'` —— + // 正常断开(关页 / 刷新 / 切走)只发 `'close'`。少了这两行,一侧关闭后另一侧 + // 会一直挂着;而 relay 形态下每条挂着的隧道都占着中继 per-port 并发额度 + // ⇒ 额度只增不减 ⇒ 该实例端口永久 `busy` ⇒ 用户侧全 503。 + upstream.on('close', () => socket.destroy()) + socket.on('close', () => upstream.destroy()) })() }) }