From 788ae176572751404379448c15f018c0030f1d27 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:08:10 +0000 Subject: [PATCH 1/9] Add UDP communication support for instances Add opt-in UDP messaging between the host and the Factorio server using the --enable-lua-udp option of Factorio 2.0.59+. When factorio.enable_lua_udp is set the instance binds a localhost UDP socket, starts Factorio with --enable-lua-udp on factorio.lua_udp_port (random if null, like the RCON port) and tells the clusterio module the host port via clusterio_private.update_instance. Data from Factorio is sent with clusterio_api.send_udp(channel, data) in the same IPC format as send_json, so plugins receive it through the existing ipc- events. Packets not sent from the Lua UDP port are ignored, as that is the only unauthenticated way into the IPC handler. Data to Factorio is sent with the new sendUdp method on instances and instance plugins, and is dispatched in-game as on_udp_packet_received events by polling helpers.recv_udp every tick from the clusterio module. Plugins requiring UDP declare the new LuaUdp feature flag, which like SavePatching and ScriptCommands refuses to load the plugin on instances without the option enabled. Verified against a headless 2.1.17 server: send_udp and recv_udp are silent no-ops when the option is off, so a stale port in a save that is moved to an instance without UDP does no harm. The integration test matrix now starts the instance with UDP enabled on Factorio 2.0.59+ and round trips a player event over it. Closes #969 Co-Authored-By: Claude Fable 5.1 --- docs/configuration.md | 17 +++++ docs/developing-for-clusterio.md | 14 ++++ docs/writing-plugins.md | 27 ++++++- packages/host/lua/clusterio_lib/api.lua | 26 +++++-- packages/host/modules/clusterio/api.lua | 19 ++++- packages/host/modules/clusterio/impl.lua | 17 ++++- packages/host/src/BaseInstancePlugin.ts | 16 ++++ packages/host/src/Instance.ts | 22 +++++- packages/host/src/server.ts | 97 ++++++++++++++++++++++++ packages/lib/src/config/definitions.ts | 27 ++++++- packages/lib/src/plugin.ts | 2 + test/host/server.js | 65 ++++++++++++++++ test/integration/index.js | 7 +- test/integration/instance.js | 26 ++++++- test/lib/config/validators.test.js | 29 +++++++ 15 files changed, 394 insertions(+), 17 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 8bf2ed82..817c5f94 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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.0.59 or later. + +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. diff --git a/docs/developing-for-clusterio.md b/docs/developing-for-clusterio.md index a311af44..39ef4be1 100644 --- a/docs/developing-for-clusterio.md +++ b/docs/developing-for-clusterio.md @@ -251,6 +251,20 @@ Parameters: - `channel`: string identifying which channel to send it on. - `data`: table that can be converted to JSON with game.table_to_json +#### clusterio_api.send_udp(channel, data) + +Send JSON data to Clusterio over the given channel using UDP instead of stdout. +Clusterio plugins receive it the same way as data sent with `send_json`. +Requires `factorio.enable_lua_udp` to be enabled on the instance and Factorio 2.0.59 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. +See the [Communicating over UDP section](writing-plugins.md#communicating-over-udp) in the Writing Plugins document for more information. + +**Note**: Delivery is not guaranteed and payloads must be less than 64 kB. + +Parameters: +- `channel`: string identifying which channel to send it on. +- `data`: table that can be converted to JSON with game.table_to_json + ### serialize library diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index a1c6cb83..4b8f704d 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -131,7 +131,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**: @@ -286,7 +286,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 = { @@ -482,6 +482,29 @@ 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 2.0.59 and later. +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 64 kB. + +Data out from Factorio is sent with the `send_udp` API of the Clusterio module and is delivered to plugins as the same `ipc-channel_name` events as `send_json` uses: + +```lua +clusterio_api.send_udp("my_plugin_foo", { data = 123 }) +``` + +Data into Factorio is sent with the `sendUdp` method on the plugin object 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 +async onStart() { + await this.sendUdp("my_plugin_foo:123"); +} +``` + ## Defining Link Messages diff --git a/packages/host/lua/clusterio_lib/api.lua b/packages/host/lua/clusterio_lib/api.lua index 6af5463b..a9242489 100644 --- a/packages/host/lua/clusterio_lib/api.lua +++ b/packages/host/lua/clusterio_lib/api.lua @@ -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,16 @@ 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 + + channel = escape_channel(channel) + data = compat.table_to_json(data) + helpers.send_udp(port, "\f$ipc:" .. channel .. "?j" .. data, 0) +end + return api diff --git a/packages/host/modules/clusterio/api.lua b/packages/host/modules/clusterio/api.lua index 49ffda06..8c1ad4ac 100644 --- a/packages/host/modules/clusterio/api.lua +++ b/packages/host/modules/clusterio/api.lua @@ -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,16 @@ function api.send_json(channel, data) end end +function api.send_udp(channel, data) + local port = compat.script_data.clusterio.host_udp_port + if not port then + return -- Lua UDP is not enabled for this instance + end + + channel = escape_channel(channel) + data = compat.table_to_json(data) + helpers.send_udp(port, "\f$ipc:" .. channel .. "?j" .. data, 0) +end + return api diff --git a/packages/host/modules/clusterio/impl.lua b/packages/host/modules/clusterio/impl.lua index 37ecf81e..202058f6 100644 --- a/packages/host/modules/clusterio/impl.lua +++ b/packages/host/modules/clusterio/impl.lua @@ -15,13 +15,21 @@ 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 + local clusterio = compat.script_data.clusterio + if clusterio and clusterio.host_udp_port 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 +82,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 +114,10 @@ remote.add_interface('clusterio_api', { return compat.script_data.clusterio.instance_name end, + get_host_udp_port = function() + return compat.script_data.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 diff --git a/packages/host/src/BaseInstancePlugin.ts b/packages/host/src/BaseInstancePlugin.ts index 457c4bbe..dd06c114 100644 --- a/packages/host/src/BaseInstancePlugin.ts +++ b/packages/host/src/BaseInstancePlugin.ts @@ -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 { + await this.instance.sendUdp(message); + } + /** * Send serially ordered RCON message to instance * diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index ed43019b..0190e5ad 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -232,6 +232,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"), @@ -505,6 +507,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) { @@ -1089,7 +1108,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, previous: Record) { diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts index 943660be..000b6c40 100644 --- a/packages/host/src/server.ts +++ b/packages/host/src/server.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import { type FSWatcher, watch as fsWatch, writeFileSync } from "node:fs"; import child_process from "child_process"; +import dgram from "dgram"; import path from "path"; import JSZip from "jszip"; import events from "events"; @@ -542,6 +543,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. */ @@ -607,6 +612,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- - invoked with data sent by the clusterio module in-game, + * either via stdout or via UDP when Lua UDP is enabled * @extends events.EventEmitter */ export class FactorioServer extends events.EventEmitter { @@ -618,6 +625,10 @@ export class FactorioServer extends events.EventEmitter { 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 **/ @@ -647,6 +658,7 @@ export class FactorioServer extends events.EventEmitter { _server: child_process.ChildProcessWithoutNullStreams | null = null; _rconClient: Rcon | null= null; _rconReady = false; + _udpSocket: dgram.Socket | null = null; _gameReady = false; _stripRegExp?: RegExp; _maxConcurrentCommands = 5; @@ -679,6 +691,10 @@ export class FactorioServer extends events.EventEmitter { 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 **/ @@ -791,6 +807,57 @@ export class FactorioServer extends events.EventEmitter { } } + _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; + } + + if (message.subarray(0, 6).equals(Buffer.from("\f$ipc:"))) { + this._handleIpc(message).catch(err => this.emit("error", err)); + return; + } + + this._logger.warn(`Ignoring UDP packet not in IPC format: ${message.subarray(0, 100).toString("utf-8")}`); + } + + async _bindUdpSocket() { + const socket = dgram.createSocket("udp4"); + socket.on("message", (message, rinfo) => this._handleUdpMessage(message, rinfo)); + try { + await new Promise((resolve, reject) => { + socket.once("error", reject); + socket.bind(0, "127.0.0.1", () => { + socket.off("error", reject); + resolve(); + }); + }); + } catch (err) { + socket.close(); + throw err; + } + socket.on("error", err => this.emit("error", err)); + this._udpSocket = socket; + } + + _closeUdpSocket() { + 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)); @@ -1063,6 +1130,7 @@ export class FactorioServer extends events.EventEmitter { this._server = null; this._rconClient = null; this._rconReady = false; + this._closeUdpSocket(); this._gameReady = false; this._unexpected = []; this._killed = false; @@ -1224,6 +1292,9 @@ export class FactorioServer extends events.EventEmitter { */ async start(save: string) { this._check(["init"]); + if (this.enableLuaUdp) { + await this._bindUdpSocket(); + } this._state = "running"; try { @@ -1239,6 +1310,7 @@ export class FactorioServer extends events.EventEmitter { "--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"] : []), @@ -1276,6 +1348,9 @@ export class FactorioServer extends events.EventEmitter { */ async startScenario(scenario: string, seed?: number, mapGenSettings?: object, mapSettings?: object) { this._check(["init"]); + if (this.enableLuaUdp) { + await this._bindUdpSocket(); + } this._state = "running"; try { @@ -1297,6 +1372,7 @@ export class FactorioServer extends events.EventEmitter { "--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"] : []), @@ -1377,6 +1453,27 @@ export class FactorioServer extends events.EventEmitter { 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("Lua UDP is not enabled"); + } + + const socket = this._udpSocket; + await new Promise((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 diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index cfed9f21..ad90b3f1 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -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 { 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.0.59 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`); + } }; } } diff --git a/packages/lib/src/plugin.ts b/packages/lib/src/plugin.ts index 9ff338ab..18d581aa 100644 --- a/packages/lib/src/plugin.ts +++ b/packages/lib/src/plugin.ts @@ -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 */ diff --git a/test/host/server.js b/test/host/server.js index c8965ed6..75b5d9c7 100644 --- a/test/host/server.js +++ b/test/host/server.js @@ -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,70 @@ 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"); + }); + after(function() { + udpServer.close(); + }); + + it("should not have a host port when not running", function() { + assert.equal(server.hostUdpPort, undefined); + }); + it("should reject sendUdp when not enabled", async function() { + server._state = "running"; + try { + await assert.rejects(server.sendUdp("data"), new Error("Lua UDP is not enabled")); + } finally { + server._state = "init"; + } + }); + it("should bind a socket, send and receive ipc over UDP", async function() { + server.enableLuaUdp = true; + server.luaUdpPort = udpServer.address().port; + await server._bindUdpSocket(); + server._state = "running"; + try { + 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); + + let ipcReceived = []; + let onIpc = content => ipcReceived.push(content); + server.on("ipc-udp_channel", onIpc); + let waiter = events.once(server, "ipc-udp_channel"); + // Packets not from the Factorio server's port are ignored + let other = dgram.createSocket("udp4"); + await events.once(other.bind(0, "127.0.0.1"), "listening"); + other.send(Buffer.from('\f$ipc:udp_channel?j{"data":"bad"}'), server.hostUdpPort, "127.0.0.1"); + udpServer.send(Buffer.from('\f$ipc:udp_channel?j{"data":"spam"}'), server.hostUdpPort, "127.0.0.1"); + await waiter; + server.off("ipc-udp_channel", onIpc); + other.close(); + assert.deepEqual(ipcReceived, [{ data: "spam" }]); + } finally { + server._resetState(); + server.enableLuaUdp = false; + } + assert.equal(server._udpSocket, null); + assert.equal(server.hostUdpPort, undefined); + }); + }); + describe(".stop()", function() { it("should handle server quitting on its own during stop", async function() { server.shutdownTimeoutMs = 20; diff --git a/test/integration/index.js b/test/integration/index.js index 5793a5d3..ccb977e7 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -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; diff --git a/test/integration/instance.js b/test/integration/instance.js index b46529a1..56d75e72 100644 --- a/test/integration/instance.js +++ b/test/integration/instance.js @@ -6,7 +6,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 +21,11 @@ const requireApi = [ "or package.loaded['__level__/modules/clusterio/api.lua']", // 2.0.0 ].join(" "); +// --enable-lua-udp was added in Factorio 2.0.59 +function hasLuaUdp() { + return lib.integerFullVersion(getFactorioVersion()) >= lib.integerFullVersion("2.0.59"); +} + function getUser(name) { return getControl().send(new lib.UserGetRequest(name)); } @@ -111,6 +116,7 @@ describe("Clusterio Instance", function() { 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()}`); await execCtl(`instance save create ${instName}`); await execCtl(`instance start ${instName}`); }); @@ -152,6 +158,24 @@ describe("Clusterio Instance", function() { const userLeave = await getUser("JoiningPlayer"); assert(!userLeave.instances.has(instId), "Player is not shown as offline"); }); + it("should respond to a player join and leave event sent over UDP", async function() { + // The host port is passed to the module by updateInstanceData on startup + if (!hasLuaUdp() || !scriptCommandsEnabled) { + this.skip(); + } + await sendRcon(instId, + `/sc ${requireApi} api.send_udp("player_event",` + + "{ type='join', name='UdpPlayer' })" + ); + const userJoin = await getUser("UdpPlayer"); + assert(userJoin.instances.has(instId), "Player is not shown as online"); + await sendRcon(instId, + `/sc ${requireApi} api.send_udp("player_event",` + + "{ type='leave', name='UdpPlayer', reason='quit' })" + ); + const userLeave = await getUser("UdpPlayer"); + assert(!userLeave.instances.has(instId), "Player is not shown as offline"); + }); } it("should respond to a player ban and unban event", async function() { await execCtl(`${instSetConfig} factorio.sync_banlist bidirectional`); diff --git a/test/lib/config/validators.test.js b/test/lib/config/validators.test.js index 1ae9de05..db5113f4 100644 --- a/test/lib/config/validators.test.js +++ b/test/lib/config/validators.test.js @@ -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 }; From 4b0238b64b3878d9d016ac7afb75b74a9624c5ee Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:05:13 +0000 Subject: [PATCH 2/9] Cover UDP error paths and sendUdp in tests Codecov flagged the sendUdp methods on Instance and BaseInstancePlugin, the bind, socket error, malformed packet and send failure paths in FactorioServer, and the --enable-lua-udp branch of startScenario. Add unit tests for each and start the integration scenario test with UDP enabled on Factorio 2.0.59+. Co-Authored-By: Claude Fable 5.1 --- test/host/BaseInstancePlugin.js | 6 +++ test/host/Instance.js | 12 ++++++ test/host/server.js | 65 +++++++++++++++++++++++++++++++++ test/integration/server.js | 3 ++ test/mock.js | 9 +++++ 5 files changed, 95 insertions(+) diff --git a/test/host/BaseInstancePlugin.js b/test/host/BaseInstancePlugin.js index 10808b40..9e7dbd30 100644 --- a/test/host/BaseInstancePlugin.js +++ b/test/host/BaseInstancePlugin.js @@ -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" }); diff --git a/test/host/Instance.js b/test/host/Instance.js index f396c105..9d4562a5 100644 --- a/test/host/Instance.js +++ b/test/host/Instance.js @@ -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"); diff --git a/test/host/server.js b/test/host/server.js index 75b5d9c7..b6cc542d 100644 --- a/test/host/server.js +++ b/test/host/server.js @@ -399,6 +399,71 @@ describe("host/server", function() { assert.equal(server._udpSocket, null); assert.equal(server.hostUdpPort, undefined); }); + it("should warn on packets not in IPC format", async function() { + server.luaUdpPort = udpServer.address().port; + await server._bindUdpSocket(); + const logger = server._logger; + let warnings = []; + server._logger = { warn: msg => warnings.push(msg) }; + try { + udpServer.send(Buffer.from("not ipc"), server.hostUdpPort, "127.0.0.1"); + while (warnings.length < 1) { + await wait(1); + } + assert.equal(warnings[0], "Ignoring UDP packet not in IPC format: not ipc"); + } finally { + server._logger = logger; + server._resetState(); + } + }); + it("should emit error on malformed IPC packets", async function() { + server.luaUdpPort = udpServer.address().port; + await server._bindUdpSocket(); + try { + let waiter = events.once(server, "error"); + udpServer.send(Buffer.from("\f$ipc:no_separator"), server.hostUdpPort, "127.0.0.1"); + let [err] = await waiter; + assert.equal(err.message, 'Malformed IPC line "\f$ipc:no_separator"'); + } finally { + server._resetState(); + } + }); + it("should forward socket errors", async function() { + await server._bindUdpSocket(); + try { + let waiter = events.once(server, "error"); + server._udpSocket.emit("error", new Error("socket failed")); + let [err] = await waiter; + assert.equal(err.message, "socket failed"); + } finally { + server._resetState(); + } + }); + it("should reject sendUdp when sending fails", async function() { + server.luaUdpPort = udpServer.address().port; + await server._bindUdpSocket(); + server._state = "running"; + try { + // Larger than the maximum size of a UDP datagram + await assert.rejects(server.sendUdp(Buffer.alloc(70000)), { code: "EMSGSIZE" }); + } finally { + server._resetState(); + } + }); + it("should close the socket if binding 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; + }; + try { + await assert.rejects(server._bindUdpSocket(), new Error("bind failed")); + } finally { + dgram.createSocket = createSocket; + } + assert.equal(server._udpSocket, null); + }); }); describe(".stop()", function() { diff --git a/test/integration/server.js b/test/integration/server.js index b74af0e9..e4e9ade2 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -179,6 +179,8 @@ describe("Integration of host/src/server", function() { slowTest(this); log(".startScenario()"); + // --enable-lua-udp was added in Factorio 2.0.59 + server.enableLuaUdp = lib.integerFullVersion(server.version) >= lib.integerFullVersion("2.0.59"); let pass = false; function filter(output) { if (output.message === "test_scenario init") { @@ -191,6 +193,7 @@ describe("Integration of host/src/server", function() { log(".stop()"); await server.stop(); + server.enableLuaUdp = false; server.off("output", filter); assert(pass, "server did not output line from test scenario"); diff --git a/test/mock.js b/test/mock.js index 265e869a..9b2d9ef6 100644 --- a/test/mock.js +++ b/test/mock.js @@ -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 { From 7a64706c823c6eb71b569d213f1effcdedd79e33 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:00:36 +0000 Subject: [PATCH 3/9] Send raw payloads over UDP and emit them as udp events Review feedback from Cooldude2606: UDP is most useful for arbitrary blobs, so send_udp now takes any string and the packet is just "channel?payload". Plugins get it as a udp- event with the payload as a Buffer, or through handleUdp which logs handler errors like handle does for ipc events. The host socket is now opened when the RCON client is started and closed where the RCON client is ended, matching how the RCON client is handled, instead of being bound in start() and closed by _resetState. The integration round trip moved to the FactorioServer tests, where it sends a packet each way through a real server without the module. Co-Authored-By: Claude Fable 5.1 --- docs/developing-for-clusterio.md | 9 +- docs/writing-plugins.md | 18 ++- packages/host/lua/clusterio_lib/api.lua | 4 +- packages/host/modules/clusterio/api.lua | 4 +- packages/host/src/server.ts | 70 +++++---- test/host/server.js | 181 ++++++++++++++---------- test/integration/instance.js | 18 --- test/integration/server.js | 52 ++++++- 8 files changed, 222 insertions(+), 134 deletions(-) diff --git a/docs/developing-for-clusterio.md b/docs/developing-for-clusterio.md index 39ef4be1..8b11ef0c 100644 --- a/docs/developing-for-clusterio.md +++ b/docs/developing-for-clusterio.md @@ -253,17 +253,16 @@ Parameters: #### clusterio_api.send_udp(channel, data) -Send JSON data to Clusterio over the given channel using UDP instead of stdout. -Clusterio plugins receive it the same way as data sent with `send_json`. +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.0.59 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. -See the [Communicating over UDP section](writing-plugins.md#communicating-over-udp) in the Writing Plugins document for more information. -**Note**: Delivery is not guaranteed and payloads must be less than 64 kB. +**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`: table that can be converted to JSON with game.table_to_json +- `data`: string with the payload to send, any bytes are allowed. ### serialize library diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index 4b8f704d..6df458dc 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -488,14 +488,26 @@ Instances can optionally exchange UDP packets with the Factorio server for low l 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 64 kB. +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 and is delivered to plugins as the same `ipc-channel_name` events as `send_json` uses: +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", { data = 123 }) +clusterio_api.send_udp("my_plugin_foo", "123") ``` +```js +async init() { + this.instance.server.handleUdp("my_plugin_foo", async data => { + this.logger.info(`got ${data.toString()}`); + }); +} +``` + +Like `handle` for `send_json`, `handleUdp` logs errors thrown by the handler. +Use `this.instance.server.on("udp-my_plugin_foo", ...)` for custom error handling. + Data into Factorio is sent with the `sendUdp` method on the plugin object 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. diff --git a/packages/host/lua/clusterio_lib/api.lua b/packages/host/lua/clusterio_lib/api.lua index a9242489..8aee448d 100644 --- a/packages/host/lua/clusterio_lib/api.lua +++ b/packages/host/lua/clusterio_lib/api.lua @@ -73,9 +73,7 @@ function api.send_udp(channel, data) return -- Lua UDP is not enabled for this instance end - channel = escape_channel(channel) - data = compat.table_to_json(data) - helpers.send_udp(port, "\f$ipc:" .. channel .. "?j" .. data, 0) + helpers.send_udp(port, escape_channel(channel) .. "?" .. data, 0) end diff --git a/packages/host/modules/clusterio/api.lua b/packages/host/modules/clusterio/api.lua index 8c1ad4ac..ec42588c 100644 --- a/packages/host/modules/clusterio/api.lua +++ b/packages/host/modules/clusterio/api.lua @@ -49,9 +49,7 @@ function api.send_udp(channel, data) return -- Lua UDP is not enabled for this instance end - channel = escape_channel(channel) - data = compat.table_to_json(data) - helpers.send_udp(port, "\f$ipc:" .. channel .. "?j" .. data, 0) + helpers.send_udp(port, escape_channel(channel) .. "?" .. data, 0) end diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts index 000b6c40..a9aec3a7 100644 --- a/packages/host/src/server.ts +++ b/packages/host/src/server.ts @@ -290,6 +290,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 * @@ -412,6 +421,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); }); + } }, }, @@ -591,8 +603,9 @@ type FactorioServerEvents = { "_saving": [], "_saved": [], - // IPS events + // IPC events [ipcEvent: `ipc-${string}`]: [ event: any ], + [udpEvent: `udp-${string}`]: [ data: Buffer ], }; /** @@ -612,8 +625,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- - invoked with data sent by the clusterio module in-game, - * either via stdout or via UDP when Lua UDP is enabled + * - ipc- - invoked with data sent with send_json in-game + * - udp- - invoked with data sent with send_udp in-game * @extends events.EventEmitter */ export class FactorioServer extends events.EventEmitter { @@ -757,19 +770,19 @@ export class FactorioServer extends events.EventEmitter { })); } + handleUdp(channel: string, handler: (data: Buffer) => Promise) { + this.on(`udp-${channel}`, (data) => handler(data).catch((err: Error) => { + 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; @@ -815,15 +828,24 @@ export class FactorioServer extends events.EventEmitter { return; } - if (message.subarray(0, 6).equals(Buffer.from("\f$ipc:"))) { - this._handleIpc(message).catch(err => this.emit("error", err)); + let channelEnd = message.indexOf("?"); + if (channelEnd === -1) { + this._logger.warn(`Ignoring malformed UDP packet "${message.subarray(0, 100).toString("utf-8")}"`); return; } - this._logger.warn(`Ignoring UDP packet not in IPC format: ${message.subarray(0, 100).toString("utf-8")}`); + 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 _bindUdpSocket() { + 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 { @@ -834,15 +856,16 @@ export class FactorioServer extends events.EventEmitter { resolve(); }); }); - } catch (err) { + } catch (err: any) { + this._logger.error(`Failed to open UDP socket:\n${err?.stack ?? err?.message ?? err}`); socket.close(); - throw err; + return; } socket.on("error", err => this.emit("error", err)); this._udpSocket = socket; } - _closeUdpSocket() { + _stopUdp() { if (this._udpSocket) { this._udpSocket.close(); this._udpSocket = null; @@ -1130,7 +1153,7 @@ export class FactorioServer extends events.EventEmitter { this._server = null; this._rconClient = null; this._rconReady = false; - this._closeUdpSocket(); + this._udpSocket = null; this._gameReady = false; this._unexpected = []; this._killed = false; @@ -1176,6 +1199,7 @@ export class FactorioServer extends events.EventEmitter { if (this._rconClient) { this._rconClient.end().catch(() => {}); } + this._stopUdp(); if (this._whitelistWatcher) { this._whitelistWatcher.close(); @@ -1194,6 +1218,7 @@ export class FactorioServer extends events.EventEmitter { if (this._rconClient) { this._rconClient.end().catch(() => {}); } + this._stopUdp(); if (this._whitelistWatcher) { this._whitelistWatcher.close(); @@ -1292,9 +1317,6 @@ export class FactorioServer extends events.EventEmitter { */ async start(save: string) { this._check(["init"]); - if (this.enableLuaUdp) { - await this._bindUdpSocket(); - } this._state = "running"; try { @@ -1348,9 +1370,6 @@ export class FactorioServer extends events.EventEmitter { */ async startScenario(scenario: string, seed?: number, mapGenSettings?: object, mapSettings?: object) { this._check(["init"]); - if (this.enableLuaUdp) { - await this._bindUdpSocket(); - } this._state = "running"; try { @@ -1465,7 +1484,7 @@ export class FactorioServer extends events.EventEmitter { async sendUdp(message: string | Buffer) { this._check(["running", "stopping"]); if (!this._udpSocket) { - throw new Error("Lua UDP is not enabled"); + throw new Error("UDP socket is not open"); } const socket = this._udpSocket; @@ -1509,6 +1528,7 @@ export class FactorioServer extends events.EventEmitter { if (this._rconClient) { this._rconClient.end().catch(() => {}); } + this._stopUdp(); if (this._whitelistWatcher) { this._whitelistWatcher.close(); } diff --git a/test/host/server.js b/test/host/server.js index b6cc542d..18fd3628 100644 --- a/test/host/server.js +++ b/test/host/server.js @@ -346,123 +346,156 @@ describe("host/server", function() { 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 not have a host port when not running", function() { assert.equal(server.hostUdpPort, undefined); }); - it("should reject sendUdp when not enabled", async function() { + it("should reject sendUdp when the socket is not open", async function() { server._state = "running"; - try { - await assert.rejects(server.sendUdp("data"), new Error("Lua UDP is not enabled")); - } finally { - server._state = "init"; - } + await assert.rejects(server.sendUdp("data"), new Error("UDP socket is not open")); }); - it("should bind a socket, send and receive ipc over UDP", async function() { - server.enableLuaUdp = true; - server.luaUdpPort = udpServer.address().port; - await server._bindUdpSocket(); + 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 { - 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); - - let ipcReceived = []; - let onIpc = content => ipcReceived.push(content); - server.on("ipc-udp_channel", onIpc); - let waiter = events.once(server, "ipc-udp_channel"); - // Packets not from the Factorio server's port are ignored - let other = dgram.createSocket("udp4"); await events.once(other.bind(0, "127.0.0.1"), "listening"); - other.send(Buffer.from('\f$ipc:udp_channel?j{"data":"bad"}'), server.hostUdpPort, "127.0.0.1"); - udpServer.send(Buffer.from('\f$ipc:udp_channel?j{"data":"spam"}'), server.hostUdpPort, "127.0.0.1"); + other.send(Buffer.from("udp\\x3fchannel?bad"), server.hostUdpPort, "127.0.0.1"); + sendFromGame("udp\\x3fchannel?spam?eggs"); await waiter; - server.off("ipc-udp_channel", onIpc); - other.close(); - assert.deepEqual(ipcReceived, [{ data: "spam" }]); } finally { - server._resetState(); - server.enableLuaUdp = false; + other.close(); + server.off("udp-udp?channel", onUdp); } - assert.equal(server._udpSocket, null); - assert.equal(server.hostUdpPort, undefined); + assert(Buffer.isBuffer(udpReceived[0]), "data is not a Buffer"); + assert.deepEqual(udpReceived.map(String), ["spam?eggs"]); }); - it("should warn on packets not in IPC format", async function() { - server.luaUdpPort = udpServer.address().port; - await server._bindUdpSocket(); - const logger = server._logger; - let warnings = []; - server._logger = { warn: msg => warnings.push(msg) }; + it("should warn on malformed packets and unhandled channels", async function() { + await server._startUdp(); + const { lines, restore } = captureLogger("warn"); try { - udpServer.send(Buffer.from("not ipc"), server.hostUdpPort, "127.0.0.1"); - while (warnings.length < 1) { + sendFromGame("no separator"); + sendFromGame("unhandled?data"); + while (lines.length < 2) { await wait(1); } - assert.equal(warnings[0], "Ignoring UDP packet not in IPC format: not ipc"); } finally { - server._logger = logger; - server._resetState(); + restore(); } + assert.deepEqual(lines, [ + 'Ignoring malformed UDP packet "no separator"', + "Warning: Unhandled udp-unhandled", + ]); }); - it("should emit error on malformed IPC packets", async function() { - server.luaUdpPort = udpServer.address().port; - await server._bindUdpSocket(); + 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 { - let waiter = events.once(server, "error"); - udpServer.send(Buffer.from("\f$ipc:no_separator"), server.hostUdpPort, "127.0.0.1"); - let [err] = await waiter; - assert.equal(err.message, 'Malformed IPC line "\f$ipc:no_separator"'); + server.emit("udp-handled", Buffer.from("data")); + server.emit("udp-failing", Buffer.from("data")); + await new Promise(resolve => setImmediate(resolve)); } finally { - server._resetState(); + restore(); } + assert.deepEqual(handled, ["data"]); + assert.equal(lines.length, 1); + assert(lines[0].startsWith("Error handling udp event:\nError: boom"), lines[0]); }); it("should forward socket errors", async function() { - await server._bindUdpSocket(); - try { - let waiter = events.once(server, "error"); - server._udpSocket.emit("error", new Error("socket failed")); - let [err] = await waiter; - assert.equal(err.message, "socket failed"); - } finally { - server._resetState(); - } + 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() { - server.luaUdpPort = udpServer.address().port; - await server._bindUdpSocket(); + await server._startUdp(); server._state = "running"; - try { - // Larger than the maximum size of a UDP datagram - await assert.rejects(server.sendUdp(Buffer.alloc(70000)), { code: "EMSGSIZE" }); - } finally { - server._resetState(); - } + // Larger than the maximum size of a UDP datagram + await assert.rejects(server.sendUdp(Buffer.alloc(70000)), { code: "EMSGSIZE" }); }); - it("should close the socket if binding fails", async function() { + 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 assert.rejects(server._bindUdpSocket(), new Error("bind failed")); + 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); }); }); diff --git a/test/integration/instance.js b/test/integration/instance.js index 56d75e72..79278f80 100644 --- a/test/integration/instance.js +++ b/test/integration/instance.js @@ -158,24 +158,6 @@ describe("Clusterio Instance", function() { const userLeave = await getUser("JoiningPlayer"); assert(!userLeave.instances.has(instId), "Player is not shown as offline"); }); - it("should respond to a player join and leave event sent over UDP", async function() { - // The host port is passed to the module by updateInstanceData on startup - if (!hasLuaUdp() || !scriptCommandsEnabled) { - this.skip(); - } - await sendRcon(instId, - `/sc ${requireApi} api.send_udp("player_event",` + - "{ type='join', name='UdpPlayer' })" - ); - const userJoin = await getUser("UdpPlayer"); - assert(userJoin.instances.has(instId), "Player is not shown as online"); - await sendRcon(instId, - `/sc ${requireApi} api.send_udp("player_event",` + - "{ type='leave', name='UdpPlayer', reason='quit' })" - ); - const userLeave = await getUser("UdpPlayer"); - assert(!userLeave.instances.has(instId), "Player is not shown as offline"); - }); } it("should respond to a player ban and unban event", async function() { await execCtl(`${instSetConfig} factorio.sync_banlist bidirectional`); diff --git a/test/integration/server.js b/test/integration/server.js index e4e9ade2..8c3b8075 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -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"); + // --enable-lua-udp was added in Factorio 2.0.59 + server.enableLuaUdp = lib.integerFullVersion(server.version) >= lib.integerFullVersion("2.0.59"); await server.start("test.zip"); }); }); @@ -159,6 +161,53 @@ 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"); + + await server.sendRcon( + "/sc script.on_event(defines.events.on_udp_packet_received, " + + "function(event) print('udp:' .. event.payload) end)" + ); + let pass = false; + function filter(output) { + if (output.message === "udp:hello") { + pass = true; + } + } + server.on("output", filter); + + await server.sendUdp("hello"); + for (let i = 0; i < 10; i++) { + await server.sendRcon("/sc helpers.recv_udp(0)"); + 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); @@ -179,8 +228,6 @@ describe("Integration of host/src/server", function() { slowTest(this); log(".startScenario()"); - // --enable-lua-udp was added in Factorio 2.0.59 - server.enableLuaUdp = lib.integerFullVersion(server.version) >= lib.integerFullVersion("2.0.59"); let pass = false; function filter(output) { if (output.message === "test_scenario init") { @@ -193,7 +240,6 @@ describe("Integration of host/src/server", function() { log(".stop()"); await server.stop(); - server.enableLuaUdp = false; server.off("output", filter); assert(pass, "server did not output line from test scenario"); From 1f37905be0cb07b3cc7e5e0ee3d5810c323c1622 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:26:27 +0000 Subject: [PATCH 4/9] Require Factorio 2.1.12 for Lua UDP Factorio 2.0.59 through 2.1.11 crash with "Trying to add invalid input action to the closure" when helpers.recv_udp dispatches a received packet, whether polled from on_tick or called from a command and whether or not the server is paused. Reproduced on headless 2.0.77 and 2.1.8, works from 2.1.12 on. FactorioServer now refuses to start with Lua UDP enabled on older versions, the docs say 2.1.12, and the integration tests only enable it on 2.1.12 or later. The to-game integration test polls recv_udp from on_tick like the clusterio module does and sends commands to make the empty server tick. Co-Authored-By: Claude Fable 5.1 --- docs/configuration.md | 2 +- docs/developing-for-clusterio.md | 2 +- docs/writing-plugins.md | 3 ++- packages/host/src/server.ts | 9 +++++++++ packages/lib/src/config/definitions.ts | 2 +- test/host/server.js | 14 ++++++++++++++ test/integration/instance.js | 4 ++-- test/integration/server.js | 11 +++++++---- 8 files changed, 37 insertions(+), 10 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 817c5f94..be96b9d1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -610,7 +610,7 @@ Defaults to true. 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.0.59 or later. +Requires Factorio 2.1.12 or later, earlier versions crash when a packet is received while the server is paused. Defaults to false. diff --git a/docs/developing-for-clusterio.md b/docs/developing-for-clusterio.md index 8b11ef0c..f096af55 100644 --- a/docs/developing-for-clusterio.md +++ b/docs/developing-for-clusterio.md @@ -255,7 +255,7 @@ Parameters: 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.0.59 or later, otherwise the data is silently dropped. +Requires `factorio.enable_lua_udp` to be enabled on the instance and Factorio 2.1.12 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. diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index 6df458dc..8479c099 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -484,7 +484,8 @@ For RCON, commands longer than 50 characters may end up being executed after sho ### 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 2.0.59 and later. +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.12 or later, earlier versions crash when a packet is received while the server is paused. 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. diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts index a9aec3a7..55f298a0 100644 --- a/packages/host/src/server.ts +++ b/packages/host/src/server.ts @@ -865,6 +865,13 @@ export class FactorioServer extends events.EventEmitter { this._udpSocket = socket; } + // Factorio before 2.1.12 crashes when a packet is received while paused + _checkLuaUdpVersion() { + if (this.enableLuaUdp && lib.integerFullVersion(this._version!) < lib.integerFullVersion("2.1.12")) { + throw new Error("Lua UDP requires Factorio 2.1.12 or later"); + } + } + _stopUdp() { if (this._udpSocket) { this._udpSocket.close(); @@ -1317,6 +1324,7 @@ export class FactorioServer extends events.EventEmitter { */ async start(save: string) { this._check(["init"]); + this._checkLuaUdpVersion(); this._state = "running"; try { @@ -1370,6 +1378,7 @@ export class FactorioServer extends events.EventEmitter { */ async startScenario(scenario: string, seed?: number, mapGenSettings?: object, mapSettings?: object) { this._check(["init"]); + this._checkLuaUdpVersion(); this._state = "running"; try { diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index ad90b3f1..4854734e 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -611,7 +611,7 @@ export class InstanceConfig extends classes.Config { "factorio.enable_lua_udp": { description: "Enable UDP messaging between the Factorio server and the host. " + - "Required by plugins using UDP. Requires Factorio 2.0.59 or later.", + "Required by plugins using UDP. Requires Factorio 2.1.12 or later.", restartRequired: true, // Because of the cli option "--enable-lua-udp" type: "boolean", initialValue: false, diff --git a/test/host/server.js b/test/host/server.js index 18fd3628..b1a70eb6 100644 --- a/test/host/server.js +++ b/test/host/server.js @@ -365,6 +365,20 @@ describe("host/server", function() { return { lines, restore: () => { server._logger = logger; } }; } + it("should refuse to start on Factorio before 2.1.12", async function() { + const version = server._version; + server._version = "2.1.11"; + server.enableLuaUdp = true; + try { + const error = new Error("Lua UDP requires Factorio 2.1.12 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 not have a host port when not running", function() { assert.equal(server.hostUdpPort, undefined); }); diff --git a/test/integration/instance.js b/test/integration/instance.js index 79278f80..139932a7 100644 --- a/test/integration/instance.js +++ b/test/integration/instance.js @@ -21,9 +21,9 @@ const requireApi = [ "or package.loaded['__level__/modules/clusterio/api.lua']", // 2.0.0 ].join(" "); -// --enable-lua-udp was added in Factorio 2.0.59 +// Lua UDP needs Factorio 2.1.12, see docs/configuration.md function hasLuaUdp() { - return lib.integerFullVersion(getFactorioVersion()) >= lib.integerFullVersion("2.0.59"); + return lib.integerFullVersion(getFactorioVersion()) >= lib.integerFullVersion("2.1.12"); } function getUser(name) { diff --git a/test/integration/server.js b/test/integration/server.js index 8c3b8075..1675ac96 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -125,8 +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"); - // --enable-lua-udp was added in Factorio 2.0.59 - server.enableLuaUdp = lib.integerFullVersion(server.version) >= lib.integerFullVersion("2.0.59"); + // Lua UDP needs Factorio 2.1.12, see docs/configuration.md + server.enableLuaUdp = lib.integerFullVersion(server.version) >= lib.integerFullVersion("2.1.12"); await server.start("test.zip"); }); }); @@ -182,9 +182,11 @@ describe("Integration of host/src/server", function() { } 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)" + "function(event) print('udp:' .. event.payload) end) " + + "script.on_nth_tick(1, function() helpers.recv_udp(0) end)" ); let pass = false; function filter(output) { @@ -195,8 +197,9 @@ describe("Integration of host/src/server", function() { 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 helpers.recv_udp(0)"); + await server.sendRcon("/sc rcon.print('tick')"); await lib.wait(10); if (pass) { break; From 8bfc42c44cd0e43713ad4144c9b0aeaf52d42913 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:40:23 +0000 Subject: [PATCH 5/9] Lower the Lua UDP floor to Factorio 2.1.10 Danielv123 pointed out the recv_udp crash was fixed in 2.1.10, not 2.1.12: recv_udp used an input action for synchronisation which the headless server could not send without a player index. Verified on downloaded headless builds, 2.1.9 crashes and 2.1.10 works. Co-Authored-By: Claude Fable 5.1 --- docs/configuration.md | 2 +- docs/developing-for-clusterio.md | 2 +- docs/writing-plugins.md | 2 +- packages/host/src/server.ts | 6 +++--- packages/lib/src/config/definitions.ts | 2 +- test/host/server.js | 6 +++--- test/integration/instance.js | 4 ++-- test/integration/server.js | 4 ++-- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index be96b9d1..a2ee116e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -610,7 +610,7 @@ Defaults to true. 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.12 or later, earlier versions crash when a packet is received while the server is paused. +Requires Factorio 2.1.10 or later, earlier versions crash when a packet is received on a headless server. Defaults to false. diff --git a/docs/developing-for-clusterio.md b/docs/developing-for-clusterio.md index f096af55..a911601d 100644 --- a/docs/developing-for-clusterio.md +++ b/docs/developing-for-clusterio.md @@ -255,7 +255,7 @@ Parameters: 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.12 or later, otherwise the data is silently dropped. +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. diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index 8479c099..4e91071b 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -485,7 +485,7 @@ For RCON, commands longer than 50 characters may end up being executed after sho ### 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.12 or later, earlier versions crash when a packet is received while the server is paused. +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. diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts index 55f298a0..6808db73 100644 --- a/packages/host/src/server.ts +++ b/packages/host/src/server.ts @@ -865,10 +865,10 @@ export class FactorioServer extends events.EventEmitter { this._udpSocket = socket; } - // Factorio before 2.1.12 crashes when a packet is received while paused + // 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.12")) { - throw new Error("Lua UDP requires Factorio 2.1.12 or later"); + if (this.enableLuaUdp && lib.integerFullVersion(this._version!) < lib.integerFullVersion("2.1.10")) { + throw new Error("Lua UDP requires Factorio 2.1.10 or later"); } } diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index 4854734e..c0464588 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -611,7 +611,7 @@ export class InstanceConfig extends classes.Config { "factorio.enable_lua_udp": { description: "Enable UDP messaging between the Factorio server and the host. " + - "Required by plugins using UDP. Requires Factorio 2.1.12 or later.", + "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, diff --git a/test/host/server.js b/test/host/server.js index b1a70eb6..c0e65517 100644 --- a/test/host/server.js +++ b/test/host/server.js @@ -365,12 +365,12 @@ describe("host/server", function() { return { lines, restore: () => { server._logger = logger; } }; } - it("should refuse to start on Factorio before 2.1.12", async function() { + it("should refuse to start on Factorio before 2.1.10", async function() { const version = server._version; - server._version = "2.1.11"; + server._version = "2.1.9"; server.enableLuaUdp = true; try { - const error = new Error("Lua UDP requires Factorio 2.1.12 or later"); + 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"); diff --git a/test/integration/instance.js b/test/integration/instance.js index 139932a7..af77db8f 100644 --- a/test/integration/instance.js +++ b/test/integration/instance.js @@ -21,9 +21,9 @@ const requireApi = [ "or package.loaded['__level__/modules/clusterio/api.lua']", // 2.0.0 ].join(" "); -// Lua UDP needs Factorio 2.1.12, see docs/configuration.md +// Lua UDP needs Factorio 2.1.10, see docs/configuration.md function hasLuaUdp() { - return lib.integerFullVersion(getFactorioVersion()) >= lib.integerFullVersion("2.1.12"); + return lib.integerFullVersion(getFactorioVersion()) >= lib.integerFullVersion("2.1.10"); } function getUser(name) { diff --git a/test/integration/server.js b/test/integration/server.js index 1675ac96..be1c70a6 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -125,8 +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.12, see docs/configuration.md - server.enableLuaUdp = lib.integerFullVersion(server.version) >= lib.integerFullVersion("2.1.12"); + // 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"); }); }); From fb53845f2aff3ffc2be7c17a9279bba878350dbf Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:47:50 +0000 Subject: [PATCH 6/9] Cover opening the UDP socket from the RCON start heuristic Co-Authored-By: Claude Fable 5.1 --- test/host/server.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/host/server.js b/test/host/server.js index c0e65517..1ffa8973 100644 --- a/test/host/server.js +++ b/test/host/server.js @@ -379,6 +379,23 @@ describe("host/server", function() { 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); }); From 25c3c9991e308e7b9c986674a670b0d8d9102545 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:29:21 +0000 Subject: [PATCH 7/9] Log synchronous throws from handleUdp handlers The listener runs from the socket callback, so a non-async handler that threw before returning a promise reached the unhandled error handler and took down the host. Co-Authored-By: Claude Opus 5.5 --- packages/host/src/server.ts | 11 ++++++++--- test/host/server.js | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts index 6808db73..091103a1 100644 --- a/packages/host/src/server.ts +++ b/packages/host/src/server.ts @@ -771,9 +771,14 @@ export class FactorioServer extends events.EventEmitter { } handleUdp(channel: string, handler: (data: Buffer) => Promise) { - this.on(`udp-${channel}`, (data) => handler(data).catch((err: Error) => { - this._logger.error(`Error handling udp event:\n${err.stack ?? err.message}`); - })); + // 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) { diff --git a/test/host/server.js b/test/host/server.js index 1ffa8973..a3cc96a2 100644 --- a/test/host/server.js +++ b/test/host/server.js @@ -471,6 +471,20 @@ describe("host/server", function() { 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"); From 0a6f614074f9c3812297eb5e0f0635ad390519e4 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:29:35 +0000 Subject: [PATCH 8/9] Fix Lua UDP before startup and without script commands send_udp and get_host_udp_port indexed script data that is only created on the first tick after patching, so calling them from on_init stopped the server. They now return nothing until then. Received packets were only polled once the host port was known, which needs script commands. Poll whenever recv_udp exists instead, it does nothing when the server runs without --enable-lua-udp. Co-Authored-By: Claude Opus 5.5 --- packages/host/modules/clusterio/api.lua | 3 +- packages/host/modules/clusterio/impl.lua | 13 ++++-- test/integration/instance.js | 53 ++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/packages/host/modules/clusterio/api.lua b/packages/host/modules/clusterio/api.lua index ec42588c..36ab00cc 100644 --- a/packages/host/modules/clusterio/api.lua +++ b/packages/host/modules/clusterio/api.lua @@ -44,7 +44,8 @@ function api.send_json(channel, data) end function api.send_udp(channel, data) - local port = compat.script_data.clusterio.host_udp_port + 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 diff --git a/packages/host/modules/clusterio/impl.lua b/packages/host/modules/clusterio/impl.lua index 202058f6..344a1754 100644 --- a/packages/host/modules/clusterio/impl.lua +++ b/packages/host/modules/clusterio/impl.lua @@ -12,14 +12,17 @@ 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] = function() check_patch() - -- Dispatch on_udp_packet_received for packets sent by the host - local clusterio = compat.script_data.clusterio - if clusterio and clusterio.host_udp_port then + -- 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 @@ -115,7 +118,9 @@ remote.add_interface('clusterio_api', { end, get_host_udp_port = function() - return compat.script_data.clusterio.host_udp_port + -- 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() diff --git a/test/integration/instance.js b/test/integration/instance.js index af77db8f..ff856717 100644 --- a/test/integration/instance.js +++ b/test/integration/instance.js @@ -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"; @@ -26,6 +28,14 @@ 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)); } @@ -110,6 +120,7 @@ describe("Clusterio Instance", function() { } } describe(`Integration - ${strSavePatching}, ${strScriptCommands}`, function() { + let luaUdpPort; before(async function() { this.timeout(20000); // Create and start a patched save @@ -117,6 +128,8 @@ describe("Clusterio Instance", function() { 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}`); }); @@ -131,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 From 857102956aaab9f825852837ba47831054958f7b Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:42:03 +0000 Subject: [PATCH 9/9] Use entrypoint functions in the UDP docs examples Co-Authored-By: Claude Opus 5.5 --- docs/writing-plugins.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index 4e91071b..965fb1c2 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -499,23 +499,24 @@ clusterio_api.send_udp("my_plugin_foo", "123") ``` ```js -async init() { - this.instance.server.handleUdp("my_plugin_foo", async data => { - this.logger.info(`got ${data.toString()}`); +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 `this.instance.server.on("udp-my_plugin_foo", ...)` for custom error handling. +Use `instance.server.on("udp-my_plugin_foo", ...)` for custom error handling. -Data into Factorio is sent with the `sendUdp` method on the plugin object and delivered in-game as `defines.events.on_udp_packet_received` events with the packet in `event.payload`. +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 -async onStart() { - await this.sendUdp("my_plugin_foo:123"); -} +instance.hooks.start.attach(plugin.name, async () => { + await instance.sendUdp("my_plugin_foo:123"); +}); ```