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 { readFileSync } from 'node:fs'
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
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 { connect } from 'node:net'
|
||||||
import { createHash } from 'node:crypto'
|
import { createHash } from 'node:crypto'
|
||||||
import { hashSessionToken, parseCookie } from '../web/auth.js'
|
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 => {
|
const attempt = (connRetry: boolean, authCookie?: string): void => {
|
||||||
// 档案 50 修正(实测定位):**HTML 导航请求必须向上游要 identity**。
|
// 档案 50 修正(实测定位):**HTML 导航请求必须向上游要 identity**。
|
||||||
// 原因:dsh 实例对带 Accept-Encoding 的请求会 **gzip 压缩 HTML**(浏览器就带),
|
// 原因:dsh 实例对带 Accept-Encoding 的请求会 **gzip 压缩 HTML**(浏览器就带),
|
||||||
@@ -375,6 +404,11 @@ function proxyHttp(
|
|||||||
headers: upHeaders,
|
headers: upHeaders,
|
||||||
},
|
},
|
||||||
(upRes: IncomingMessage) => {
|
(upRes: IncomingMessage) => {
|
||||||
|
// 客户端已断开 ⇒ 丢弃上游响应,**一个字都不再往 reply 写**(2026-09-21)。
|
||||||
|
if (clientGone) {
|
||||||
|
upRes.resume()
|
||||||
|
return
|
||||||
|
}
|
||||||
const headers = { ...upRes.headers }
|
const headers = { ...upRes.headers }
|
||||||
const isNav =
|
const isNav =
|
||||||
request.raw.method === 'GET' && String(request.headers.accept ?? '').includes('text/html')
|
request.raw.method === 'GET' && String(request.headers.accept ?? '').includes('text/html')
|
||||||
@@ -548,10 +582,17 @@ function proxyHttp(
|
|||||||
reply.raw.writeHead(status, headers)
|
reply.raw.writeHead(status, headers)
|
||||||
reply.raw.end(out)
|
reply.raw.end(out)
|
||||||
})
|
})
|
||||||
upRes.on('error', () => replyUpstreamUnavailable(request, reply))
|
upRes.on('error', () => {
|
||||||
|
if (clientGone) return
|
||||||
|
replyUpstreamUnavailable(request, reply)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
liveUpstream = upstream
|
||||||
upstream.on('error', () => {
|
upstream.on('error', () => {
|
||||||
|
// 客户端已断开:这个 error 多半就是我们上面主动 destroy 造成的 ⇒ 不重试、不再写响应。
|
||||||
|
// 少了这句,每次断开都会**凭空再开一条上游连接** —— 正是额度被占满的正反馈来源。
|
||||||
|
if (clientGone) return
|
||||||
if (connRetry) {
|
if (connRetry) {
|
||||||
// 冷启动窗口:实例端口已分配但还没监听 ⇒ 回 503(不是裸断连接,见 helper 注释)。
|
// 冷启动窗口:实例端口已分配但还没监听 ⇒ 回 503(不是裸断连接,见 helper 注释)。
|
||||||
replyUpstreamUnavailable(request, reply)
|
replyUpstreamUnavailable(request, reply)
|
||||||
@@ -738,6 +779,12 @@ export async function registerDshProxy(app: FastifyInstance): Promise<void> {
|
|||||||
})
|
})
|
||||||
upstream.on('error', () => socket.destroy())
|
upstream.on('error', () => socket.destroy())
|
||||||
socket.on('error', () => upstream.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