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:
max bittkerandClaude committed 2026-08-17 19:56:51 -04:00
1 parent 34641c5cbf
commit 9763784285
4 files changed
+84 -13

No files matched your search

+3
View File
@@ -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;
}
+79 -13
View File
@@ -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();
+1
View File
@@ -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('&');
+1
View File
@@ -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');