Files
dsh_shenxian/test/im-backpressure.test.mjs
T
admin 5ec99ce227
build / build-and-scan (push) Canceled after 0s
chore: 文档库全量重新入库(2026-10-20)
2026-10-20 15:05:55 +08:00

287 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// IM 扇出**背压**回归测试(node:test)—— 2026-09-25 第 15 棒 · 修 `send()` 丢弃 `write()` 返回值。
//
// 修的问题:`send()` 恒 `return true`、不看 `socket.write()` 返回值、也不看水位
// ⇒ **慢客户端(弱网 / 故意不读)让待写缓冲无界增长** ⇒ 单点即可把进程内存顶爆。
//
// 覆盖面:① hub 背压计数(计数 / 峰值 / 断开数)② 扇出语义**未变**(delivered === onlineIn,
// ⛔ 不因客户端慢而虚减)③ `write()=false` ⇒ 记账但**不丢帧** ④ 水位超限 ⇒ **具名断开**。
//
// ⚠️ 用**假 socket**(`writableLength` 可注入、`write` 可返回 false)⇒ 判据可复现,
// ⛔ 不必真写 4 MiB 等 OS 缓冲满(那样测试既慢又不稳)。
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { Duplex } from 'node:stream'
import { ImHub } from '../lib/im/hub.js'
import { createImWsRuntime, IM_SEND_BUFFER_LIMIT_BYTES } from '../lib/im/ws.js'
/** 只满足 hub / ws 需要的 store 桩(类型在编译期,运行期只需这几个方法)。 */
function makeStore({ member = true } = {}) {
return {
isMember: async () => member,
findRoom: async () => ({ roomType: 'demo', config: {} }),
findMember: async () => ({ roomId: 'r1', memberId: 'u1', kind: 'human', role: 'member' }),
decideWrite: async () => 'ok',
appendMessage: async () => ({ message: { id: 'm1', seq: 1 }, inserted: true }),
all: async () => [],
latestSeq: async () => 0,
listMessages: async () => [],
countMembers: async () => 1,
presenceSnapshot: () => ({}),
}
}
function makeHub(store) {
return new ImHub(store, {})
}
/**
* 假 socket —— 继承 `Duplex`,但把两个关键量**变成可控的**:
* - `writableLength`:可注入(`setPending`)⇒ 不必真写满 OS 缓冲就能进"超限"分支
* - `write()` 返回值:`_write` 不回调 `cb` 且 `highWaterMark` 很小 ⇒ 首次写即返回 `false`
*/
class FakeSocket extends Duplex {
constructor({ highWaterMark = 4 << 20 } = {}) {
super({ highWaterMark })
this.written = []
this.destroyedBySelf = false
this._pendingOverride = null
this._blockWrites = false
}
/**
* 注入待写缓冲读数(超限分支用)。
*
* ⚠️ 为什么要 `defineProperty` **在实例上**:Node 在构造时把 `writableLength` 定义成
* **实例自有属性** ⇒ 子类原型上的 getter **被遮蔽、静默失效**(本夹具第一版就踩了这个:
* 断言"该帧不投递"却投递了,因为读到的仍是真实值 0)。所以必须在**同一层**覆盖。
*/
setPending(n) {
this._pendingOverride = n
Object.defineProperty(this, 'writableLength', { get: () => n, configurable: true })
}
/** 让后续 `write()` 返回 false(= 水位满),但不丢数据。 */
blockWrites() {
this._blockWrites = true
this.emit('drain')
}
get writableLength() {
if (this._pendingOverride !== null) return this._pendingOverride
return super.writableLength
}
_write(chunk, _enc, cb) {
this.written.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
// 阻塞态:**不调 cb** ⇒ Node 认为缓冲未消化 ⇒ 后续 write() 返回 false(数据仍已排队)
if (!this._blockWrites) cb()
}
_read() {}
destroy(err) {
this.destroyedBySelf = true
return super.destroy(err)
}
/** 服务端写回的全部字节(拼起来)。 */
output() {
return Buffer.concat(this.written).toString('utf8')
}
}
/** 构造一个**带掩码**的客户端文本帧(RFC 6455 最小必要子集)。 */
function clientTextFrame(text) {
const payload = Buffer.from(text, 'utf8')
assert.ok(payload.length < 126, '本夹具只支持 <126 字节的载荷')
const mask = Buffer.from([1, 2, 3, 4])
const header = Buffer.from([0x81, 0x80 | payload.length])
const masked = Buffer.from(payload)
for (let i = 0; i < masked.length; i += 1) masked[i] ^= mask[i % 4]
return Buffer.concat([header, mask, masked])
}
/** 伪造一个 upgrade 请求(`ws.ts` 只读 url / headers)。 */
function fakeReq() {
return {
url: '/api/im/ws',
method: 'GET',
headers: {
upgrade: 'websocket',
connection: 'Upgrade',
'sec-websocket-key': 'dGhlIHNhbXBsZSBub25jZQ==',
'sec-websocket-version': '13',
cookie: 'dsh_session=fake',
},
}
}
/** 起一套 ws 运行时 + 一个已握手的假 socket(并让它订上 `r1`)。 */
function connected({ limit = 1024, member = true, socketOptions } = {}) {
const store = makeStore({ member })
const hub = makeHub(store)
const logs = []
const runtime = createImWsRuntime({
hub,
store,
authenticate: async () => ({ userId: 'u1' }),
sendBufferLimitBytes: limit,
log: {
info: (f, m) => logs.push({ level: 'info', f, m }),
warn: (f, m) => logs.push({ level: 'warn', f, m }),
},
})
const socket = new FakeSocket(socketOptions)
const handled = runtime.dispatch(fakeReq(), socket, Buffer.alloc(0))
return { store, hub, runtime, socket, logs, handled }
}
/* ── ① hub 背压计数 ─────────────────────────────────────────────────────── */
test('背压 hub 计数:noteBackpressure 累计 + 取峰值;noteBackpressureClose 单计;stats 带出新字段', () => {
const hub = makeHub(makeStore())
assert.equal(hub.stats().backpressureEvents, 0)
assert.equal(hub.stats().peakPendingBytes, 0)
assert.equal(hub.stats().closedByBackpressure, 0)
hub.noteBackpressure(100)
hub.noteBackpressure(5000)
hub.noteBackpressure(200) // 峰值不回落
assert.equal(hub.stats().backpressureEvents, 3)
assert.equal(hub.stats().peakPendingBytes, 5000)
// 非法读数**不入峰值**(⛔ 不让 NaN 污染观测面)
hub.noteBackpressure(Number.NaN)
assert.equal(hub.stats().peakPendingBytes, 5000)
assert.equal(hub.stats().backpressureEvents, 4)
hub.noteBackpressureClose()
assert.equal(hub.stats().closedByBackpressure, 1)
// 既有字段一个都没动(⛔ 不是替换而是新增)
assert.equal(hub.stats().closedByHeartbeat, 0)
assert.equal(hub.stats().rejectedByQuota, 0)
assert.equal(hub.stats().deliveries, 0)
})
test('背压 上限常量=4×单帧上限(取值有依据,⛔ 非拍脑袋)', () => {
assert.equal(IM_SEND_BUFFER_LIMIT_BYTES, 4 << 20)
})
/* ── ② 扇出语义未变(关键回归) ────────────────────────────────────────── */
test('背压 扇出语义**未变**:正常连接 delivered === onlineIn,慢连接也**不虚减** delivered', () => {
const hub = makeHub(makeStore())
const sent = []
const fast = {
id: 'c1',
userId: 'u1',
rooms: new Set(['r1']),
send: (t) => {
sent.push(t)
return true
},
close: () => {},
lastSeenAt: Date.now(),
openedAt: Date.now(),
}
const slow = {
id: 'c2',
userId: 'u2',
rooms: new Set(['r1']),
// 🔴 慢连接:write() 返回 false(水位满)—— 但**数据已排队、会送达** ⇒ 修复后仍算 delivered
send: (t) => {
sent.push(t)
return true
},
close: () => {},
lastSeenAt: Date.now(),
openedAt: Date.now(),
}
hub.attach(fast)
hub.attach(slow)
hub.subscribe(fast, 'r1', false)
hub.subscribe(slow, 'r1', false)
assert.equal(hub.onlineIn('r1'), 2)
const res = hub.deliverFrame('r1', '{"op":"message"}')
assert.equal(res.delivered, 2)
assert.equal(res.skipped, 0)
assert.equal(res.delivered, hub.onlineIn('r1'), '判据 3:delivered 必须等于在线订阅数')
assert.equal(sent.length, 2)
})
test('背压 真失败(连接已死)仍计入 skipped:⛔ 不与"慢"混为一谈', () => {
const hub = makeHub(makeStore())
const dead = {
id: 'c1',
userId: 'u1',
rooms: new Set(['r1']),
send: () => false, // 连接已死
close: () => {},
lastSeenAt: Date.now(),
openedAt: Date.now(),
}
hub.attach(dead)
hub.subscribe(dead, 'r1', false)
const res = hub.deliverFrame('r1', 'x')
assert.equal(res.delivered, 0)
assert.equal(res.skipped, 1)
})
/* ── ③④ ws 层:write()=false 记账不断开;水位超限 ⇒ 具名断开 ──────────── */
test('背压 ws 握手 + 订阅:dispatch 接管,101 已回,连接进了 hub', async () => {
const { handled, socket, hub, logs } = connected()
assert.equal(handled, true, 'dispatch 必须接管 /api/im/ws')
// ⚠️ 握手是异步的(`authenticate` 是 async)⇒ 必须等一拍再断言
await new Promise((r) => setTimeout(r, 10))
assert.match(socket.output(), /HTTP\/1\.1 101/)
assert.equal(hub.connections.size, 1)
assert.equal(logs.filter((l) => l.level === 'warn').length, 0)
})
test('背压 write() 返回 false ⇒ 记一次背压,但**仍算 delivered**(数据已排队,⛔ 不丢)', () => {
// highWaterMark = 1 ⇒ 首次 write 即返回 false(模拟水位满)
const { socket, hub } = connected({ limit: 8 << 20, socketOptions: { highWaterMark: 1 } })
// 订上 r1(发一帧 subscribe)
socket.push(clientTextFrame(JSON.stringify({ op: 'subscribe', roomId: 'r1' })))
return new Promise((resolve) => setTimeout(resolve, 10)).then(() => {
socket.blockWrites()
const res = hub.deliverFrame('r1', 'hello')
assert.equal(res.delivered, 1, 'write()=false 不等于丢帧 ⇒ 仍算 delivered')
assert.equal(res.skipped, 0)
assert.ok(hub.stats().backpressureEvents >= 1, '必须记下这次背压(供观测 / 调阈值)')
assert.ok(hub.stats().peakPendingBytes > 0)
// ⛔ 未断开
assert.equal(hub.stats().closedByBackpressure, 0)
assert.equal(socket.destroyedBySelf, false)
})
})
test('背压 🔴 水位超限 ⇒ **具名断开**(1013 send-buffer-overflow)+ 计数 + 落具名日志', async () => {
const { socket, hub, logs } = connected({ limit: 1024 })
socket.push(clientTextFrame(JSON.stringify({ op: 'subscribe', roomId: 'r1' })))
await new Promise((r) => setTimeout(r, 10))
assert.equal(hub.onlineIn('r1'), 1)
// 注入超限水位 ⇒ 下一次 send 必须**拒发并断开**
socket.setPending(1024 + 1)
const res = hub.deliverFrame('r1', 'x'.repeat(100))
assert.equal(res.delivered, 0, '超限 ⇒ 该帧不投递')
assert.equal(res.skipped, 1)
assert.equal(hub.stats().closedByBackpressure, 1)
assert.ok(hub.stats().backpressureEvents >= 1)
assert.equal(socket.destroyedBySelf, true, '连接必须真被销毁(防内存无界增长)')
// 断开帧具名(1013 = Try Again Later),且**日志点名原因**
assert.match(socket.output(), /1013|send-buffer-overflow/)
const warn = logs.find((l) => l.level === 'warn')
assert.ok(warn, '必须落一条 warn 日志')
assert.equal(warn.m, 'im_send_buffer_overflow')
assert.equal(warn.f.limit, 1024)
assert.equal(warn.f.pendingBytes, 1025)
})
test('背压 恰好等于上限 ⇒ **不断开**(判据是">limit",⛔ 不是"≥")', async () => {
const { socket, hub } = connected({ limit: 1024 })
socket.push(clientTextFrame(JSON.stringify({ op: 'subscribe', roomId: 'r1' })))
await new Promise((r) => setTimeout(r, 10))
socket.setPending(1024)
const res = hub.deliverFrame('r1', 'y')
assert.equal(res.delivered, 1)
assert.equal(hub.stats().closedByBackpressure, 0)
assert.equal(socket.destroyedBySelf, false)
})