287 lines
11 KiB
JavaScript
287 lines
11 KiB
JavaScript
// 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)
|
||
})
|