覆盖网络线累积产物(此前只在工作区、未入版本库):
- 新增 relay 子系统 src/net/relay/**(wire/duplex/server/client/dialer/switcher/directory/identity/keys/placement/network/addr-override/main/index)
- 新增 src/worker/relay-tunnel.ts、src/web/routes/overlay.ts
- 新增观测/演练脚本 overlay-probe、overlay-failover-drill、overlay-keyring、overlay-holepunch、overlay-jitter、overlay-wan、overlay-relaykey-add、relay-mem-calibrate
- 新增测试 12 个(relay / relay-failover / remote-spawner / instance-port / overlay-{network,auth,bootstrap,identity} / remote-user-fs 等)
验收基线:npm test = 162 pass / 0 fail / 1 skip;--scene all = 12 PASS / 0 SKIP / 0 FAIL;overlay-probe = 12/12
358 lines
11 KiB
JavaScript
358 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 覆盖网络 序⑥ · S2/S5 一次性载荷与探针(⛔ 不进产品路径、⛔ 不进 package.json 依赖)。
|
||
*
|
||
* 为什么要有它:参数表 §3.5 的 `WAN_STEADY_THROUGHPUT` 与 §3.3 的 `PER_PLAYER_BW_LOCAL`
|
||
* 都是 `待测` —— 两端都拿不到"可读大响应"(无凭据时只回 24–68 B 的 401/404)。
|
||
* 本脚本只做一件事:**造可控载荷 + 量稳态速率**,供人工写回参数表。
|
||
*
|
||
* ## 四种角色
|
||
* | 角色 | 跑在哪 | 干什么 |
|
||
* |---|---|---|
|
||
* | `--serve` | 被压的一端(106) | HTTP:`/blob?sec=N`(连续吐 N 秒)/`/blob?mb=N`/`POST /sink`(吞体) |
|
||
* | `--download` | 挤压的一端(47) | 连 relay 回环端点 → 丢前 `--drop` 秒 → 量 `--secs` 秒 ⇒ **被压端 → 挤压端** |
|
||
* | `--upload` | 挤压的一端(47) | 反向 POST 大流量(**尊重反压**)⇒ **挤压端 → 被压端** |
|
||
* | `--echo-serve` / `--players` | 106 / 47 | S5 合成玩家:长度前缀回显 + 多连接消息率扫描 |
|
||
*
|
||
* ## 口径(⛔ 别丢,写回参数表时要抄)
|
||
* - 速率 = **稳态段字节数 ÷ 稳态段秒数**(前 `--drop` 秒的字节**单独计数、不进分子**);
|
||
* - `--upload` 的速率**必须**在 `write()` 返回 false 时停下等 `drain` —— 否则量的是 Node 的
|
||
* 内存缓冲,不是链路(这正是"看起来很快、其实全在本地队列里"这个假象的成因)。
|
||
*
|
||
* 用法示例(远端):
|
||
* node overlay-wan.cjs --serve --port 19777 --secs 600
|
||
* node overlay-wan.cjs --download --port <relay回环口号> --secs 30 --drop 3
|
||
* node overlay-wan.cjs --upload --port <relay回环口号> --secs 30 --drop 3 --mb 0
|
||
*
|
||
* @module scripts/overlay-wan
|
||
*/
|
||
|
||
'use strict'
|
||
|
||
const http = require('node:http')
|
||
const net = require('node:net')
|
||
|
||
function parseArgs(argv) {
|
||
const out = {}
|
||
for (let i = 0; i < argv.length; i++) {
|
||
const a = argv[i]
|
||
if (!a.startsWith('--')) continue
|
||
const k = a.slice(2)
|
||
const v = argv[i + 1]
|
||
if (v === undefined || v.startsWith('--')) out[k] = true
|
||
else {
|
||
out[k] = isNaN(Number(v)) ? v : Number(v)
|
||
i++
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
const args = parseArgs(process.argv.slice(2))
|
||
const log = (s) => process.stderr.write(`[wan] ${s}\n`)
|
||
const out = (obj) => process.stdout.write(`### RESULT ### ${JSON.stringify(obj)}\n`)
|
||
|
||
const CHUNK = Buffer.alloc(64 * 1024, 0x41)
|
||
|
||
// ─────────────────────────── serve ───────────────────────────
|
||
function serve(port, secs) {
|
||
const server = http.createServer((req, res) => {
|
||
const u = new URL(req.url, 'http://x')
|
||
if (req.method === 'POST') {
|
||
// 吞体:只数不清
|
||
let n = 0
|
||
req.on('data', (c) => {
|
||
n += c.length
|
||
})
|
||
req.on('end', () => {
|
||
res.writeHead(200, { 'content-type': 'text/plain', connection: 'close' })
|
||
res.end(`sink ${n}\n`)
|
||
log(`POST /sink ${n} B in ${Date.now() - t0} ms`)
|
||
})
|
||
var t0 = Date.now()
|
||
return
|
||
}
|
||
if (u.pathname === '/small') {
|
||
res.writeHead(200, { 'content-type': 'text/plain', connection: 'close' })
|
||
res.end('ok-0123456789-0123456789\n')
|
||
return
|
||
}
|
||
const sec = Number(u.searchParams.get('sec') || 0)
|
||
const mb = Number(u.searchParams.get('mb') || 0)
|
||
if (sec > 0) {
|
||
res.writeHead(200, { 'content-type': 'application/octet-stream', connection: 'close' })
|
||
const start = Date.now()
|
||
let sent = 0
|
||
const pump = () => {
|
||
while (Date.now() - start < sec * 1000) {
|
||
if (!res.write(CHUNK)) {
|
||
sent += CHUNK.length
|
||
res.once('drain', pump)
|
||
return
|
||
}
|
||
sent += CHUNK.length
|
||
}
|
||
res.end()
|
||
log(`GET /blob?sec=${sec} 送出 ${sent} B`)
|
||
}
|
||
pump()
|
||
return
|
||
}
|
||
res.writeHead(200, { 'content-type': 'application/octet-stream', connection: 'close' })
|
||
const total = mb * 1024 * 1024
|
||
let sent = 0
|
||
const pump = () => {
|
||
while (sent < total) {
|
||
if (!res.write(CHUNK)) {
|
||
sent += CHUNK.length
|
||
res.once('drain', pump)
|
||
return
|
||
}
|
||
sent += CHUNK.length
|
||
}
|
||
res.end()
|
||
}
|
||
pump()
|
||
})
|
||
server.listen(port, '127.0.0.1', () => log(`serve 127.0.0.1:${port} secs=${secs}`))
|
||
if (secs > 0) setTimeout(() => process.exit(0), secs * 1000)
|
||
}
|
||
|
||
// ─────────────────────── download / upload ───────────────────────
|
||
/** 从 `buf` 里切掉 HTTP 头,返回剩下的体;找不到头返回 null。 */
|
||
function splitHeader(buf) {
|
||
const i = buf.indexOf('\r\n\r\n')
|
||
if (i < 0) return null
|
||
return buf.subarray(i + 4)
|
||
}
|
||
|
||
function download(port, secs, drop) {
|
||
const t0 = Date.now()
|
||
const sock = net.connect(port, '127.0.0.1')
|
||
let hdrDone = false
|
||
let tail = Buffer.alloc(0)
|
||
let ramp = 0
|
||
let steady = 0
|
||
sock.on('connect', () => {
|
||
sock.write(
|
||
`GET /blob?sec=${Math.ceil(secs + drop + 6)} HTTP/1.1\r\nHost: wan\r\nConnection: close\r\n\r\n`,
|
||
)
|
||
})
|
||
const finish = () => {
|
||
try {
|
||
sock.destroy()
|
||
} catch {
|
||
/* noop */
|
||
}
|
||
out({
|
||
mode: 'download',
|
||
port,
|
||
dropS: drop,
|
||
steadyS: secs,
|
||
rampBytes: ramp,
|
||
steadyBytes: steady,
|
||
steadyKBps: Math.round((steady / 1024 / secs) * 10) / 10,
|
||
ms: Date.now() - t0,
|
||
})
|
||
process.exit(0)
|
||
}
|
||
sock.on('data', (b) => {
|
||
if (!hdrDone) {
|
||
tail = Buffer.concat([tail, b])
|
||
const body = splitHeader(tail)
|
||
if (body === null) return
|
||
hdrDone = true
|
||
b = body
|
||
}
|
||
const el = Date.now() - t0
|
||
if (el < drop * 1000) ramp += b.length
|
||
else if (el < (drop + secs) * 1000) steady += b.length
|
||
else finish()
|
||
})
|
||
sock.on('error', (e) => {
|
||
log(`sock error ${e.message}`)
|
||
finish()
|
||
})
|
||
setTimeout(finish, (drop + secs + 20) * 1000)
|
||
}
|
||
|
||
function upload(port, secs, drop) {
|
||
const t0 = Date.now()
|
||
const sock = net.connect(port, '127.0.0.1')
|
||
let ramp = 0
|
||
let steady = 0
|
||
let acc = 0
|
||
let started = false
|
||
const head =
|
||
'POST /sink HTTP/1.1\r\nHost: wan\r\nContent-Type: application/octet-stream\r\n' +
|
||
'Transfer-Encoding: chunked\r\nConnection: close\r\n\r\n'
|
||
const frame = Buffer.concat([
|
||
Buffer.from((CHUNK.length).toString(16) + '\r\n'),
|
||
CHUNK,
|
||
Buffer.from('\r\n'),
|
||
])
|
||
const finish = () => {
|
||
try {
|
||
sock.destroy()
|
||
} catch {
|
||
/* noop */
|
||
}
|
||
out({
|
||
mode: 'upload',
|
||
port,
|
||
dropS: drop,
|
||
steadyS: secs,
|
||
rampBytes: ramp,
|
||
steadyBytes: steady,
|
||
steadyKBps: Math.round((steady / 1024 / secs) * 10) / 10,
|
||
ms: Date.now() - t0,
|
||
})
|
||
process.exit(0)
|
||
}
|
||
const pump = () => {
|
||
const el = Date.now() - t0
|
||
if (el >= (drop + secs) * 1000) return finish()
|
||
let ok = true
|
||
while (ok) {
|
||
ok = sock.write(frame)
|
||
if (el < drop * 1000) ramp += CHUNK.length
|
||
else steady += CHUNK.length
|
||
if (Date.now() - t0 >= (drop + secs) * 1000) break
|
||
}
|
||
if (!ok) sock.once('drain', pump)
|
||
else setImmediate(pump)
|
||
}
|
||
sock.on('connect', () => {
|
||
sock.write(head)
|
||
started = true
|
||
pump()
|
||
})
|
||
sock.on('error', (e) => {
|
||
log(`sock error ${e.message}`)
|
||
finish()
|
||
})
|
||
setTimeout(finish, (drop + secs + 30) * 1000)
|
||
}
|
||
|
||
// ─────────────────────── S5:合成玩家 ───────────────────────
|
||
function echoServe(port) {
|
||
const server = net.createServer((c) => {
|
||
c.setNoDelay(true)
|
||
let buf = Buffer.alloc(0)
|
||
c.on('data', (d) => {
|
||
buf = Buffer.concat([buf, d])
|
||
for (;;) {
|
||
if (buf.length < 4) return
|
||
const n = buf.readUInt32BE(0)
|
||
if (buf.length < 4 + n) return
|
||
const payload = buf.subarray(4, 4 + n)
|
||
buf = buf.subarray(4 + n)
|
||
c.write(payload)
|
||
}
|
||
})
|
||
c.on('error', () => c.destroy())
|
||
})
|
||
server.listen(port, '127.0.0.1', () => log(`echo-serve 127.0.0.1:${port}`))
|
||
}
|
||
|
||
function p95(arr) {
|
||
if (arr.length === 0) return 0
|
||
const s = [...arr].sort((a, b) => a - b)
|
||
return s[Math.min(s.length - 1, Math.floor(s.length * 0.95))]
|
||
}
|
||
|
||
function playOne(port, rate, secs, msgBytes, done) {
|
||
const sentAt = []
|
||
const rtts = []
|
||
let sent = 0
|
||
let recv = 0
|
||
const sock = net.connect(port, '127.0.0.1')
|
||
const frame = Buffer.alloc(4 + msgBytes, 0x42)
|
||
frame.writeUInt32BE(msgBytes, 0)
|
||
let buf = Buffer.alloc(0)
|
||
let stopped = false
|
||
const t0 = Date.now()
|
||
const report = () => {
|
||
if (stopped) return
|
||
stopped = true
|
||
const s = [...rtts].sort((a, b) => a - b)
|
||
const half = rtts.map((x) => x / 2).sort((a, b) => a - b)
|
||
done({
|
||
sent,
|
||
recv,
|
||
lossPct: sent === 0 ? 0 : Math.round(((sent - recv) / sent) * 10000) / 100,
|
||
rttP50: Math.round((s[Math.floor(s.length * 0.5)] || 0) * 10) / 10,
|
||
rttP95: Math.round(p95(rtts) * 10) / 10,
|
||
oneWayP50: Math.round((half[Math.floor(half.length * 0.5)] || 0) * 10) / 10,
|
||
oneWayP95: Math.round(p95(half) * 10) / 10,
|
||
})
|
||
}
|
||
sock.on('connect', () => {
|
||
sock.setNoDelay(true)
|
||
const timer = setInterval(() => {
|
||
if (Date.now() - t0 > secs * 1000) {
|
||
clearInterval(timer)
|
||
sock.end()
|
||
report()
|
||
return
|
||
}
|
||
sentAt.push(Date.now())
|
||
sock.write(frame)
|
||
sent++
|
||
}, Math.max(1, Math.round(1000 / rate)))
|
||
})
|
||
sock.on('data', (d) => {
|
||
buf = Buffer.concat([buf, d])
|
||
while (buf.length >= msgBytes) {
|
||
buf = buf.subarray(msgBytes)
|
||
const t = sentAt[recv]
|
||
if (t !== undefined) rtts.push(Date.now() - t)
|
||
recv++
|
||
}
|
||
})
|
||
sock.on('error', (e) => log(`player err ${e.message}`))
|
||
sock.on('close', report)
|
||
}
|
||
|
||
function players(port, n, rate, secs, msgBytes) {
|
||
const results = []
|
||
let left = n
|
||
for (let i = 0; i < n; i++) {
|
||
setTimeout(() => {
|
||
playOne(port, rate, secs, msgBytes, (r) => {
|
||
results.push(r)
|
||
if (--left === 0) {
|
||
const agg = (k) => {
|
||
const a = results.map((x) => x[k]).sort((x, y) => x - y)
|
||
return a[Math.floor(a.length / 2)]
|
||
}
|
||
out({
|
||
mode: 'players',
|
||
players: n,
|
||
rate,
|
||
msgBytes,
|
||
secs,
|
||
perPlayerKBps: Math.round(((rate * msgBytes) / 1024) * 100) / 100,
|
||
aggThroughputKBps: Math.round(((rate * msgBytes * n) / 1024) * 10) / 10,
|
||
lossPctMax: Math.max(...results.map((r) => r.lossPct)),
|
||
oneWayP50: agg('oneWayP50'),
|
||
oneWayP95: agg('oneWayP95'),
|
||
})
|
||
process.exit(0)
|
||
}
|
||
})
|
||
}, i * 60)
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────── main ───────────────────────────
|
||
if (args.serve) serve(args.port, args.secs || 0)
|
||
else if (args['echo-serve']) echoServe(args.port)
|
||
else if (args.download) download(args.port, args.secs, args.drop)
|
||
else if (args.upload) upload(args.port, args.secs, args.drop)
|
||
else if (args.players) players(args.port, args.players, args.rate, args.secs, args['msg-bytes'] || 200)
|
||
else {
|
||
process.stderr.write('usage: --serve --port P [--secs N] | --download|--upload --port P --secs S --drop D | --echo-serve --port P | --players --port P --players N --rate R --secs S\n')
|
||
process.exit(2)
|
||
}
|