背景:生产压测确认中继 >1MB 时吞吐崩塌(2MB 传 20s 只到 139KB)。
缺陷**本轮未修好**,如实记录尝试过的五条路都失败(详见 relay.js 注释与 runbook):
① 放大限速 128× → 仍慢;② 摘背压 → 仍慢;③ 背压改 once('drain')
→ 仍慢;④ 完全去掉限速 → 更差(139KB 卡死);⑤ 改 pipeline() → done()
依赖的 onDrain 被删导致 /relay/open 崩,已回滚。
已排除:容器内 nginx(另修 128KB 卡点)、body-parser(PUT 路由无)、
TCP/内核(容器内纯 HTTP 4MB 响应仅 497ms)。
因此在根因修好前收紧上限:
· MAX_ONCE_BYTES: 2 GiB → **1 MiB**(实测稳定边界:1MiB 337ms 完整,
2MiB 卡)。可用 RELAY_MAX_BYTES 覆盖,修好缺陷后调大即可、不必改代码。
· 报错文案改 **humanBytes() 自适应单位**(原来写死 "GiB",上限一变就错)。
文案里给出路:"较大的文件请用局域网直连"(LAN 直连不走中继、不受此限)。
· compose/.env.example 透传 RELAY_MAX_BYTES。
· relay-route-e2e 的 E_TOO_LARGE 断言**解耦单位**(匹配「上限」二字,
不再绑死 GiB —— 否则改上限就假红)。
现网验证:恰好 1MiB → 完整传输 337ms;1MiB+1 → 409 + 人话;2MiB → 409;
健康检查五项全过。
205 lines
7.5 KiB
JavaScript
205 lines
7.5 KiB
JavaScript
/**
|
||
* 中继路由端到端 —— 打**真网关的三条 HTTP 路由**(open / GET / PUT)
|
||
*
|
||
* 为什么单靠 relay-test.js 不够:那 30 项测的是 Relay 类,跑在进程内。
|
||
* 历史上有过一次真实事故:Relay 类全绿,但路由里把 `store.getDeviceToken`
|
||
* 写成了不存在的变量,被 catch 吞成 503,**中继永远开不出来**——
|
||
* 直到部署后 curl 一次 /relay/open 才发现。本套件把路由当被测对象。
|
||
*
|
||
* 覆盖:
|
||
* ① 开通:200 + id + 双令牌 + 限速声明
|
||
* ② 完整往返:GET 挂起 → PUT → 字节一致(不落盘由服务端测试保证)
|
||
* ③ 401:缺设备身份
|
||
* ④ 409 E_BAD_SIZE:size ≤ 0
|
||
* ⑤ 409 E_TOO_LARGE:超过单次上限(默认 1 MiB,RELAY_MAX_BYTES 可覆盖)
|
||
* ⑥ 409 E_TOO_MANY:每账号并发 1(第二条被拒)
|
||
* ⑦ 假 size 截断:声明多少只能传多少(多传的被丢弃)
|
||
* ⑧ 错向令牌:拿下载令牌走 PUT → 404(不区分原因,防爆破反馈)
|
||
*
|
||
* 前置:网关 @ :3000(compat 模式即可,devtoken 键不存在时放行)
|
||
* 运行:node tests/relay-route-e2e.js
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const http = require('http');
|
||
const crypto = require('crypto');
|
||
|
||
const GATEWAY = process.env.GATEWAY || 'http://127.0.0.1:3000';
|
||
const HOST = new URL(GATEWAY).hostname;
|
||
const PORT = Number(new URL(GATEWAY).port || 80);
|
||
|
||
let pass = 0;
|
||
let fail = 0;
|
||
|
||
function check(name, cond, extra = '') {
|
||
if (cond) {
|
||
pass++;
|
||
console.log(' \u2713 ' + name);
|
||
} else {
|
||
fail++;
|
||
console.log(' \u2717 ' + name + ' ' + extra);
|
||
}
|
||
}
|
||
|
||
function req(opts, body) {
|
||
return new Promise((resolve) => {
|
||
const r = http.request({ hostname: HOST, port: PORT, ...opts }, (res) => {
|
||
const chunks = [];
|
||
res.on('data', (c) => chunks.push(c));
|
||
res.on('end', () =>
|
||
resolve({ status: res.statusCode, body: Buffer.concat(chunks), headers: res.headers }));
|
||
});
|
||
r.on('error', (e) =>
|
||
resolve({ status: 0, error: e.message, body: Buffer.alloc(0), headers: {} }));
|
||
if (body) r.write(body);
|
||
r.end();
|
||
});
|
||
}
|
||
|
||
const uniq = (tag) => `relay-e2e-${tag}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||
|
||
function openRelay(deviceId, size, token = 'e2e-token') {
|
||
return req(
|
||
{
|
||
path: '/relay/open',
|
||
method: 'POST',
|
||
headers: {
|
||
'content-type': 'application/json',
|
||
'x-device-id': deviceId,
|
||
'x-device-token': token,
|
||
},
|
||
},
|
||
JSON.stringify({ size })
|
||
);
|
||
}
|
||
|
||
(async () => {
|
||
console.log('\n── ① 开通中继 ──');
|
||
const dev1 = uniq('open');
|
||
const opened = await openRelay(dev1, 1024);
|
||
let j = {};
|
||
try {
|
||
j = JSON.parse(opened.body.toString());
|
||
} catch (_) {}
|
||
check('open 200', opened.status === 200, `status=${opened.status} ${opened.body}`);
|
||
check('返回 id', typeof j.id === 'string' && j.id.length > 0);
|
||
check('双令牌各自独立', j.uploadToken && j.downloadToken && j.uploadToken !== j.downloadToken);
|
||
check('限速声明 8 MB/s', j.throttleBytesPerSec === 8 * 1024 * 1024, `bps=${j.throttleBytesPerSec}`);
|
||
|
||
console.log('\n── ② 完整往返(GET 挂起 → PUT → 字节一致)──');
|
||
{
|
||
const payload = crypto.randomBytes(1024);
|
||
const got = new Promise((resolve) => {
|
||
req({
|
||
path: `/relay/${j.id}?t=${encodeURIComponent(j.downloadToken)}`,
|
||
method: 'GET',
|
||
}).then(resolve);
|
||
});
|
||
await new Promise((r) => setTimeout(r, 120));
|
||
const put = await req(
|
||
{
|
||
path: `/relay/${j.id}?t=${encodeURIComponent(j.uploadToken)}`,
|
||
method: 'PUT',
|
||
headers: { 'content-type': 'application/octet-stream', 'content-length': payload.length },
|
||
},
|
||
payload
|
||
);
|
||
const down = await got;
|
||
check('PUT 200', put.status === 200, `status=${put.status}`);
|
||
check('GET 200 且字节一致', down.status === 200 && down.body.equals(payload),
|
||
`status=${down.status} bytes=${down.body.length}`);
|
||
}
|
||
|
||
console.log('\n── ③ 401 缺设备身份 ──');
|
||
{
|
||
const r = await req(
|
||
{ path: '/relay/open', method: 'POST', headers: { 'content-type': 'application/json' } },
|
||
JSON.stringify({ size: 10 })
|
||
);
|
||
check('缺头 → 401', r.status === 401, `status=${r.status}`);
|
||
}
|
||
|
||
console.log('\n── ④ E_BAD_SIZE ──');
|
||
{
|
||
const r = await openRelay(uniq('bad'), 0);
|
||
const b = (() => { try { return JSON.parse(r.body.toString()); } catch (_) { return {}; } })();
|
||
check('size=0 → 409 E_BAD_SIZE', r.status === 409 && b.code === 'E_BAD_SIZE',
|
||
`status=${r.status} code=${b.code}`);
|
||
}
|
||
|
||
console.log('\n── ⑤ E_TOO_LARGE ──');
|
||
{
|
||
const r = await openRelay(uniq('big'), 3 * 1024 * 1024 * 1024);
|
||
const b = (() => { try { return JSON.parse(r.body.toString()); } catch (_) { return {}; } })();
|
||
// 断言不绑定单位:上限是可配置的(RELAY_MAX_BYTES),文案单位会随值变
|
||
// (MiB/GiB)。绑死 "GiB" 会在改了上限之后变成假红。
|
||
check('3 GiB → 409 E_TOO_LARGE(原因含「上限」)',
|
||
r.status === 409 && b.code === 'E_TOO_LARGE' && /上限/.test(b.reason || ''),
|
||
`status=${r.status} code=${b.code}`);
|
||
}
|
||
|
||
console.log('\n── ⑥ E_TOO_MANY(每账号并发 1)──');
|
||
{
|
||
const dev = uniq('conc');
|
||
const a = await openRelay(dev, 10);
|
||
const b = await openRelay(dev, 10);
|
||
const bj = (() => { try { return JSON.parse(b.body.toString()); } catch (_) { return {}; } })();
|
||
check('第一条 200', a.status === 200, `status=${a.status}`);
|
||
check('第二条 409 E_TOO_MANY', b.status === 409 && bj.code === 'E_TOO_MANY',
|
||
`status=${b.status} code=${bj.code}`);
|
||
}
|
||
|
||
console.log('\n── ⑦ 假 size 截断(声明 100 却传 200 → 断连,且不超声明)──');
|
||
{
|
||
const dev = uniq('cut');
|
||
const o = await openRelay(dev, 100);
|
||
const oj = JSON.parse(o.body.toString());
|
||
const got = new Promise((resolve) => {
|
||
req({ path: `/relay/${oj.id}?t=${encodeURIComponent(oj.downloadToken)}`, method: 'GET' })
|
||
.then(resolve);
|
||
});
|
||
await new Promise((r) => setTimeout(r, 120));
|
||
const fat = crypto.randomBytes(200);
|
||
const put = await req(
|
||
{
|
||
path: `/relay/${oj.id}?t=${encodeURIComponent(oj.uploadToken)}`,
|
||
method: 'PUT',
|
||
headers: { 'content-type': 'application/octet-stream', 'content-length': fat.length },
|
||
},
|
||
fat
|
||
);
|
||
const down = await got;
|
||
// 服务端实现是 `req.destroy(E_OVER_LIMIT)` —— 超声明即断连。
|
||
// 安全检查的是「不超过声明」,不是「上传成功」。
|
||
check('超声明上传被断连(PUT 非 200)', put.status !== 200,
|
||
`status=${put.status}`);
|
||
check('接收端字节不超过声明(≤100)', down.body.length <= 100,
|
||
`bytes=${down.body.length}`);
|
||
}
|
||
|
||
console.log('\n── ⑧ 错向令牌 → 404 ──');
|
||
{
|
||
const dev = uniq('swap');
|
||
const o = await openRelay(dev, 10);
|
||
const oj = JSON.parse(o.body.toString());
|
||
const r = await req(
|
||
{
|
||
path: `/relay/${oj.id}?t=${encodeURIComponent(oj.downloadToken)}`,
|
||
method: 'PUT',
|
||
headers: { 'content-type': 'application/octet-stream', 'content-length': 10 },
|
||
},
|
||
Buffer.alloc(10)
|
||
);
|
||
check('下载令牌走 PUT → 404', r.status === 404, `status=${r.status}`);
|
||
}
|
||
|
||
console.log('\n========================================');
|
||
console.log(' 结果: ' + pass + ' 通过 / ' + fail + ' 失败');
|
||
console.log('========================================');
|
||
process.exit(fail ? 1 : 0);
|
||
})().catch((e) => {
|
||
console.error('测试异常:', e);
|
||
process.exit(1);
|
||
});
|