fix(实例代理): 客户端断连主动中止上游,根治 relay per-port 额度泄漏导致的 503
- 判据落在响应流 reply.raw 上(不可用 request.raw:GET 无请求体, 请求头读完即 close,会误判为客户端断开而掐死正常请求) - clientGone 后丢弃上游响应、不再重试、不写已关闭的 reply - 隧道 'close' 双向 destroy('close' 不是 'error',正常断开只发 close) - 档案 146
This commit is contained in:
1 parent
a06885295c
commit
20fb8dcbb5
1 file changed
+49
-2
+49
-2
@@ -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())
|
||||
})()
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user