refactor(net): isolate transport and wire lifecycle
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
WireClient,
|
||||
type WireEvent,
|
||||
} from '../../YouleNexus/assets/framework/net/wire-client.ts';
|
||||
import type { OutboundEnvelope } from '../../YouleNexus/assets/framework/core/types/envelope.ts';
|
||||
import type { Clock } from '../../YouleNexus/assets/framework/net/heartbeat.ts';
|
||||
import { FakeTransport } from '../helpers/fake-transport.ts';
|
||||
|
||||
function fakeClock() {
|
||||
let sequence = 0;
|
||||
let now = 0;
|
||||
const timers = new Map<number, { readonly fn: () => void; readonly at: number }>();
|
||||
const clock: Clock = {
|
||||
setTimeout(fn, ms) {
|
||||
const id = ++sequence;
|
||||
timers.set(id, { fn, at: now + ms });
|
||||
return id;
|
||||
},
|
||||
clearTimeout(handle) {
|
||||
timers.delete(handle as number);
|
||||
},
|
||||
};
|
||||
const advance = (ms: number) => {
|
||||
now += ms;
|
||||
while (true) {
|
||||
const due = [...timers.entries()]
|
||||
.filter(([, timer]) => timer.at <= now)
|
||||
.sort(([leftId, left], [rightId, right]) => left.at - right.at || leftId - rightId)[0];
|
||||
if (!due) return;
|
||||
timers.delete(due[0]);
|
||||
due[1].fn();
|
||||
}
|
||||
};
|
||||
return { clock, advance, activeTimers: () => timers.size };
|
||||
}
|
||||
|
||||
function makeHarness(servers: readonly string[] = ['ws://a']) {
|
||||
const transports: FakeTransport[] = [];
|
||||
const timer = fakeClock();
|
||||
const client = new WireClient({
|
||||
servers,
|
||||
clock: timer.clock,
|
||||
transportFactory: () => {
|
||||
const transport = new FakeTransport();
|
||||
transports.push(transport);
|
||||
return transport;
|
||||
},
|
||||
});
|
||||
return { client, transports, ...timer };
|
||||
}
|
||||
|
||||
const envelope: OutboundEnvelope = {
|
||||
app: 'youle',
|
||||
route: 'agent',
|
||||
rpc: 'player_get_roomlist',
|
||||
data: { gameid: 101 },
|
||||
};
|
||||
|
||||
test('start emits the opened server and never sends login automatically', async () => {
|
||||
const { client, transports } = makeHarness();
|
||||
const events: WireEvent[] = [];
|
||||
client.subscribe((event) => events.push(event));
|
||||
|
||||
client.start();
|
||||
await transports[0]!.flush();
|
||||
|
||||
assert.deepEqual(events, [{ type: 'open', server: 'ws://a' }]);
|
||||
assert.equal(transports[0]!.sent.length, 0, 'open 不应由 WireClient 自动发 login');
|
||||
});
|
||||
|
||||
test('all decoded business RPCs are emitted exactly once without interception', async () => {
|
||||
const { client, transports } = makeHarness();
|
||||
const events: WireEvent[] = [];
|
||||
client.subscribe((event) => events.push(event));
|
||||
client.start();
|
||||
const transport = transports[0]!;
|
||||
await transport.flush();
|
||||
|
||||
const messages = [
|
||||
{ route: 'agent', rpc: 'player_login', data: { state: 0 } },
|
||||
{ route: 'agent', rpc: 'kick_server', data: { msg: 'bye' } },
|
||||
{ route: 'room', rpc: 'connect_roomserver', data: { roomserver: 'ws://room' } },
|
||||
{ route: 'agent', rpc: 'connect_agentserver', data: { agentserver: 'ws://agent', opt: 'free_room' } },
|
||||
];
|
||||
for (const message of messages) transport.serverPush(JSON.stringify(message));
|
||||
await transport.flush();
|
||||
|
||||
assert.deepEqual(events.slice(1), messages.map((message) => ({ type: 'message', message })));
|
||||
assert.equal(transport.sent.length, 0);
|
||||
});
|
||||
|
||||
test('handshake, heartbeat, server-down and invalid frames are not business messages', async () => {
|
||||
const { client, transports, advance } = makeHarness();
|
||||
const events: WireEvent[] = [];
|
||||
client.subscribe((event) => events.push(event));
|
||||
client.start();
|
||||
const transport = transports[0]!;
|
||||
await transport.flush();
|
||||
|
||||
transport.serverPush('@toconconABC');
|
||||
transport.serverPush(JSON.stringify({ com: '@serverheartbeat' }));
|
||||
transport.serverPush('webserve-服务器未工作');
|
||||
transport.serverPush('not json');
|
||||
await transport.flush();
|
||||
|
||||
assert.deepEqual(events, [{ type: 'open', server: 'ws://a' }]);
|
||||
assert.equal(transport.sent.length, 0);
|
||||
advance(29999);
|
||||
assert.equal(events.some((event) => event.type === 'slow'), false);
|
||||
advance(1);
|
||||
assert.equal(events.filter((event) => event.type === 'slow').length, 1);
|
||||
});
|
||||
|
||||
test('send requires an open connection and serializes the completed envelope once', async () => {
|
||||
const { client, transports } = makeHarness();
|
||||
assert.throws(() => client.send(envelope), /connection.*open/i);
|
||||
|
||||
client.start();
|
||||
const transport = transports[0]!;
|
||||
assert.throws(() => client.send(envelope), /connection.*open/i);
|
||||
await transport.flush();
|
||||
client.send(envelope);
|
||||
|
||||
assert.deepEqual(transport.sent.map((frame) => JSON.parse(frame)), [envelope]);
|
||||
client.stop();
|
||||
assert.throws(() => client.send(envelope), /connection.*open/i);
|
||||
});
|
||||
|
||||
test('constructor rejects an empty validated server list and start cannot run twice', () => {
|
||||
assert.throws(
|
||||
() => new WireClient({ servers: [], transportFactory: () => new FakeTransport() }),
|
||||
/servers.*non-empty/i,
|
||||
);
|
||||
|
||||
const { client } = makeHarness();
|
||||
client.start();
|
||||
assert.throws(() => client.start(), /already.*started/i);
|
||||
});
|
||||
|
||||
test('switchServer validates the target before closing and reconnects to it after policy delay', async () => {
|
||||
const { client, transports, advance } = makeHarness(['ws://a', 'ws://b']);
|
||||
const events: WireEvent[] = [];
|
||||
client.subscribe((event) => events.push(event));
|
||||
client.start();
|
||||
const first = transports[0]!;
|
||||
await first.flush();
|
||||
|
||||
assert.throws(() => client.switchServer('http://room'), /WebSocket.*URL/i);
|
||||
assert.throws(() => client.switchServer('ws://'), /WebSocket.*URL/i);
|
||||
assert.equal(first.closeCalls, 0);
|
||||
|
||||
client.switchServer('ws://room');
|
||||
assert.equal(first.closeCalls, 1);
|
||||
assert.deepEqual(events.slice(-2), [
|
||||
{ type: 'close' },
|
||||
{ type: 'reconnecting', server: 'ws://room' },
|
||||
]);
|
||||
advance(9999);
|
||||
assert.equal(transports.length, 1);
|
||||
advance(1);
|
||||
assert.equal(transports[1]!.url, 'ws://room');
|
||||
await transports[1]!.flush();
|
||||
assert.deepEqual(events[events.length - 1], { type: 'open', server: 'ws://room' });
|
||||
});
|
||||
|
||||
test('reconnectCurrent closes once and reconnects to the current target after 10 seconds', async () => {
|
||||
const { client, transports, advance } = makeHarness(['ws://a', 'ws://b']);
|
||||
const events: WireEvent[] = [];
|
||||
client.subscribe((event) => events.push(event));
|
||||
client.start();
|
||||
await transports[0]!.flush();
|
||||
|
||||
client.reconnectCurrent();
|
||||
assert.equal(transports[0]!.closeCalls, 1);
|
||||
assert.deepEqual(events.slice(-2), [
|
||||
{ type: 'close' },
|
||||
{ type: 'reconnecting', server: 'ws://a' },
|
||||
]);
|
||||
advance(10000);
|
||||
assert.equal(transports[1]!.url, 'ws://a');
|
||||
});
|
||||
|
||||
test('duplicate transport close/error is handled once and failures rotate every three attempts', async () => {
|
||||
const { client, transports, advance, activeTimers } = makeHarness(['ws://a', 'ws://b']);
|
||||
const reconnectTargets: string[] = [];
|
||||
client.subscribe((event) => {
|
||||
if (event.type === 'reconnecting') reconnectTargets.push(event.server);
|
||||
});
|
||||
client.start();
|
||||
await transports[0]!.flush();
|
||||
|
||||
transports[0]!.serverClose();
|
||||
await transports[0]!.flush();
|
||||
assert.deepEqual(reconnectTargets, ['ws://a']);
|
||||
advance(10000);
|
||||
await transports[1]!.flush();
|
||||
|
||||
transports[1]!.serverError();
|
||||
await transports[1]!.flush();
|
||||
assert.deepEqual(reconnectTargets, ['ws://a', 'ws://a']);
|
||||
advance(10000);
|
||||
await transports[2]!.flush();
|
||||
|
||||
transports[2]!.serverError();
|
||||
transports[2]!.serverClose();
|
||||
await transports[2]!.flush();
|
||||
assert.deepEqual(reconnectTargets, ['ws://a', 'ws://a', 'ws://b']);
|
||||
assert.equal(activeTimers(), 1);
|
||||
advance(10000);
|
||||
assert.equal(transports.length, 4);
|
||||
assert.equal(transports[3]!.url, 'ws://b');
|
||||
});
|
||||
|
||||
test('stale transport generations cannot emit messages or opens', async () => {
|
||||
const { client, transports, advance } = makeHarness();
|
||||
const events: WireEvent[] = [];
|
||||
client.subscribe((event) => events.push(event));
|
||||
client.start();
|
||||
const first = transports[0]!;
|
||||
await first.flush();
|
||||
first.serverClose();
|
||||
await first.flush();
|
||||
|
||||
first.serverPush(JSON.stringify({ route: 'agent', rpc: 'stale_before_reconnect', data: {} }));
|
||||
await first.flush();
|
||||
advance(10000);
|
||||
first.serverPush(JSON.stringify({ route: 'agent', rpc: 'stale_after_reconnect', data: {} }));
|
||||
await first.flush();
|
||||
await transports[1]!.flush();
|
||||
|
||||
assert.deepEqual(events.filter((event) => event.type === 'message'), []);
|
||||
assert.equal(events.filter((event) => event.type === 'open').length, 2);
|
||||
});
|
||||
|
||||
test('unsubscribe removes the listener without affecting transport lifecycle', async () => {
|
||||
const { client, transports } = makeHarness();
|
||||
const events: WireEvent[] = [];
|
||||
const unsubscribe = client.subscribe((event) => events.push(event));
|
||||
client.start();
|
||||
const transport = transports[0]!;
|
||||
await transport.flush();
|
||||
unsubscribe();
|
||||
unsubscribe();
|
||||
transport.serverPush(JSON.stringify({ route: 'agent', rpc: 'update_bean', data: { bean: 1 } }));
|
||||
await transport.flush();
|
||||
|
||||
assert.deepEqual(events, [{ type: 'open', server: 'ws://a' }]);
|
||||
});
|
||||
|
||||
test('stop is idempotent, closes an active transport once and clears its watchdog', async () => {
|
||||
const { client, transports, advance, activeTimers } = makeHarness();
|
||||
const events: WireEvent[] = [];
|
||||
client.subscribe((event) => events.push(event));
|
||||
client.start();
|
||||
const first = transports[0]!;
|
||||
await first.flush();
|
||||
first.serverPush('@toconconABC');
|
||||
await first.flush();
|
||||
assert.equal(activeTimers(), 1);
|
||||
|
||||
client.stop();
|
||||
client.stop();
|
||||
assert.equal(activeTimers(), 0);
|
||||
assert.equal(first.closeCalls, 1);
|
||||
advance(60000);
|
||||
await first.flush();
|
||||
assert.equal(transports.length, 1);
|
||||
assert.equal(events.filter((event) => event.type === 'close').length, 1);
|
||||
assert.throws(() => client.send(envelope), /connection.*open/i);
|
||||
});
|
||||
|
||||
test('stop clears a pending reconnect timer and prevents a later transport generation', async () => {
|
||||
const { client, transports, advance, activeTimers } = makeHarness();
|
||||
client.start();
|
||||
const first = transports[0]!;
|
||||
await first.flush();
|
||||
|
||||
client.reconnectCurrent();
|
||||
assert.equal(first.closeCalls, 1);
|
||||
assert.equal(activeTimers(), 1);
|
||||
client.stop();
|
||||
assert.equal(activeTimers(), 0);
|
||||
advance(60000);
|
||||
await first.flush();
|
||||
|
||||
assert.equal(transports.length, 1);
|
||||
});
|
||||
Reference in New Issue
Block a user