fix(实例代理): 客户端断连主动中止上游,根治 relay per-port 额度泄漏导致的 503

- 判据落在响应流 reply.raw 上(不可用 request.raw:GET 无请求体,
  请求头读完即 close,会误判为客户端断开而掐死正常请求)
- clientGone 后丢弃上游响应、不再重试、不写已关闭的 reply
- 隧道 'close' 双向 destroy('close' 不是 'error',正常断开只发 close)
- 档案 146
This commit is contained in:
admin committed 2026-09-21 17:22:04 +08:00
1 parent a06885295c
commit 20fb8dcbb5
1 file changed
+49 -2
+49 -2
View File
@@ -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<void> {
})
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())
})()
})
}