Files
SnowLuma/packages/onebot/tests/ws-server-heartbeat.test.ts
T
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

186 lines
5.7 KiB
TypeScript

// Issue #208 (server side): symmetric transport keepalive for the reverse-WS
// SERVER. Each attached client is pinged; a client that goes fully silent for
// maxMissed consecutive intervals is terminated (and drops out of the
// connection set) so a half-open client can't wedge forever. A healthy or
// actively-messaging client must never be reaped — the same false-positive
// guard as the client adapter.
//
// Mock `@snowluma/websocket` (native addon isn't built in a plain checkout),
// mirroring network-bootstrap-meta.test.ts. Timers are faked and driven.
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { FakeWebSocket, FakeWebSocketServer, servers } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { EventEmitter } = require('node:events') as typeof import('node:events');
const servers: FakeWebSocketServer[] = [];
class FakeWebSocket extends EventEmitter {
public readyState = 1; // OPEN
public pings = 0;
public terminated = false;
send(_payload: string, cb?: (err?: Error | null) => void): void {
cb?.(null);
}
ping(): void {
this.pings += 1;
}
terminate(): void {
if (this.readyState === 3) {
return;
}
this.terminated = true;
this.readyState = 3;
this.emit('close');
}
close(): void {
if (this.readyState < 2) {
this.readyState = 2;
}
}
simulatePong(): void {
this.emit('pong', Buffer.alloc(0));
}
simulateMessage(): void {
this.emit('message', Buffer.from('{}'), false);
}
}
class FakeWebSocketServer extends EventEmitter {
public closed = false;
constructor(_opts: unknown) {
super();
servers.push(this);
}
close(): void {
this.closed = true;
}
simulateConnection(socket: FakeWebSocket, request: unknown): void {
this.emit('connection', socket, request);
}
}
return { FakeWebSocket, FakeWebSocketServer, servers };
});
vi.mock('@snowluma/websocket', () => ({
WebSocket: FakeWebSocket,
WebSocketServer: FakeWebSocketServer,
}));
import { WsServerAdapter } from '../src/network/ws-server-adapter';
import type { NetworkAdapterContext } from '../src/network/adapter';
import type { WsServerNetwork } from '../src/types';
const INTERVAL = 30_000;
const MAX_MISSED = 2;
function ctx(): NetworkAdapterContext {
return {
uin: '10001',
api: { processStreamRequest: async () => {} } as never,
buildLifecycleEvent: () => ({}),
buildHeartbeatEvent: () => ({}),
};
}
function cfg(over: Partial<WsServerNetwork> = {}): WsServerNetwork {
return {
enabled: true,
host: '0.0.0.0',
port: 8080,
path: '/',
role: 'Universal',
...over,
} as WsServerNetwork;
}
const REQUEST = { url: '/', headers: {} } as unknown;
function connect(): { adapter: WsServerAdapter; socket: InstanceType<typeof FakeWebSocket> } {
const adapter = new WsServerAdapter('ws', cfg(), ctx());
adapter.open();
const server = servers[servers.length - 1];
server.emit('listening'); // adapter marks itself listening → status 'ok'
const socket = new FakeWebSocket();
server.simulateConnection(socket, REQUEST);
return { adapter, socket };
}
describe('WsServerAdapter — transport heartbeat (issue #208)', () => {
beforeEach(() => {
servers.length = 0;
});
it('reaps a silent client only after maxMissed intervals, then drops it', () => {
vi.useFakeTimers();
try {
const { adapter, socket } = connect();
expect(adapter.describeStatus().detail).toBe('1 个客户端');
vi.advanceTimersByTime(INTERVAL * MAX_MISSED); // tolerated
expect(socket.terminated).toBe(false);
vi.advanceTimersByTime(INTERVAL); // threshold crossed
expect(socket.terminated).toBe(true);
expect(adapter.describeStatus().detail).toBe('0 个客户端');
} finally {
vi.useRealTimers();
}
});
it('never reaps a client that keeps ponging', () => {
vi.useFakeTimers();
try {
const { adapter, socket } = connect();
for (let i = 0; i < 10; i++) {
vi.advanceTimersByTime(INTERVAL);
socket.simulatePong();
}
expect(socket.terminated).toBe(false);
expect(adapter.describeStatus().detail).toBe('1 个客户端');
expect(socket.pings).toBeGreaterThan(0);
} finally {
vi.useRealTimers();
}
});
it('treats inbound messages as liveness', () => {
vi.useFakeTimers();
try {
const { adapter, socket } = connect();
for (let i = 0; i < 10; i++) {
vi.advanceTimersByTime(INTERVAL);
socket.simulateMessage();
}
expect(socket.terminated).toBe(false);
expect(adapter.describeStatus().detail).toBe('1 个客户端');
} finally {
vi.useRealTimers();
}
});
it('stops all client heartbeats on adapter close — no lingering pings', () => {
vi.useFakeTimers();
try {
const { adapter, socket } = connect();
adapter.close();
const pingsAtClose = socket.pings;
vi.advanceTimersByTime(INTERVAL * 5);
expect(socket.pings).toBe(pingsAtClose);
expect(socket.terminated).toBe(false); // closed cleanly, not reaped
} finally {
vi.useRealTimers();
}
});
});