187 lines
7.8 KiB
JavaScript
187 lines
7.8 KiB
JavaScript
// Group frames against the transport's real rate limit.
|
|||
|
|
//
|
||
|
|
// This is the test that was missing when group formation kept dying. The manager
|
||
|
|
// allows a burst of ten sends per second, but one group frame spends TWO of those
|
||
|
|
// slots — sendMessage checks the shared limiter and then sendSecureMessage checks
|
||
|
|
// it again — so the six frames formation sends back to back asked for twelve.
|
||
|
|
// The overflow was rejected as a plain Error with no code, which reached the user
|
||
|
|
// as a meaningless "frame_rejected", while the peer that never got the dropped
|
||
|
|
// frame waited until "ceremony_timed_out".
|
||
|
|
//
|
||
|
|
// The stand-in manager below reproduces that accounting exactly. Time is
|
||
|
|
// injected, so the pacing is asserted rather than waited for.
|
||
|
|
|
||
|
|
import assert from 'node:assert/strict';
|
||
|
|
|
||
|
|
const { createGroupSender, GROUP_SEND_GAP_MS } = await import('../src/group/groupSender.js');
|
||
|
|
|
||
|
|
/** A virtual clock: sleeps advance it instead of blocking. */
|
||
|
|
function makeClock() {
|
||
|
|
let t = 0;
|
||
|
|
return {
|
||
|
|
now: () => t,
|
||
|
|
sleep: async (ms) => { t += Math.max(0, ms); },
|
||
|
|
advance: (ms) => { t += ms; },
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A manager with the real limiter's shape: ten slots per rolling second, and two
|
||
|
|
* slots consumed per outbound message.
|
||
|
|
*/
|
||
|
|
function makeManager(clock, { burst = 10, slotsPerSend = 2, connected = true } = {}) {
|
||
|
|
let windowStart = clock.now();
|
||
|
|
let used = 0;
|
||
|
|
const sent = [];
|
||
|
|
return {
|
||
|
|
sent,
|
||
|
|
isConnected: () => connected,
|
||
|
|
async sendMessage(payload) {
|
||
|
|
if (clock.now() - windowStart >= 1000) { windowStart = clock.now(); used = 0; }
|
||
|
|
if (used + slotsPerSend > burst) {
|
||
|
|
// Verbatim from EnhancedSecureWebRTCManager: a plain Error, no code.
|
||
|
|
throw new Error('Rate limit exceeded for message sending');
|
||
|
|
}
|
||
|
|
used += slotsPerSend;
|
||
|
|
sent.push({ at: clock.now(), payload });
|
||
|
|
return true;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// the six frames of group formation all arrive
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
{
|
||
|
|
const clock = makeClock();
|
||
|
|
const manager = makeManager(clock);
|
||
|
|
const send = createGroupSender({
|
||
|
|
getManager: () => manager, now: clock.now, sleep: clock.sleep,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Exactly what forming a group puts on one session.
|
||
|
|
const frames = ['g_invite', 'g_member', 'g_member', 'g_roster', 'g_commit', 'g_reveal']
|
||
|
|
.map((type, i) => ({ type, gid: 'a'.repeat(32), seq: i }));
|
||
|
|
|
||
|
|
await Promise.all(frames.map((f) => send('s1', f)));
|
||
|
|
|
||
|
|
assert.equal(manager.sent.length, 6, 'every formation frame must reach the transport');
|
||
|
|
|
||
|
|
// Order is protocol-critical: a commitment has to arrive before the reveal
|
||
|
|
// that opens it. Concurrent sends must not reorder.
|
||
|
|
assert.deepEqual(
|
||
|
|
manager.sent.map((s) => JSON.parse(s.payload).type),
|
||
|
|
['g_invite', 'g_member', 'g_member', 'g_roster', 'g_commit', 'g_reveal'],
|
||
|
|
'frames keep the order they were queued in',
|
||
|
|
);
|
||
|
|
|
||
|
|
// And they are spaced, which is what keeps them inside the burst budget.
|
||
|
|
for (let i = 1; i < manager.sent.length; i++) {
|
||
|
|
const gap = manager.sent[i].at - manager.sent[i - 1].at;
|
||
|
|
assert.ok(gap >= GROUP_SEND_GAP_MS,
|
||
|
|
`frame ${i} came ${gap}ms after the last, under the ${GROUP_SEND_GAP_MS}ms budget`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// without pacing the same run fails — the bug this exists to prevent
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
{
|
||
|
|
const clock = makeClock();
|
||
|
|
const manager = makeManager(clock);
|
||
|
|
const send = createGroupSender({
|
||
|
|
getManager: () => manager, now: clock.now, sleep: clock.sleep,
|
||
|
|
gapMs: 0, attempts: 1, // pacing and retries disabled
|
||
|
|
});
|
||
|
|
|
||
|
|
const results = await Promise.allSettled(
|
||
|
|
Array.from({ length: 6 }, (_, i) => send('s1', { type: 'g_commit', seq: i })),
|
||
|
|
);
|
||
|
|
const rejected = results.filter((r) => r.status === 'rejected');
|
||
|
|
assert.ok(rejected.length > 0, 'unpaced, the burst limit really does reject frames');
|
||
|
|
assert.match(rejected[0].reason.message, /Rate limit exceeded/);
|
||
|
|
// And the rejection carries no `code`, which is why it surfaced as the
|
||
|
|
// generic frame_rejected rather than anything a user could act on.
|
||
|
|
assert.equal(rejected[0].reason.code, undefined);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// a rate-limited frame is retried, not dropped
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
{
|
||
|
|
const clock = makeClock();
|
||
|
|
let calls = 0;
|
||
|
|
const manager = {
|
||
|
|
isConnected: () => true,
|
||
|
|
async sendMessage() {
|
||
|
|
calls++;
|
||
|
|
if (calls <= 2) throw new Error('Rate limit exceeded for secure message sending');
|
||
|
|
return true;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const send = createGroupSender({ getManager: () => manager, now: clock.now, sleep: clock.sleep });
|
||
|
|
|
||
|
|
await send('s1', { type: 'g_commit' });
|
||
|
|
assert.equal(calls, 3, 'the frame is retried until it lands — losing one strands every member');
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// failures that will not improve are surfaced, not retried
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
{
|
||
|
|
const clock = makeClock();
|
||
|
|
let calls = 0;
|
||
|
|
const manager = {
|
||
|
|
isConnected: () => true,
|
||
|
|
async sendMessage() { calls++; throw new Error('Data channel not ready'); },
|
||
|
|
};
|
||
|
|
const send = createGroupSender({ getManager: () => manager, now: clock.now, sleep: clock.sleep });
|
||
|
|
|
||
|
|
await assert.rejects(() => send('s1', { type: 'g_commit' }), /Data channel not ready/);
|
||
|
|
assert.equal(calls, 1, 'a closed channel is reported at once rather than retried');
|
||
|
|
|
||
|
|
// One failure must not wedge the session: later frames still go out.
|
||
|
|
const ok = { isConnected: () => true, sent: 0, async sendMessage() { this.sent++; return true; } };
|
||
|
|
const send2 = createGroupSender({
|
||
|
|
getManager: (id) => (id === 'dead' ? manager : ok), now: clock.now, sleep: clock.sleep,
|
||
|
|
});
|
||
|
|
await assert.rejects(() => send2('dead', { type: 'g_commit' }));
|
||
|
|
await send2('dead', { type: 'g_reveal' }).catch(() => {});
|
||
|
|
await send2('live', { type: 'g_reveal' });
|
||
|
|
assert.equal(ok.sent, 1, 'a rejected frame does not block the queue behind it');
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// a link that is gone is refused before anything is queued
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
{
|
||
|
|
const clock = makeClock();
|
||
|
|
const send = createGroupSender({ getManager: () => null, now: clock.now, sleep: clock.sleep });
|
||
|
|
await assert.rejects(() => send('s1', { type: 'g_commit' }), /no such link/);
|
||
|
|
|
||
|
|
const offline = createGroupSender({
|
||
|
|
getManager: () => ({ isConnected: () => false, sendMessage: async () => true }),
|
||
|
|
now: clock.now, sleep: clock.sleep,
|
||
|
|
});
|
||
|
|
await assert.rejects(() => offline('s1', { type: 'g_commit' }), /link is down/);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// separate sessions do not queue behind each other
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
{
|
||
|
|
const clock = makeClock();
|
||
|
|
const a = makeManager(clock);
|
||
|
|
const b = makeManager(clock);
|
||
|
|
const send = createGroupSender({
|
||
|
|
getManager: (id) => (id === 'a' ? a : b), now: clock.now, sleep: clock.sleep,
|
||
|
|
});
|
||
|
|
|
||
|
|
await Promise.all([send('a', { type: 'g_commit' }), send('b', { type: 'g_commit' })]);
|
||
|
|
assert.equal(a.sent.length, 1);
|
||
|
|
assert.equal(b.sent.length, 1);
|
||
|
|
assert.equal(a.sent[0].at, b.sent[0].at, 'each session paces independently');
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log('group-sender.test.mjs: all assertions passed');
|