chore: 文档库全量重新入库(2026-10-20)
build / build-and-scan (push) Canceled after 0s

This commit is contained in:
admin committed 2026-10-20 15:05:55 +08:00
commit 5ec99ce227
668 files changed
+164448

No files matched your search

+286
View File
@@ -0,0 +1,286 @@
// 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)
})