mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
perf(engine): coalesce outgoing game packets into one websocket message per tick
Profiling prod (~950 players) put ~20% of the tick thread in ws.send(): every packet was its own websocket frame + syscall (~20 per player per tick). Batch per client in WSClientSocket and flush once at the end of processClientsOut; login-phase replies still go out immediately, close() flushes first, and the ws close event discards the batch. Clients read the socket as a byte stream, so framing is not observable. Also collapse /status/:bot in the web request stats. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
34641c5cbf
commit
9763784285
4 files changed
+84
-13
No files matched your search
@@ -1,6 +1,7 @@
|
||||
// stdlib
|
||||
import fs from 'fs';
|
||||
import { Worker } from 'worker_threads';
|
||||
import WSClientSocket from '#/server/ws/WSClientSocket.js';
|
||||
|
||||
// deps
|
||||
import * as rsbuf from '#/network/rsbuf/index.js';
|
||||
@@ -1270,6 +1271,8 @@ class World {
|
||||
}
|
||||
}
|
||||
}
|
||||
// one websocket message per client per tick (see WSClientSocket.send)
|
||||
WSClientSocket.flushAll();
|
||||
this.cycleStats[WorldStat.CLIENT_OUT] = Date.now() - start;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,20 @@ type RuntimeWebSocket = {
|
||||
let droppedSendCount = 0;
|
||||
let lastDropLog = 0;
|
||||
|
||||
// rs-sdk: sockets with batched-but-unflushed output; World flushes them once per tick
|
||||
const pendingFlush: Set<WSClientSocket> = new Set();
|
||||
|
||||
export default class WSClientSocket extends ClientSocket {
|
||||
socket: RuntimeWebSocket | null = null;
|
||||
|
||||
// rs-sdk: outgoing game packets are coalesced into one websocket message per tick.
|
||||
// Upstream writes each packet with its own send(); on TCP the kernel coalesces that,
|
||||
// on websockets it is a frame + syscall per packet (~20 per player per tick), which
|
||||
// profiled at ~20% of the tick thread with ~1000 players. The client side reads the
|
||||
// socket as a byte stream (ClientStream queues frames), so framing is not observable.
|
||||
private pending: Uint8Array = new Uint8Array(4096);
|
||||
private pendingLen = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
@@ -23,24 +34,72 @@ export default class WSClientSocket extends ClientSocket {
|
||||
}
|
||||
|
||||
send(src: Uint8Array): void {
|
||||
if (this.socket) {
|
||||
// Bun's ServerWebSocket.send returns a status: -1 = backpressure (enqueued),
|
||||
// 0 = DROPPED (connection closed/closing), >0 = bytes sent. Dropped sends were
|
||||
// previously silent - clients would hang on "Connecting to server..." or
|
||||
// "Loading - please wait." with no server-side trace.
|
||||
const result = this.socket.send(src);
|
||||
if (result === 0) {
|
||||
droppedSendCount++;
|
||||
const now = Date.now();
|
||||
if (now - lastDropLog > 5000) {
|
||||
lastDropLog = now;
|
||||
console.warn(`[WSClientSocket] send() dropped ${src.length} bytes to ${this.remoteAddress} (ws closed/closing; ${droppedSendCount} drops total)`);
|
||||
}
|
||||
if (!this.socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
// login negotiation and non-player replies go straight out - they are single
|
||||
// small packets whose latency matters and nothing would flush them otherwise
|
||||
if (this.state !== 1 || !this.player) {
|
||||
this.sendNow(src);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.pendingLen + src.length > this.pending.length) {
|
||||
let size = this.pending.length * 2;
|
||||
while (size < this.pendingLen + src.length) {
|
||||
size *= 2;
|
||||
}
|
||||
const grown = new Uint8Array(size);
|
||||
grown.set(this.pending.subarray(0, this.pendingLen));
|
||||
this.pending = grown;
|
||||
}
|
||||
this.pending.set(src, this.pendingLen);
|
||||
this.pendingLen += src.length;
|
||||
pendingFlush.add(this);
|
||||
}
|
||||
|
||||
flush(): void {
|
||||
if (this.pendingLen === 0) {
|
||||
pendingFlush.delete(this);
|
||||
return;
|
||||
}
|
||||
const data = this.pending.subarray(0, this.pendingLen);
|
||||
this.pendingLen = 0;
|
||||
pendingFlush.delete(this);
|
||||
this.sendNow(data);
|
||||
}
|
||||
|
||||
// flush every socket that batched output this tick; called by World once per cycle
|
||||
static flushAll(): void {
|
||||
for (const socket of pendingFlush) {
|
||||
socket.flush();
|
||||
}
|
||||
}
|
||||
|
||||
private sendNow(src: Uint8Array): void {
|
||||
if (!this.socket) {
|
||||
return;
|
||||
}
|
||||
// Bun's ServerWebSocket.send returns a status: -1 = backpressure (enqueued),
|
||||
// 0 = DROPPED (connection closed/closing), >0 = bytes sent. Dropped sends were
|
||||
// previously silent - clients would hang on "Connecting to server..." or
|
||||
// "Loading - please wait." with no server-side trace.
|
||||
const result = this.socket.send(src);
|
||||
if (result === 0) {
|
||||
droppedSendCount++;
|
||||
const now = Date.now();
|
||||
if (now - lastDropLog > 5000) {
|
||||
lastDropLog = now;
|
||||
console.warn(`[WSClientSocket] send() dropped ${src.length} bytes to ${this.remoteAddress} (ws closed/closing; ${droppedSendCount} drops total)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
// deliver anything still batched (logout packet, login rejection) before closing
|
||||
this.flush();
|
||||
|
||||
// give time to acknowledge and receive packets
|
||||
this.state = -1;
|
||||
|
||||
@@ -51,8 +110,15 @@ export default class WSClientSocket extends ClientSocket {
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// the peer is gone (ws close event) - nothing batched can be delivered any more
|
||||
discard(): void {
|
||||
this.pendingLen = 0;
|
||||
pendingFlush.delete(this);
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.state = -1;
|
||||
this.discard();
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.terminate();
|
||||
|
||||
@@ -30,6 +30,7 @@ const WEB_SLOW_MS = Number(process.env.WEB_SLOW_MS ?? 100);
|
||||
function routeKey(url: URL): string {
|
||||
// collapse ids/usernames so keys stay bounded: keep the first two path segments
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts[0] === 'status' && parts.length > 1) parts[1] = ':bot';
|
||||
let key = '/' + parts.slice(0, 2).join('/');
|
||||
if (parts.length > 2) key += '/*';
|
||||
if (url.searchParams.size > 0) key += '?' + [...url.searchParams.keys()].slice(0, 3).sort().join('&');
|
||||
|
||||
@@ -162,6 +162,7 @@ export const websocketHandlers = {
|
||||
|
||||
const { client } = ws.data;
|
||||
client.state = -1;
|
||||
client.discard();
|
||||
|
||||
if (client.player) {
|
||||
client.player.addSessionLog(LoggerEventType.ENGINE, 'WS socket closed');
|
||||
|
||||
Reference in new issue
Block a user