Merge pull request #996 from bbassie/feature/instance-udp

Add UDP communication support for instances
This commit is contained in:
Cooldude2606 authored and GitHub committed 2026-10-17 20:47:54 +01:00
commit ba800e853d
19 files changed
+708 -25

No files matched your search

+17
View File
@@ -606,6 +606,23 @@ Like factorio.enable_save_patching, plugins that need script commands can't be l
Defaults to true.
### factorio.enable_lua_udp
Enable UDP messaging between the host and the Factorio server by passing `--enable-lua-udp` to Factorio.
This lets plugins send UDP packets to the server and receive data sent with `clusterio_api.send_udp` in-game.
Requires Factorio 2.1.10 or later, earlier versions crash when a packet is received on a headless server.
Defaults to false.
### factorio.lua_udp_port
UDP port on localhost the Factorio server receives Lua UDP messages on when `factorio.enable_lua_udp` is enabled.
If null a random port in the dynamic range is assigned each time the instance starts.
Defaults to null.
### factorio.enable_whitelist
Turn on the whitelist on the server.
+13
View File
@@ -292,6 +292,19 @@ Parameters:
- `channel`: string identifying which channel to send it on.
- `data`: table that can be converted to JSON with `compat.table_to_json`
#### clusterio_api.send_udp(channel, data)
Send data to Clusterio over the given channel using UDP instead of stdout.
Clusterio plugins receive it as `udp-channel_name` events, see the [Communicating over UDP section](writing-plugins.md#communicating-over-udp) in the Writing Plugins document.
Requires `factorio.enable_lua_udp` to be enabled on the instance and Factorio 2.1.10 or later, otherwise the data is silently dropped.
The port to send to is passed to the module with a script command on startup, so `factorio.enable_script_commands` must be enabled as well.
**Note**: Delivery is not guaranteed and payloads should be kept well below 32 kB.
Parameters:
- `channel`: string identifying which channel to send it on.
- `data`: string with the payload to send, any bytes are allowed.
### serialize library
+39 -2
View File
@@ -147,7 +147,7 @@ The following properties are recognized:
See [Migrating Stored Data](#migrating-stored-data)
**features**:
Array of features the plugin needs from the instances it is loaded on, `"SavePatching"` and/or `"ScriptCommands"`.
Array of features the plugin needs from the instances it is loaded on, any of `"SavePatching"`, `"ScriptCommands"` and `"LuaUdp"`.
See [Handling Invalid Configuration](#handling-invalid-configuration)
**messages**:
@@ -303,7 +303,7 @@ See [Configuration System](config-system.md) for more details on how this system
### Handling Invalid Configuration
If the plugin requires save patching or script commands to function, which can be disabled to run vanilla or scenarios not compatible with Clusterio, declare it under `features` in the `plugin` export:
If the plugin requires save patching, script commands or Lua UDP to function, which can be disabled to run vanilla or scenarios not compatible with Clusterio, declare it under `features` in the `plugin` export:
```js
export const plugin = {
@@ -501,6 +501,43 @@ Data out from Factorio does not have the same limits as data into Factorio, RCON
For `send_json` it's possible that payloads greater than 4kB are received after payloads that were sent at a later point in time.
For RCON, commands longer than 50 characters may end up being executed after shorter commands sent after it.
### Communicating over UDP
Instances can optionally exchange UDP packets with the Factorio server for low latency fire-and-forget communication, using the `--enable-lua-udp` option of Factorio.
This needs Factorio 2.1.10 or later, earlier versions crash when a packet is received on a headless server.
This is disabled by default and enabled by setting `factorio.enable_lua_udp` on the instance.
Sending data out of Factorio also needs `factorio.enable_script_commands`, as the port to send to is passed to the module with a script command on startup.
Plugins requiring it should declare the `LuaUdp` feature.
Note that UDP does not guarantee delivery or ordering, and packets should be kept well below 32 kB.
Data out from Factorio is sent with the `send_udp` API of the Clusterio module.
Unlike `send_json` the payload is an arbitrary string, and it is delivered to plugins as a `udp-channel_name` event with the payload as a Buffer:
```lua
clusterio_api.send_udp("my_plugin_foo", "123")
```
```js
export default async function(context) {
const { instance, logger } = context;
instance.server.handleUdp("my_plugin_foo", async data => {
logger.info(`got ${data.toString()}`);
});
}
```
Like `handle` for `send_json`, `handleUdp` logs errors thrown by the handler.
Use `instance.server.on("udp-my_plugin_foo", ...)` for custom error handling.
Data into Factorio is sent with the `sendUdp` method on the instance and delivered in-game as `defines.events.on_udp_packet_received` events with the packet in `event.payload`.
The Clusterio module polls for received packets every tick, all modules and mods receive every packet so make sure the payload identifies your plugin.
```js
instance.hooks.start.attach(plugin.name, async () => {
await instance.sendUdp("my_plugin_foo:123");
});
```
## Defining Link Messages
+18 -6
View File
@@ -38,17 +38,20 @@ function api.get_instance_id()
end
local function escape_channel(channel)
-- Escape bad characters. The question mark is used for separating the
-- channel name from the payload.
return channel:gsub("([\x00-\x1f?\\])", function(match)
return "\\x" .. string.format("%02x", match:byte())
end)
end
function api.send_json(channel, data)
if not remote.interfaces.clusterio_api then
return
end
-- Escape bad characters. The question mark is used for separating the
-- channel name from the payload.
channel = channel:gsub("([\x00-\x1f?\\])", function(match)
return "\\x" .. string.format("%02x", match:byte())
end)
channel = escape_channel(channel)
data = compat.table_to_json(data)
-- If there's more than about 4kB of data users running with the Windows
@@ -64,5 +67,14 @@ function api.send_json(channel, data)
end
end
function api.send_udp(channel, data)
local port = call_api("get_host_udp_port")
if not port then
return -- Lua UDP is not enabled for this instance
end
helpers.send_udp(port, escape_channel(channel) .. "?" .. data, 0)
end
return api
+15 -3
View File
@@ -17,14 +17,16 @@ function api.get_instance_id()
return compat.script_data.clusterio.instance_id
end
function api.send_json(channel, data)
local function escape_channel(channel)
-- Escape bad characters. The question mark is used for separating the
-- channel name from the payload.
channel = channel:gsub("([\x00-\x1f?\\])", function(match)
return channel:gsub("([\x00-\x1f?\\])", function(match)
return "\\x" .. string.format("%02x", match:byte())
end)
end
function api.send_json(channel, data)
channel = escape_channel(channel)
data = compat.table_to_json(data)
-- If there's more than about 4kB of data users running with the Windows
@@ -41,5 +43,15 @@ function api.send_json(channel, data)
end
end
function api.send_udp(channel, data)
local clusterio = compat.script_data.clusterio
local port = clusterio and clusterio.host_udp_port
if not port then
return -- Lua UDP is not enabled for this instance
end
helpers.send_udp(port, escape_channel(channel) .. "?" .. data, 0)
end
return api
+20 -2
View File
@@ -12,16 +12,27 @@ local function check_patch()
end
end
-- Added in Factorio 2.0.59, indexing a missing key on a LuaObject errors
local has_recv_udp = helpers ~= nil and pcall(function() return helpers.recv_udp end)
local impl = {}
impl.events = {}
impl.events[defines.events.on_tick] = check_patch
impl.events[defines.events.on_tick] = function()
check_patch()
-- Dispatch on_udp_packet_received for packets sent by the host, this is
-- a no-op when the server was started without --enable-lua-udp
if has_recv_udp then
helpers.recv_udp(0)
end
end
impl.events[api.events.on_server_startup] = function()
if not compat.script_data.clusterio then
compat.script_data.clusterio = {
instance_id = nil,
instance_name = nil,
host_udp_port = nil,
}
end
@@ -74,11 +85,12 @@ end
-- Internal API
clusterio_private = {}
function clusterio_private.update_instance(new_id, new_name)
function clusterio_private.update_instance(new_id, new_name, host_udp_port)
check_patch()
local script_data = compat.script_data
script_data.clusterio.instance_id = new_id
script_data.clusterio.instance_name = new_name
script_data.clusterio.host_udp_port = host_udp_port
script.raise_event(api.events.on_instance_updated, {
instance_id = new_id,
instance_name = new_name,
@@ -105,6 +117,12 @@ remote.add_interface('clusterio_api', {
return compat.script_data.clusterio.instance_name
end,
get_host_udp_port = function()
-- Not initialised until the first tick after the save is patched
local clusterio = compat.script_data.clusterio
return clusterio and clusterio.host_udp_port
end,
get_file_no = function()
local script_data = compat.script_data
script_data.clusterio_file_no = (script_data.clusterio_file_no or 0) + 1
+16
View File
@@ -259,6 +259,22 @@ export class BaseInstancePlugin {
return await this.instance.sendRcon(message, expectEmpty, this.info.name);
}
/**
* Send UDP message to instance
*
* Send a UDP packet to the Factorio server, which is delivered in-game
* as an on_udp_packet_received event. Delivery is not guaranteed and
* the payload should be kept well below 64 kB. Requires the LuaUdp
* plugin feature.
*
* This should not be called before onStart or after onStop.
*
* @param message - payload of the packet to send.
*/
async sendUdp(message: string | Buffer): Promise<void> {
await this.instance.sendUdp(message);
}
/**
* Send serially ordered RCON message to instance
*
+21 -1
View File
@@ -233,6 +233,8 @@ export default class Instance extends lib.Link {
gamePort: this.config.get("factorio.game_port") ?? host.assignGamePort(this.id),
rconPort: this.config.get("factorio.rcon_port") ?? undefined,
rconPassword: this.config.get("factorio.rcon_password") ?? undefined,
enableLuaUdp: this.config.get("factorio.enable_lua_udp"),
luaUdpPort: this.config.get("factorio.lua_udp_port") ?? undefined,
enableWhitelist: this.config.get("factorio.enable_whitelist"),
enableAuthserverBans: this.config.get("factorio.enable_authserver_bans"),
verboseLogging: this.config.get("factorio.verbose_logging"),
@@ -506,6 +508,23 @@ end`.replace(/\r?\n/g, " ");
}
}
/**
* Send UDP message to the Factorio server
*
* Delivered in-game as an on_udp_packet_received event. Requires
* factorio.enable_lua_udp to be enabled on the instance.
*
* @param message - payload of the packet to send.
*/
async sendUdp(message: string | Buffer) {
if (!this.config.get("factorio.enable_lua_udp")) {
throw new Error(
"Attempted to send UDP message while Lua UDP is disabled. See config factorio.enable_lua_udp."
);
}
await this.server.sendUdp(message);
}
static async listSaves(instanceId: number, savesDir: string, loadedSave: string | null) {
let defaultSave = null;
if (loadedSave === null) {
@@ -1090,7 +1109,8 @@ end`.replace(/\r?\n/g, " ");
*/
async updateInstanceData() {
let name = lib.escapeString(this.name);
await this.sendRcon(`/sc clusterio_private.update_instance(${this.id}, "${name}")`, true);
let hostUdpPort = this.server.hostUdpPort ?? "nil";
await this.sendRcon(`/sc clusterio_private.update_instance(${this.id}, "${name}", ${hostUdpPort})`, true);
}
async updateFactorioSettings(current: Record<string, unknown>, previous: Record<string, unknown>) {
+139 -8
View File
@@ -2,6 +2,7 @@
import fs from "node:fs/promises";
import { type FSWatcher, createWriteStream, watch as fsWatch, writeFileSync } from "node:fs";
import child_process from "child_process";
import dgram from "dgram";
import path from "path";
import stream from "node:stream";
import { pipeline } from "node:stream/promises";
@@ -303,6 +304,15 @@ async function generatePassword(length: number) {
}
}
function unescapeChannel(channel: Buffer) {
return channel
.toString("utf-8")
.replace(/\\x([0-9a-f]{2})/g, (match, p1) => (
String.fromCharCode(parseInt(p1, 16))
))
;
}
/**
* Interpret lines of output from Factorio
*
@@ -425,6 +435,9 @@ const outputHeuristics: Heuristic[] = [
filter: { type: "log", message: /^Starting RCON interface/ },
action: function() {
this._startRcon().catch((err) => { this.emit("error", err); });
if (this.enableLuaUdp) {
this._startUdp().catch((err) => { this.emit("error", err); });
}
},
},
@@ -556,6 +569,10 @@ export interface FactorioServerOptions {
rconPort?: number,
/** Password use for RCON. */
rconPassword?: string,
/** Enable UDP messaging with the Factorio server. */
enableLuaUdp?: boolean,
/** UDP port the Factorio server receives Lua UDP messages on. */
luaUdpPort?: number,
/** Turn on whitelisting. */
enableWhitelist?: boolean,
/** Enable Factorio.com based multiplayer bans. */
@@ -600,8 +617,9 @@ type FactorioServerEvents = {
"_saving": [],
"_saved": [],
// IPS events
// IPC events
[ipcEvent: `ipc-${string}`]: [ event: any ],
[udpEvent: `udp-${string}`]: [ data: Buffer ],
};
/**
@@ -621,6 +639,8 @@ type FactorioServerEvents = {
* - autosave-fnished - invoked when the autosave finished
* - save-finished - invoked when the server has finished a manual save
* - exit - invoked when the sterver has exited
* - ipc-<channel> - invoked with data sent with send_json in-game
* - udp-<channel> - invoked with data sent with send_udp in-game
* @extends events.EventEmitter
*/
export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
@@ -632,6 +652,10 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
rconPort: number;
/** Password used for RCON on the Factorio game server */
rconPassword: string;
/** Enable UDP messaging with the Factorio game server */
enableLuaUdp: boolean;
/** UDP port the Factorio game server receives Lua UDP messages on */
luaUdpPort: number;
/** Enable player whitelist */
enableWhitelist: boolean;
/** Enable Factorio.com based multiplayer bans **/
@@ -661,6 +685,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
_server: child_process.ChildProcessWithoutNullStreams | null = null;
_rconClient: Rcon | null= null;
_rconReady = false;
_udpSocket: dgram.Socket | null = null;
_gameReady = false;
_stripRegExp?: RegExp;
_maxConcurrentCommands = 5;
@@ -693,6 +718,10 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
this.rconPort = options.rconPort || randomDynamicPort();
/** Password used for RCON on the Factorio game server */
this.rconPassword = options.rconPassword as string; // init will generate one if not there
/** Enable UDP messaging with the Factorio game server */
this.enableLuaUdp = options.enableLuaUdp || false;
/** UDP port the Factorio game server receives Lua UDP messages on */
this.luaUdpPort = options.luaUdpPort || randomDynamicPort();
/** Enable player whitelist */
this.enableWhitelist = options.enableWhitelist || false;
/** Enable Factorio.com based multiplayer bans **/
@@ -755,19 +784,24 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
}));
}
handleUdp(channel: string, handler: (data: Buffer) => Promise<void>) {
// Runs from the socket callback, so synchronous throws must be caught here too
this.on(`udp-${channel}`, async (data) => {
try {
await handler(data);
} catch (err: any) {
this._logger.error(`Error handling udp event:\n${err.stack ?? err.message}`);
}
});
}
async _handleIpc(line: Buffer) {
let channelEnd = line.indexOf("?");
if (channelEnd === -1) {
throw new Error(`Malformed IPC line "${line.toString()}"`);
}
let channel = line
.subarray(6, channelEnd)
.toString("utf-8")
.replace(/\\x([0-9a-f]{2})/g, (match, p1) => (
String.fromCharCode(parseInt(p1, 16))
))
;
let channel = unescapeChannel(line.subarray(6, channelEnd));
let type = line.subarray(channelEnd + 1, channelEnd + 2).toString("utf-8");
let content;
@@ -805,6 +839,74 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
}
}
_handleUdpMessage(message: Buffer, rinfo: dgram.RemoteInfo) {
// Factorio sends from the socket bound to the Lua UDP port, anything
// else is some other local process.
if (rinfo.port !== this.luaUdpPort) {
this._logger.warn(`Ignoring UDP packet from unexpected source ${rinfo.address}:${rinfo.port}`);
return;
}
let channelEnd = message.indexOf("?");
if (channelEnd === -1) {
this._logger.warn(`Ignoring malformed UDP packet "${message.subarray(0, 100).toString("utf-8")}"`);
return;
}
let channel = unescapeChannel(message.subarray(0, channelEnd));
let data = message.subarray(channelEnd + 1);
if (!this.emit(`udp-${channel}`, data)) {
this._logger.warn(`Warning: Unhandled udp-${channel}`);
}
}
async _startUdp() {
if (this._udpSocket !== null) {
throw Error("UDP socket is already open");
}
const socket = dgram.createSocket("udp4");
socket.on("message", (message, rinfo) => this._handleUdpMessage(message, rinfo));
try {
await new Promise<void>((resolve, reject) => {
socket.once("error", reject);
socket.bind(0, "127.0.0.1", () => {
socket.off("error", reject);
resolve();
});
});
} catch (err: any) {
this._logger.error(`Failed to open UDP socket:\n${err?.stack ?? err?.message ?? err}`);
socket.close();
return;
}
socket.on("error", err => this.emit("error", err));
this._udpSocket = socket;
}
// Factorio before 2.1.10 crashes when a packet is received on a headless server
_checkLuaUdpVersion() {
if (this.enableLuaUdp && lib.integerFullVersion(this._version!) < lib.integerFullVersion("2.1.10")) {
throw new Error("Lua UDP requires Factorio 2.1.10 or later");
}
}
_stopUdp() {
if (this._udpSocket) {
this._udpSocket.close();
this._udpSocket = null;
}
}
/**
* UDP port on localhost the host receives Lua UDP messages on
*
* Only available while the server is running with Lua UDP enabled.
*/
get hostUdpPort(): number | undefined {
return this._udpSocket?.address().port;
}
_handleOutput(rawLine: Buffer, source: "stdout" | "stderr") {
if (rawLine.subarray(0, 6).equals(Buffer.from("\f$ipc:"))) {
this._handleIpc(rawLine).catch(err => this.emit("error", err));
@@ -1087,6 +1189,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
this._server = null;
this._rconClient = null;
this._rconReady = false;
this._udpSocket = null;
this._gameReady = false;
this._unexpected = [];
this._killed = false;
@@ -1132,6 +1235,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
if (this._rconClient) {
this._rconClient.end().catch(() => {});
}
this._stopUdp();
if (this._whitelistWatcher) {
this._whitelistWatcher.close();
@@ -1150,6 +1254,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
if (this._rconClient) {
this._rconClient.end().catch(() => {});
}
this._stopUdp();
if (this._whitelistWatcher) {
this._whitelistWatcher.close();
@@ -1248,6 +1353,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
*/
async start(save: string) {
this._check(["init"]);
this._checkLuaUdpVersion();
this._state = "running";
try {
@@ -1263,6 +1369,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
"--port", String(this.gamePort),
"--rcon-port", String(this.rconPort),
"--rcon-password", this.rconPassword,
...(this.enableLuaUdp ? ["--enable-lua-udp", String(this.luaUdpPort)] : []),
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
...(this.verboseLogging ? ["--verbose"] : []),
@@ -1300,6 +1407,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
*/
async startScenario(scenario: string, seed?: number, mapGenSettings?: object, mapSettings?: object) {
this._check(["init"]);
this._checkLuaUdpVersion();
this._state = "running";
try {
@@ -1321,6 +1429,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
"--port", String(this.gamePort),
"--rcon-port", String(this.rconPort),
"--rcon-password", this.rconPassword,
...(this.enableLuaUdp ? ["--enable-lua-udp", String(this.luaUdpPort)] : []),
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
...(this.verboseLogging ? ["--verbose"] : []),
@@ -1401,6 +1510,27 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
return response;
}
/**
* Send UDP message to the server
*
* Sends a UDP packet to the Lua UDP port of the Factorio server, which
* is delivered in-game as an on_udp_packet_received event. Delivery is
* not guaranteed. Requires Lua UDP to be enabled.
*
* @param message - payload of the packet to send.
*/
async sendUdp(message: string | Buffer) {
this._check(["running", "stopping"]);
if (!this._udpSocket) {
throw new Error("UDP socket is not open");
}
const socket = this._udpSocket;
await new Promise<void>((resolve, reject) => {
socket.send(message, this.luaUdpPort, "127.0.0.1", err => (err ? reject(err) : resolve()));
});
}
/**
* Timeout in ms to wait after a shutdown is requested before killing the
* process. Defaults to 0 meaning no timeout
@@ -1436,6 +1566,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
if (this._rconClient) {
this._rconClient.end().catch(() => {});
}
this._stopUdp();
if (this._whitelistWatcher) {
this._whitelistWatcher.close();
}
+26 -1
View File
@@ -482,6 +482,8 @@ export interface InstanceConfigFields {
"factorio.host_assigned_game_port": number | null;
"factorio.rcon_port": number | null;
"factorio.rcon_password": string | null;
"factorio.enable_lua_udp": boolean;
"factorio.lua_udp_port": number | null;
"factorio.player_online_autosave_slots": number;
"factorio.mod_pack_id": number | null;
"factorio.enable_save_patching": boolean;
@@ -606,6 +608,21 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
type: "string",
optional: true,
},
"factorio.enable_lua_udp": {
description:
"Enable UDP messaging between the Factorio server and the host. " +
"Required by plugins using UDP. Requires Factorio 2.1.10 or later.",
restartRequired: true, // Because of the cli option "--enable-lua-udp"
type: "boolean",
initialValue: false,
},
"factorio.lua_udp_port": {
description: "UDP port for the Factorio server to receive Lua UDP messages on, uses a random port if null",
restartRequired: true,
type: "number",
optional: true,
validator: validators.optional(validators.greaterThanEqualZero),
},
"factorio.player_online_autosave_slots": {
description:
"Rename autosaves where players have been online since the previous autosave into a separate " +
@@ -795,7 +812,12 @@ export function addPluginFieldDefinitions(
fieldDef.dependsOn.push("factorio.enable_script_commands");
}
if (scriptCommands || savePatching) {
const luaUdp = pluginInfo.features.includes("LuaUdp");
if (luaUdp) {
fieldDef.dependsOn.push("factorio.enable_lua_udp");
}
if (scriptCommands || savePatching || luaUdp) {
fieldDef.validator = (enabled, config) => {
if (!enabled) {
return;
@@ -806,6 +828,9 @@ export function addPluginFieldDefinitions(
if (scriptCommands && !config.get("factorio.enable_script_commands")) {
throw new Error(`Plugin ${pluginInfo.name} requires script commands`);
}
if (luaUdp && !config.get("factorio.enable_lua_udp")) {
throw new Error(`Plugin ${pluginInfo.name} requires Lua UDP`);
}
};
}
}
+2
View File
@@ -14,6 +14,8 @@ export const PluginFeatureFlags = [
"SavePatching",
/** The plugin requires access to script commands over rcon */
"ScriptCommands",
/** The plugin requires UDP messaging with the Factorio server */
"LuaUdp",
] as const;
/** Used to define the plugin export in plugins */
+6
View File
@@ -66,6 +66,12 @@ describe("host/src/BaseInstancePlugin", function() {
await assert.rejects(instancePlugin.sendRcon("a"), new Error("ref"));
});
});
describe("sendUdp", function() {
it("should send message to the instance", async function() {
await instancePlugin.sendUdp("a");
assert.deepEqual(instancePlugin.instance.server.udpMessages, ["a"]);
});
});
describe("sendOrderedRcon", function() {
it("should send commands in order", async function() {
instancePlugin.instance.server.rconCommandResults.set("a", { time: 100, response: "a" });
+12
View File
@@ -240,6 +240,18 @@ describe("class Instance", function() {
});
});
describe(".sendUdp()", function() {
it("should throw when Lua UDP is disabled", async function() {
await assert.rejects(instance.sendUdp("data"), /Lua UDP is disabled/);
assert.deepEqual(instance.server.udpMessages, []);
});
it("should send to the server when enabled", async function() {
instance.config.set("factorio.enable_lua_udp", true);
await instance.sendUdp("data");
assert.deepEqual(instance.server.udpMessages, ["data"]);
});
});
describe("._recordPlayerJoin()", function() {
it("should add player to playersOnline", function() {
instance._recordPlayerJoin("player");
+208
View File
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import dgram from "node:dgram";
import events from "node:events";
import fs from "node:fs/promises";
import path from "node:path";
@@ -336,6 +337,213 @@ describe("host/server", function() {
});
});
describe("Lua UDP", function() {
let udpServer;
let received;
before(async function() {
// Stand-in for the Factorio server's --enable-lua-udp socket
udpServer = dgram.createSocket("udp4");
received = [];
udpServer.on("message", (msg, rinfo) => received.push([msg, rinfo]));
await events.once(udpServer.bind(0, "127.0.0.1"), "listening");
server.luaUdpPort = udpServer.address().port;
});
after(function() {
udpServer.close();
});
afterEach(function() {
server._stopUdp();
server._state = "init";
});
function sendFromGame(message) {
udpServer.send(Buffer.from(message), server.hostUdpPort, "127.0.0.1");
}
function captureLogger(level) {
const logger = server._logger;
const lines = [];
server._logger = { [level]: msg => lines.push(msg) };
return { lines, restore: () => { server._logger = logger; } };
}
it("should refuse to start on Factorio before 2.1.10", async function() {
const version = server._version;
server._version = "2.1.9";
server.enableLuaUdp = true;
try {
const error = new Error("Lua UDP requires Factorio 2.1.10 or later");
await assert.rejects(server.start("save.zip"), error);
await assert.rejects(server.startScenario("test"), error);
assert.equal(server._state, "init");
} finally {
server._version = version;
server.enableLuaUdp = false;
}
});
it("should open the socket when RCON starts", async function() {
const startRcon = server._startRcon;
server._startRcon = async () => {};
server.enableLuaUdp = true;
try {
server._handleOutput(Buffer.from(
" 0.500 Info RemoteCommandProcessor.cpp:133: Starting RCON interface"
), "stdout");
while (!server._udpSocket) {
await wait(1);
}
} finally {
server._startRcon = startRcon;
server.enableLuaUdp = false;
}
assert(server.hostUdpPort > 0, "socket was not opened");
});
it("should not have a host port when not running", function() {
assert.equal(server.hostUdpPort, undefined);
});
it("should reject sendUdp when the socket is not open", async function() {
server._state = "running";
await assert.rejects(server.sendUdp("data"), new Error("UDP socket is not open"));
});
it("should send packets to the Lua UDP port", async function() {
await server._startUdp();
server._state = "running";
assert(server.hostUdpPort > 0, "hostUdpPort not set");
await server.sendUdp("hello");
await server.sendUdp(Buffer.from("world"));
while (received.length < 2) {
await wait(1);
}
assert.equal(received[0][0].toString(), "hello");
assert.equal(received[1][0].toString(), "world");
assert.equal(received[0][1].port, server.hostUdpPort);
});
it("should emit udp events for packets from the game", async function() {
await server._startUdp();
let udpReceived = [];
let onUdp = data => udpReceived.push(data);
// Escaped characters in the channel name are unescaped
server.on("udp-udp?channel", onUdp);
let waiter = events.once(server, "udp-udp?channel");
// Packets not from the Factorio server's port are ignored
let other = dgram.createSocket("udp4");
try {
await events.once(other.bind(0, "127.0.0.1"), "listening");
other.send(Buffer.from("udp\\x3fchannel?bad"), server.hostUdpPort, "127.0.0.1");
sendFromGame("udp\\x3fchannel?spam?eggs");
await waiter;
} finally {
other.close();
server.off("udp-udp?channel", onUdp);
}
assert(Buffer.isBuffer(udpReceived[0]), "data is not a Buffer");
assert.deepEqual(udpReceived.map(String), ["spam?eggs"]);
});
it("should warn on malformed packets and unhandled channels", async function() {
await server._startUdp();
const { lines, restore } = captureLogger("warn");
try {
sendFromGame("no separator");
sendFromGame("unhandled?data");
while (lines.length < 2) {
await wait(1);
}
} finally {
restore();
}
assert.deepEqual(lines, [
'Ignoring malformed UDP packet "no separator"',
"Warning: Unhandled udp-unhandled",
]);
});
it("should invoke handlers registered with handleUdp", async function() {
let handled = [];
server.handleUdp("handled", async data => { handled.push(data.toString()); });
server.handleUdp("failing", async () => { throw new Error("boom"); });
const { lines, restore } = captureLogger("error");
try {
server.emit("udp-handled", Buffer.from("data"));
server.emit("udp-failing", Buffer.from("data"));
await new Promise(resolve => setImmediate(resolve));
} finally {
restore();
}
assert.deepEqual(handled, ["data"]);
assert.equal(lines.length, 1);
assert(lines[0].startsWith("Error handling udp event:\nError: boom"), lines[0]);
});
it("should log errors thrown synchronously by handleUdp handlers", async function() {
await server._startUdp();
server.handleUdp("sync", () => { throw new Error("sync boom"); });
const { lines, restore } = captureLogger("error");
try {
sendFromGame("sync?data");
while (lines.length < 1) {
await wait(1);
}
} finally {
restore();
}
assert(lines[0].startsWith("Error handling udp event:\nError: sync boom"), lines[0]);
});
it("should forward socket errors", async function() {
await server._startUdp();
let waiter = events.once(server, "error");
server._udpSocket.emit("error", new Error("socket failed"));
let [err] = await waiter;
assert.equal(err.message, "socket failed");
});
it("should reject sendUdp when sending fails", async function() {
await server._startUdp();
server._state = "running";
// Larger than the maximum size of a UDP datagram
await assert.rejects(server.sendUdp(Buffer.alloc(70000)), { code: "EMSGSIZE" });
});
it("should throw if the socket is already open", async function() {
await server._startUdp();
await assert.rejects(server._startUdp(), new Error("UDP socket is already open"));
});
it("should log an error if opening the socket fails", async function() {
const createSocket = dgram.createSocket;
dgram.createSocket = (...args) => {
const socket = createSocket(...args);
socket.bind = () => process.nextTick(() => socket.emit("error", new Error("bind failed")));
return socket;
};
const { lines, restore } = captureLogger("error");
try {
await server._startUdp();
} finally {
dgram.createSocket = createSocket;
restore();
}
assert.equal(server._udpSocket, null);
assert(lines[0].startsWith("Failed to open UDP socket:\nError: bind failed"), lines[0]);
});
it("should close the socket when the server exits", async function() {
await server._startUdp();
server._server = new events.EventEmitter();
server._state = "stopping";
server._watchExit();
server._server.emit("exit", 0, null);
assert.equal(server._udpSocket, null);
assert.equal(server._state, "init");
});
it("should close the socket when the server is killed for hanging", async function() {
await server._startUdp();
let killed = false;
server._server = { kill: () => { killed = true; } };
const { restore } = captureLogger("error");
try {
server.onStopTimeout();
} finally {
restore();
server._server = null;
}
assert(killed, "server was not killed");
assert.equal(server._udpSocket, null);
});
});
describe(".stop()", function() {
it("should handle server quitting on its own during stop", async function() {
server.shutdownTimeoutMs = 20;
+6 -1
View File
@@ -138,6 +138,10 @@ export function hasFactorio() {
return haveFactorioInstall;
}
export function getFactorioVersion() {
return latestFactorioVersion;
}
export async function get(urlPath) {
const url = new URL("https://localhost:4443");
url.pathname = urlPath;
@@ -170,6 +174,7 @@ let controllerProcess;
let hostProcess;
let control;
let haveFactorioInstall;
let latestFactorioVersion;
const baseHostConfig = loadJSON("config-host.json");
@@ -356,7 +361,7 @@ before(async function() {
controllerProcess = await spawnNode("controller:", "../../packages/controller run", /Started controller/);
const factorioVersions = (await _listFactorioVersions(factorioDir)).versions;
const latestFactorioVersion = [...factorioVersions.values()]
latestFactorioVersion = [...factorioVersions.values()]
.sort((a, b) => lib.integerFullVersion(b) - lib.integerFullVersion(a))[0];
haveFactorioInstall = factorioVersions.size > 0;
+60 -1
View File
@@ -1,4 +1,6 @@
import path from "node:path";
import dgram from "node:dgram";
import events from "node:events";
import assert from "node:assert/strict";
import * as lib from "@clusterio/lib";
import fs from "node:fs/promises";
@@ -6,7 +8,7 @@ import fs from "node:fs/promises";
import { slowTest, testMatrix } from "../common.js";
import {
execCtl, execCtlProcess, sendRcon, getControl,
requiresFactorio, hasFactorio, instancesDir,
requiresFactorio, hasFactorio, getFactorioVersion, instancesDir,
} from "./index.js";
const instId = 48;
@@ -21,6 +23,19 @@ const requireApi = [
"or package.loaded['__level__/modules/clusterio/api.lua']", // 2.0.0
].join(" ");
// Lua UDP needs Factorio 2.1.10, see docs/configuration.md
function hasLuaUdp() {
return lib.integerFullVersion(getFactorioVersion()) >= lib.integerFullVersion("2.1.10");
}
async function freeUdpPort() {
const socket = dgram.createSocket("udp4");
await events.once(socket.bind(0, "127.0.0.1"), "listening");
const port = socket.address().port;
socket.close();
return port;
}
function getUser(name) {
return getControl().send(new lib.UserGetRequest(name));
}
@@ -105,12 +120,16 @@ describe("Clusterio Instance", function() {
}
}
describe(`Integration - ${strSavePatching}, ${strScriptCommands}`, function() {
let luaUdpPort;
before(async function() {
this.timeout(20000);
// Create and start a patched save
await execCtl(`${instSetConfig} factorio.enable_whitelist true`);
await execCtl(`${instSetConfig} factorio.enable_save_patching ${savePatchingEnabled}`);
await execCtl(`${instSetConfig} factorio.enable_script_commands ${scriptCommandsEnabled}`);
await execCtl(`${instSetConfig} factorio.enable_lua_udp ${hasLuaUdp()}`);
luaUdpPort = await freeUdpPort();
await execCtl(`${instSetConfig} factorio.lua_udp_port ${luaUdpPort}`);
await execCtl(`instance save create ${instName}`);
await execCtl(`instance start ${instName}`);
});
@@ -125,6 +144,46 @@ describe("Clusterio Instance", function() {
assert.equal(res === "Disabled\n", scriptCommandsEnabled);
});
});
if (savePatchingEnabled) {
describe("Lua UDP", function() {
it("should drop send_udp before the module is initialised", async function() {
// Mods can call send_udp from on_init, before the first tick sets up script data
await allowSendingScriptCommands();
const res = await sendRcon(instId,
`/sc ${requireApi} local data = storage or global ` +
"local saved = data.clusterio data.clusterio = nil " +
"local module_ok = pcall(api.send_udp, 'early', 'data') " +
"local mod_ok, port = pcall(remote.call, 'clusterio_api', 'get_host_udp_port') " +
"data.clusterio = saved " +
"rcon.print(tostring(module_ok) .. ' ' .. tostring(mod_ok) .. ' ' .. tostring(port))"
);
assert.equal(res, "true true nil\n");
});
it("should dispatch packets sent to the game", async function() {
if (!hasLuaUdp()) {
this.skip();
}
await allowSendingScriptCommands();
await sendRcon(instId,
"/sc script.on_event(defines.events.on_udp_packet_received, " +
"function(event) storage.udp_test = event.payload end)"
);
const socket = dgram.createSocket("udp4");
let payload;
try {
for (let i = 0; i < 10 && payload !== "hello\n"; i++) {
socket.send("hello", luaUdpPort, "127.0.0.1");
await lib.wait(10);
// The server does not tick without players, commands make it tick
payload = await sendRcon(instId, "/sc rcon.print(tostring(storage.udp_test))");
}
} finally {
socket.close();
}
assert.equal(payload, "hello\n");
});
});
}
describe("Player events", function() {
if (savePatchingEnabled) {
// IPC expects save patching to be enabled
+52
View File
@@ -125,6 +125,8 @@ describe("Integration of host/src/server", function() {
let mapPath = server.writePath("saves", "test.zip");
await assert.doesNotReject(fs.access(mapPath), "save is missing");
// Lua UDP needs Factorio 2.1.10, see docs/configuration.md
server.enableLuaUdp = lib.integerFullVersion(server.version) >= lib.integerFullVersion("2.1.10");
await server.start("test.zip");
});
});
@@ -159,6 +161,56 @@ describe("Integration of host/src/server", function() {
});
});
describe("Lua UDP", function() {
it("emits udp events for packets sent from the game", async function() {
slowTest(this);
if (!server.enableLuaUdp) {
this.skip();
}
log("Lua UDP from game");
assert(server.hostUdpPort > 0, "host UDP socket is not open");
let waiter = events.once(server, "udp-test_channel");
await server.sendRcon(`/sc helpers.send_udp(${server.hostUdpPort}, "test_channel?hello", 0)`);
let [data] = await waiter;
assert.equal(data.toString(), "hello");
});
it("delivers packets sent with sendUdp to the game", async function() {
slowTest(this);
if (!server.enableLuaUdp) {
this.skip();
}
log("Lua UDP to game");
// Poll from on_tick like the clusterio module does
await server.sendRcon(
"/sc script.on_event(defines.events.on_udp_packet_received, " +
"function(event) print('udp:' .. event.payload) end) " +
"script.on_nth_tick(1, function() helpers.recv_udp(0) end)"
);
let pass = false;
function filter(output) {
if (output.message === "udp:hello") {
pass = true;
}
}
server.on("output", filter);
await server.sendUdp("hello");
// The server does not tick without players, commands make it tick
for (let i = 0; i < 10; i++) {
await server.sendRcon("/sc rcon.print('tick')");
await lib.wait(10);
if (pass) {
break;
}
}
server.off("output", filter);
assert(pass, "server did not output the packet payload");
});
});
describe(".stop()", function() {
it("stops the server", async function() {
slowTest(this);
+29
View File
@@ -340,6 +340,35 @@ describe("lib/config/definitions/validators", function() {
/requires script commands/
);
});
it("should validate against factorio.enable_lua_udp", function() {
class TestInstanceConfig extends InstanceConfig {
static fieldDefinitions = { ...InstanceConfig.fieldDefinitions };
}
lib.addPluginFieldDefinitions(
{ name: "lua_udp", features: ["LuaUdp"] },
"instanceConfigFields", TestInstanceConfig
);
const config = new TestInstanceConfig("controller", initialConfigFields);
// valid: does not require save patching or script commands
assert.doesNotThrow(() => config.set("factorio.enable_save_patching", false));
assert.doesNotThrow(() => config.set("factorio.enable_script_commands", false));
// valid: can have load disabled
assert.doesNotThrow(() => config.set("lua_udp.load_plugin", false));
// invalid: requires lua udp when loaded (disabled by default)
assert.throws(
() => config.set("lua_udp.load_plugin", true),
/requires Lua UDP/
);
// valid: requirement satisfied
assert.doesNotThrow(() => config.set("factorio.enable_lua_udp", true));
assert.doesNotThrow(() => config.set("lua_udp.load_plugin", true));
});
it("should allow when no feature flags are present", function() {
class TestInstanceConfig extends InstanceConfig {
static fieldDefinitions = { ...InstanceConfig.fieldDefinitions };
+9
View File
@@ -95,6 +95,11 @@ export class MockServer extends events.EventEmitter {
reset() {
this.rconCommands = [];
this.rconCommandResults = new Map();
this.udpMessages = [];
}
async sendUdp(message) {
this.udpMessages.push(message);
}
async sendRcon(command) {
@@ -144,6 +149,10 @@ export class MockInstance extends lib.Link {
async sendRcon(command, expectEmpty, plugin) {
return await this.server.sendRcon(command);
}
async sendUdp(message) {
await this.server.sendUdp(message);
}
}
export class MockHost extends lib.Link {