mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-14 15:24:52 +09:00
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 <noreply@anthropic.com>
380 lines
11 KiB
JavaScript
380 lines
11 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import events from "node:events";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
import * as lib from "@clusterio/lib";
|
|
import { InstanceManager } from "@clusterio/controller";
|
|
import { FactorioServer, _getFactorioVersion } from "@clusterio/host/dist/node/src/server.js";
|
|
import { logger } from "@clusterio/lib";
|
|
|
|
import { factorioDir, requiresFactorio } from "./index.js";
|
|
import { slowTest } from "../common.js";
|
|
|
|
|
|
describe("Integration of host/src/server", function() {
|
|
requiresFactorio(this);
|
|
describe("getFactorioVersion()", function() {
|
|
it("should get a version from factorio's changelog.txt", async function() {
|
|
function checkVersion(version) {
|
|
if (!/^\d+\.\d+\.\d+$/.test(version)) {
|
|
assert.fail(`Detected version '${version}' does not followed the format x.y.z`);
|
|
}
|
|
}
|
|
|
|
let version = await _getFactorioVersion(factorioDir);
|
|
if (version !== null) {
|
|
checkVersion(version);
|
|
} else {
|
|
for (let name of await fs.readdir(factorioDir)) {
|
|
// Avoid any fancy directory detection in order to follow symlinks
|
|
version = await _getFactorioVersion(path.join(factorioDir, name));
|
|
if (version !== null) {
|
|
checkVersion(version);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("class FactorioServer", function() {
|
|
let writePath = path.join("temp", "test", "integration");
|
|
let server = new FactorioServer(factorioDir, writePath, {});
|
|
// Let Factorio bind an OS-assigned UDP port instead of guessing a free one.
|
|
server.gamePort = 0;
|
|
let logFile;
|
|
|
|
before(async function() {
|
|
// Delete result from previous run of these tests
|
|
await fs.rm(writePath, { force: true, recursive: true, maxRetries: 10 });
|
|
|
|
await fs.mkdir(writePath, { recursive: true });
|
|
logFile = (await fs.open(path.join(writePath, "log.txt"), "w")).createWriteStream({ encoding: "utf8" });
|
|
server.on("output", function(output) {
|
|
if (output.source === "stderr") {
|
|
// Factorio doesn't print on stderr but sometimes on
|
|
// Travis a BrokenPipeError is printed on stderr after
|
|
// the server has closed for mysterious reasons.
|
|
// I have no idea what causes it, but it causes a write
|
|
// after end error if not handled.
|
|
logger.error(`Factorio stderr: ${JSON.stringify(output)}`);
|
|
return;
|
|
}
|
|
logFile.write(`${JSON.stringify(output)}\n`);
|
|
});
|
|
|
|
// Initialize sever.
|
|
await server.init();
|
|
});
|
|
|
|
after(function() {
|
|
logFile.end();
|
|
});
|
|
|
|
describe(".exampleSettings()", function() {
|
|
let settings;
|
|
it("returns an object", async function() {
|
|
settings = await server.exampleSettings();
|
|
assert(typeof settings === "object");
|
|
});
|
|
|
|
it("aligns with the default settings in Clusterio", async function() {
|
|
const unusedDefaultKeys = new Set(Object.keys(InstanceManager.DefaultFactorioSettings));
|
|
|
|
for (const key of Object.keys(settings)) {
|
|
unusedDefaultKeys.delete(key);
|
|
}
|
|
|
|
assert(
|
|
unusedDefaultKeys.size === 0,
|
|
`Factorio's server-settings.example.json does not contain the key(s) ${[...unusedDefaultKeys]}`
|
|
);
|
|
});
|
|
});
|
|
|
|
function log(message) {
|
|
logFile.write(`=================== ${message}\n`);
|
|
}
|
|
|
|
describe(".create()", function() {
|
|
it("creates a map file at writeDir/saves/name", async function() {
|
|
slowTest(this);
|
|
log(".create() with new save");
|
|
|
|
// Make sure the test is not fooled by previous data
|
|
let mapPath = server.writePath("saves", "test.zip");
|
|
await assert.rejects(fs.access(mapPath), "save exist before test");
|
|
|
|
await server.create("test.zip", undefined, { width: 50, height: 50 });
|
|
assert.doesNotReject(await fs.access(mapPath), "test did not create save");
|
|
});
|
|
});
|
|
|
|
describe(".start()", function() {
|
|
it("starts the server", async function() {
|
|
slowTest(this);
|
|
log(".start()");
|
|
|
|
// Make sure the test does not fail due to not having internet
|
|
let settings = await server.exampleSettings();
|
|
settings.visibility = { public: false, lan: true };
|
|
settings.require_user_verification = false;
|
|
await fs.writeFile(server.writePath("server-settings.json"), JSON.stringify(settings, null, "\t"));
|
|
|
|
// Make sure the test does not fail due to create() failing.
|
|
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");
|
|
await server.start("test.zip");
|
|
});
|
|
});
|
|
|
|
describe(".disableAchievments()", function() {
|
|
it("disables acheivements", async function() {
|
|
slowTest(this);
|
|
log(".disableAchievements()");
|
|
assert.equal(await server.disableAchievements(), true);
|
|
});
|
|
|
|
it("can tell when acheivements were disabled", async function() {
|
|
slowTest(this);
|
|
assert.equal(await server.disableAchievements(), false);
|
|
});
|
|
});
|
|
|
|
describe(".sendRcon()", function() {
|
|
it("returns the result of a command", async function() {
|
|
slowTest(this);
|
|
log(".sendRcon()");
|
|
|
|
let result = await server.sendRcon("/sc rcon.print('success')");
|
|
assert.equal(result, "success\n");
|
|
});
|
|
it("throws on non-empty response when enabled", async function() {
|
|
slowTest(this);
|
|
await assert.rejects(
|
|
server.sendRcon("/sc rcon.print('fail')", true),
|
|
new Error('Expected empty response but got "fail\n"')
|
|
);
|
|
});
|
|
});
|
|
|
|
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);
|
|
log(".stop()");
|
|
|
|
await server.stop();
|
|
});
|
|
});
|
|
|
|
describe(".startScenario()", function() {
|
|
before("Write test_scenario", async function() {
|
|
let content = "script.on_init(function() print('test_scenario init') end)\n";
|
|
await fs.mkdir(server.writePath("scenarios", "test_scenario"), { recursive: true });
|
|
await fs.writeFile(server.writePath("scenarios", "test_scenario", "control.lua"), content);
|
|
});
|
|
|
|
it("runs the given scenario", async function() {
|
|
slowTest(this);
|
|
log(".startScenario()");
|
|
|
|
let pass = false;
|
|
function filter(output) {
|
|
if (output.message === "test_scenario init") {
|
|
pass = true;
|
|
}
|
|
}
|
|
server.on("output", filter);
|
|
|
|
await server.startScenario("test_scenario");
|
|
|
|
log(".stop()");
|
|
await server.stop();
|
|
|
|
server.off("output", filter);
|
|
assert(pass, "server did not output line from test scenario");
|
|
});
|
|
});
|
|
|
|
describe(".start() error handling", function() {
|
|
it("should handle factorio erroring out", async function() {
|
|
slowTest(this);
|
|
log(".start() for error handling");
|
|
|
|
await server.start("test.zip");
|
|
if (!server._rconReady) {
|
|
await events.once(server, "rcon-ready");
|
|
}
|
|
server.sendRcon("/c script.on_nth_tick(1, function() o.o = 1 end)").catch(() => {});
|
|
|
|
function discard() { }
|
|
server.on("error", discard);
|
|
await new Promise(resolve => server.once("exit", resolve));
|
|
server.off("error", discard);
|
|
});
|
|
});
|
|
describe(".stop() hang detection", function() {
|
|
it("should detect factorio hanging on shutdown", async function() {
|
|
slowTest(this);
|
|
log(".start() for hang detection");
|
|
|
|
await server.start("test.zip");
|
|
if (!server._rconReady) {
|
|
await events.once(server, "rcon-ready");
|
|
}
|
|
server.sendRcon("/c while true do end").catch(() => {});
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
log(".stop() for hang detection");
|
|
server.shutdownTimeoutMs = 2000;
|
|
await server.stop();
|
|
server.shutdownTimeoutMs = 0;
|
|
});
|
|
it("should not hang if RCON fails to connect while stopping", async function() {
|
|
slowTest(this);
|
|
|
|
const origWaitForReady = server._waitForReady;
|
|
server._waitForReady = () => {
|
|
server._waitForReady = origWaitForReady;
|
|
server.rconPort -= 1; // Mangle port so it fails to connect.
|
|
return server._waitForReady();
|
|
};
|
|
try {
|
|
await server.start("test.zip");
|
|
if (server._rconReady) {
|
|
assert.fail("Test failed, RCON managed to connect");
|
|
}
|
|
await server.stop();
|
|
|
|
} finally {
|
|
if (server._state !== "init") {
|
|
await server.kill();
|
|
assert.fail("Server did not stop");
|
|
}
|
|
}
|
|
});
|
|
it("should not hang if RCON fails to connect before stopping", async function() {
|
|
slowTest(this);
|
|
|
|
const origWaitForReady = server._waitForReady;
|
|
server._waitForReady = () => {
|
|
server._waitForReady = origWaitForReady;
|
|
server.rconPassword = "wrong"; // Mangle password so it fails to authenticate.
|
|
return server._waitForReady();
|
|
};
|
|
try {
|
|
await server.start("test.zip");
|
|
if (server._rconReady) {
|
|
assert.fail("Test failed, RCON managed to connect");
|
|
}
|
|
await events.once(server, "rcon-ready");
|
|
await server.stop();
|
|
|
|
} finally {
|
|
if (server._state !== "init") {
|
|
await server.kill();
|
|
assert.fail("Server did not stop");
|
|
}
|
|
}
|
|
});
|
|
it("should work if RCON has dropped out", async function() {
|
|
slowTest(this);
|
|
|
|
try {
|
|
await server.start("test.zip");
|
|
if (!server._rconReady) {
|
|
await events.once(server, "rcon-ready");
|
|
}
|
|
await server._rconClient.end();
|
|
await server.stop();
|
|
|
|
} finally {
|
|
if (server._state !== "init") {
|
|
await server.kill();
|
|
assert.fail("Server did not stop");
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe(".start() termination detection", function() {
|
|
it("should tell if Factorio got killed", async function() {
|
|
slowTest(this);
|
|
log(".start() for kill detection");
|
|
|
|
let startPromise = server.start("test.zip");
|
|
server.once("output", () => server._server.kill("SIGKILL"));
|
|
|
|
await assert.rejects(
|
|
startPromise,
|
|
new lib.EnvironmentError(
|
|
"Factorio server was unexpectedly killed, is the system low on memory?"
|
|
)
|
|
);
|
|
});
|
|
|
|
it("should tell if Factorio unexpectedly closed with a code", async function() {
|
|
slowTest(this);
|
|
log(".start() for unexpected close detection");
|
|
|
|
await assert.rejects(
|
|
server.start("does-not-exist.zip"),
|
|
new lib.EnvironmentError("Factorio server unexpectedly shut down with code 1")
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|