feat(overlay): 覆盖网络线序①–⑮ 代码与测试产物入库

覆盖网络线累积产物(此前只在工作区、未入版本库):
- 新增 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
This commit is contained in:
admin committed 2026-09-17 17:03:27 +08:00
1 parent 640813e84e
commit 146c3d25ef
53 files changed
+16187 -63

No files matched your search

+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env node
/**
* 覆盖网络 序⑥ · S6 `MEM_PER_HOST_MB` 校准(⛔ 一次性脚本、不进产品路径)。
*
* ## 为什么必须放大测
* 现网 `used = 2` ⇒ relay 的 RSS(72888 KB)**只反映 Node 基座**,参数表 §5.1 的
* "2 MB/台" 是**推导值不是实测**。本脚本把 N 拉到 2/10/25/50/100,量 RSS 斜率。
*
* ## 干净方案(⛔ 不污染生产)
* - **relay 起在本机回环**(独立进程、独立高层口号,与生产的 20080 无关);
* - **N 个合成 client 跑在"本脚本进程内"** —— 这是刻意的:只要被测量的对象是 **relay 子进程**
* 的 RSS,client 的内存长在本进程里 ⇒ **不进被测量**。(若让 client 也各自起进程,
* 机器上会多出 100 个 Node 基座 ≈ 4 GB,纯浪费。)
* - 全程 **127.0.0.1**,零公网面 ⇒ ⛔ 不动 47 的任何配置(S6 权限附注)。
*
* ## 判据
* 线性回归 `RSS(N) = a + b·N`,`R² ≥ 0.9` 方为有效;否则**如实报告跳点**,⛔ 不许硬套斜率。
*
* 用法:node scripts/relay-mem-calibrate.mjs [--base 19000] [--port 23456]
*
* @module scripts/relay-mem-calibrate
*/
import { spawn } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = join(HERE, '..')
function arg(name, dflt) {
const i = process.argv.indexOf(`--${name}`)
return i >= 0 ? process.argv[i + 1] : dflt
}
const PORT = Number(arg('port', 23456))
const BASE = Number(arg('base', 19000))
const SPAN = Number(arg('span', 3000))
const POINTS = (arg('points', '2,10,25,50,100')).split(',').map(Number)
const logs = []
const { RelayClient } = await import(pathToFileURL(join(REPO, 'lib/net/relay/client.js')).href)
const tmp = mkdtempSync(join(tmpdir(), 'relay-mem-'))
const keysFile = join(tmp, 'keys.json')
const keys = {}
const secrets = {}
for (let i = 1; i <= Math.max(...POINTS); i++) {
const sec = randomBytes(32).toString('hex')
secrets[i] = sec
keys[`ops/w-${i}`] = sec
}
writeFileSync(keysFile, JSON.stringify(keys, null, 2))
const relay = spawn(
process.execPath,
[
join(REPO, 'lib/net/relay/main.js'),
'--port',
String(PORT),
'--keys-file',
keysFile,
'--base',
String(BASE),
'--span',
String(SPAN),
'--max-hosts',
'0',
],
{ cwd: REPO, stdio: ['ignore', 'pipe', 'pipe'] },
)
relay.stdout.on('data', (d) => logs.push(String(d)))
relay.stderr.on('data', (d) => logs.push(String(d)))
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
async function status() {
const r = await fetch(`http://127.0.0.1:${PORT}/status`)
return r.json()
}
async function used() {
try {
return (await status()).capacity.used
} catch {
return -1
}
}
async function rssKbMedian(pid, times = 7, gapMs = 500) {
const arr = []
for (let i = 0; i < times; i++) {
arr.push(await rssKbOnce(pid))
await sleep(gapMs)
}
const s = arr.filter((x) => Number.isFinite(x)).sort((a, b) => a - b)
return { median: s[Math.floor(s.length / 2)], min: s[0], max: s[s.length - 1], n: s.length }
}
async function rssKbOnce(pid) {
if (process.platform === 'win32') {
const r = await new Promise((res) => {
const p = spawn('powershell', ['-NoProfile', '-Command', `(Get-Process -Id ${pid}).WorkingSet64`])
let o = ''
p.stdout.on('data', (d) => (o += d))
p.on('close', () => res(o.trim()))
})
return Math.round(Number(r) / 1024)
}
const r = await new Promise((res) => {
const p = spawn('sh', ['-c', `ps -o rss= -p ${pid}`])
let o = ''
p.stdout.on('data', (d) => (o += d))
p.on('close', () => res(o.trim()))
})
return Number(r)
}
const clients = []
const samples = []
const results = { platform: process.platform, node: process.version, points: [], raw: [] }
for (const target of POINTS) {
while (clients.length < target) {
const i = clients.length + 1
const c = new RelayClient({
url: `ws://127.0.0.1:${PORT}/dshs-relay`,
hostId: `w-${i}`,
networkId: 'ops',
secret: secrets[i],
ports: [BASE + i],
})
c.start()
clients.push(c)
}
let u = -1
for (let i = 0; i < 60; i++) {
u = await used()
if (u >= target) break
await sleep(500)
}
await sleep(1000)
const rss = await rssKbMedian(relay.pid)
samples.push({ n: clients.length, used: u, rssKb: rss.median, rssMin: rss.min, rssMax: rss.max, nSamples: rss.n })
process.stderr.write(`[mem] N=${clients.length} used=${u} RSS=${rss.median} KB (min ${rss.min} / max ${rss.max})\n`)
}
// 线性回归
const n = samples.length
const sx = samples.reduce((a, s) => a + s.n, 0)
const sy = samples.reduce((a, s) => a + s.rssKb, 0)
const sxx = samples.reduce((a, s) => a + s.n * s.n, 0)
const sxy = samples.reduce((a, s) => a + s.n * s.rssKb, 0)
const b = (n * sxy - sx * sy) / (n * sxx - sx * sx)
const a = (sy - b * sx) / n
const my = sy / n
const ssTot = samples.reduce((acc, s) => acc + (s.rssKb - my) ** 2, 0)
const ssRes = samples.reduce((acc, s) => acc + (s.rssKb - (a + b * s.n)) ** 2, 0)
results.points = samples
results.fit = {
interceptKb: Math.round(a),
slopeKbPerHost: Math.round(b * 10) / 10,
r2: Math.round((1 - ssRes / ssTot) * 10000) / 10000,
}
results.projected = {
memPerHostMbCeil20: Math.ceil((b / 1024) * 1.2 * 10) / 10,
at225HostsMb: Math.round((a + b * 225) / 1024),
}
const last = samples[samples.length - 1]
process.stdout.write(`### RESULT ### ${JSON.stringify({ ...results, lastUsed: last.used })}\n`)
for (const c of clients) c.stop()
relay.kill()
await sleep(500)
rmSync(tmp, { recursive: true, force: true })
process.exit(0)