mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 07:24:52 +09:00
Every message sent over a link is kept in the send buffer until the other side acknowledges it with a heartbeat, so it can be resent if the session resumes. Nothing forced a peer to acknowledge, so a client that kept heartbeating without a sequence number while subscribed to a busy event made the controller buffer messages until it ran out of memory. Store buffered messages serialised, track their total size, and close the connection once it goes over maxSendBufferSize. The controller sets this to 64 MiB for the connections it accepts. A client that far behind could not resume in a reasonable time anyway. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
238 lines
5.7 KiB
TypeScript
238 lines
5.7 KiB
TypeScript
import { type WebSocket } from "ws";
|
|
|
|
import events from "events";
|
|
import assert from "assert/strict";
|
|
|
|
import * as lib from "@clusterio/lib";
|
|
import { logger } from "@clusterio/lib";
|
|
|
|
|
|
/**
|
|
* Connector for controller connections
|
|
*
|
|
* @extends module:lib.WebSocketBaseConnector
|
|
* @alias module:controller/src/WsServerConnector
|
|
*/
|
|
// Clients that leave this much unacknowledged are dropped rather than buffered for.
|
|
const maxSendBufferSize = 64 * 1024 * 1024;
|
|
|
|
export default class WsServerConnector extends lib.WebSocketBaseConnector {
|
|
_timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
constructor(
|
|
dst: lib.Address,
|
|
private _sessionId: number,
|
|
private _sessionTimeout: number,
|
|
public _heartbeatInterval: number,
|
|
) {
|
|
super(new lib.Address(lib.Address.controller, 0), dst);
|
|
this.maxSendBufferSize = maxSendBufferSize;
|
|
|
|
// The following states are used in the server connector
|
|
// closed: Connection is closed
|
|
// connected: Connection is online
|
|
// resuming: Waiting for client to resume.
|
|
}
|
|
|
|
_reset() {
|
|
if (this._timeoutId) {
|
|
clearTimeout(this._timeoutId);
|
|
this._timeoutId = null;
|
|
}
|
|
super._reset();
|
|
}
|
|
|
|
/**
|
|
* Send ready over the socket
|
|
*
|
|
* Sends the ready message over the socket to initiate the session.
|
|
*
|
|
* @param socket - WebSocket connection to client.
|
|
* @param src - Source address for this link.
|
|
* @param sessionToken - the session token to send to the client.
|
|
* @param account - account data to provide to control connection
|
|
*/
|
|
ready(
|
|
socket: WebSocket,
|
|
src: lib.Address,
|
|
sessionToken: string,
|
|
account: lib.AccountDetails | undefined
|
|
) {
|
|
this._socket = socket;
|
|
this._sendInternal(new lib.MessageReady(
|
|
new lib.ReadyData(
|
|
src,
|
|
sessionToken,
|
|
this._sessionTimeout,
|
|
this._heartbeatInterval,
|
|
account,
|
|
),
|
|
));
|
|
|
|
this._state = "connected";
|
|
this._attachSocketHandlers();
|
|
this.emit("connect", { src: this.src });
|
|
}
|
|
|
|
/**
|
|
* Continue connection with the given socket
|
|
*
|
|
* Terminates the current socket and contiunes the session over the
|
|
* socket given from the message sequence given.
|
|
*
|
|
* @param socket - New socket to continue on.
|
|
* @param lastSeq - The last message the client received.
|
|
*/
|
|
continue(
|
|
socket: WebSocket,
|
|
lastSeq: number
|
|
) {
|
|
|
|
// It's possible the previous connection hasn't closed yet due to a
|
|
// stale connection. Terminate it if so.
|
|
if (this._state === "connected" && this._socket !== null) {
|
|
this._socket.terminate();
|
|
this._socket.once("close", () => this.continue(socket, lastSeq));
|
|
return;
|
|
}
|
|
|
|
this._socket = socket;
|
|
|
|
if (this._timeoutId) {
|
|
clearTimeout(this._timeoutId);
|
|
this._timeoutId = null;
|
|
}
|
|
|
|
this._sendInternal(new lib.MessageContinue(
|
|
new lib.ContinueData(this._sessionTimeout, this._heartbeatInterval, this._lastReceivedSeq)
|
|
));
|
|
|
|
this._state = "connected";
|
|
this._attachSocketHandlers();
|
|
this._dropSendBufferSeq(lastSeq);
|
|
logger.verbose(
|
|
`Connector | resuming session with ${this.dst}, resending ${this._sendBuffer.length} buffered messages`
|
|
);
|
|
for (let message of this._sendBuffer) {
|
|
this._socket.send(message.text);
|
|
}
|
|
this.emit("resume");
|
|
}
|
|
|
|
_timedOut() {
|
|
logger.verbose(`Connector | Connection to ${this.dst} timed out`);
|
|
this._close();
|
|
}
|
|
|
|
_attachSocketHandlers() {
|
|
if (this._socket === null) {
|
|
throw new Error("this._socket is null");
|
|
}
|
|
|
|
this.startHeartbeat();
|
|
|
|
this._socket.on("close", (code: number, reason: string) => {
|
|
logger.verbose(`Connector | Close (code: ${code}, reason: ${reason})`);
|
|
this.stopHeartbeat();
|
|
this._socket = null;
|
|
|
|
if (this._closing) {
|
|
this._close();
|
|
|
|
} else {
|
|
this._state = "resuming";
|
|
this.emit("drop");
|
|
this._timeoutId = setTimeout(() => { this._timedOut(); }, this._sessionTimeout * 1000);
|
|
}
|
|
});
|
|
|
|
this._socket.on("error", (err: any) => {
|
|
// It's assumed that close is always called by ws
|
|
logger.verbose("Connector | Error:", err);
|
|
});
|
|
|
|
this._socket.on("open", () => {
|
|
logger.verbose("Connector | Open");
|
|
});
|
|
this._socket.on("ping", (data: string) => {
|
|
logger.verbose(`Connector | Ping (data: ${data}`);
|
|
});
|
|
this._socket.on("pong", (data: string) => {
|
|
logger.verbose(`Connector | Pong (data: ${data}`);
|
|
});
|
|
|
|
// Handle messages
|
|
this._socket.on("message", (data: string) => {
|
|
let message = this._parseMessage(data);
|
|
if (!message) {
|
|
return;
|
|
}
|
|
if (this._state === "connected") {
|
|
this._processMessage(message);
|
|
|
|
} else {
|
|
throw new Error(`Received message in unexpected state ${this._state}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Close the connection with the given reason.
|
|
*
|
|
* Sends a close frame and disconnects the connector.
|
|
*
|
|
* @param code - WebSocket close code.
|
|
* @param reason - WebSocket close reason.
|
|
*/
|
|
async close(
|
|
code: number,
|
|
reason: string
|
|
): Promise<void> {
|
|
if (this._state === "closed") {
|
|
return;
|
|
}
|
|
|
|
if (this._state === "resuming") {
|
|
this._close();
|
|
return;
|
|
}
|
|
|
|
this._closing = true;
|
|
if (this._socket !== null) {
|
|
this._socket.close(code, reason);
|
|
}
|
|
await events.once(this, "close");
|
|
}
|
|
|
|
/**
|
|
* Forcefully close the connection immediately
|
|
*
|
|
* This should only be used if there's a security implication from
|
|
* letting the connection carry on with a normal close proceedure.
|
|
*/
|
|
terminate() {
|
|
this.setClosing();
|
|
|
|
if (this._socket) {
|
|
this._socket.terminate();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Notify the connection is expected to close
|
|
*
|
|
* Sets the internal flag to signal not to wait for the client to
|
|
* reconnect and resume the connection if the client closes it. This
|
|
* will close the connection if it's currently waiting for the client to
|
|
* resume it.
|
|
*/
|
|
setClosing() {
|
|
if (this._state === "resuming") {
|
|
this._close();
|
|
return;
|
|
}
|
|
|
|
this._closing = true;
|
|
}
|
|
}
|