From 137211d55dac0000304160d757ee9c3d105ffcab Mon Sep 17 00:00:00 2001 From: ILLISIS <92364928+ILLISIS@users.noreply.github.com> Date: Wed, 14 Oct 2026 06:23:46 +1100 Subject: [PATCH] Merge pull request #1067 from ILLISIS/fix-game-port-in-use Replace a stored game port that another process holds --- packages/host/src/Host.ts | 60 +++++++++++++++++++++++++++++++++-- packages/host/src/Instance.ts | 3 ++ test/host/Host.test.js | 39 +++++++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index 8750aace..f5ce4ad3 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -1,3 +1,4 @@ +import dgram from "node:dgram"; import fs from "node:fs/promises"; import path from "path"; import pidusage from "pidusage"; @@ -25,6 +26,35 @@ function checkRequestSaveName(name: string) { } } +/** + * Checks that no socket is bound to the given UDP port, on IPv4 or IPv6 + * + * Factorio binds the game port on both 0.0.0.0 and [::]. + * + * @param port - UDP port to check. + * @returns true if the port can be bound. + * @internal + */ +async function udpPortFree(port: number) { + for (const type of ["udp4", "udp6"] as const) { + const socket = dgram.createSocket({ type, ipv6Only: type === "udp6" }); + try { + await new Promise((resolve, reject) => { + socket.once("error", reject); + socket.bind(port, resolve); + }); + } catch (err: any) { + // Other errors, such as no IPv6 support on the host, don't stop Factorio from binding the port. + if (err.code === "EADDRINUSE") { + return false; + } + } finally { + socket.close(); + } + } + return true; +} + /** * Searches for instances in the provided directory * @@ -791,8 +821,11 @@ export default class Host extends lib.Link { ); } - assignGamePort(instanceId: number) { + assignGamePort(instanceId: number, inUse = new Set()) { const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2 ** 16 - 1); + for (const port of inUse) { + availablePorts.delete(port); + } for (const [id, instance] of this.assignedInstances) { if (id === instanceId) { continue; @@ -811,13 +844,36 @@ export default class Host extends lib.Link { return assignedPort; } if (!availablePorts.size) { - throw new lib.RequestError("No available port left to assign from host.factorio_port_range"); + const others = inUse.size ? ` (${inUse.size} in use by other processes)` : ""; + throw new lib.RequestError(`No available port left to assign from host.factorio_port_range${others}`); } const [newPort] = availablePorts; instanceToAssign.config.set("factorio.host_assigned_game_port", newPort); return newPort; } + /** + * Assign a game port that no other process is bound to + * + * Replaces the stored port if it is taken, so a port another process + * holds is not reused on every start. + * + * @param instanceId - ID of the instance to assign a game port to. + * @returns the assigned port. + */ + async assignFreeGamePort(instanceId: number) { + const inUse = new Set(); + for (;;) { + // Picking and storing the port before checking it keeps concurrent assignments from picking the same one. + const port = this.assignGamePort(instanceId, inUse); + if (await udpPortFree(port)) { + return port; + } + logger.warn(`Game port ${port} is in use by another process`, this.instanceLogMeta(instanceId)); + inUse.add(port); + } + } + /** * Initialize and connect an unloaded instance * diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index d8c9311c..ed43019b 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -678,6 +678,9 @@ end`.replace(/\r?\n/g, " "); // After changing the status this function is responsible for calling notifyExit if it throws. this.notifyStatus("starting"); try { + if (this.config.get("factorio.game_port") === null) { + this.server.gamePort = await this._host.assignFreeGamePort(this.id); + } const factorioVersions = await this.sendTo("controller", new lib.FactorioVersionsRequest()); await this._loadStats(); // Resolve a release channel target (e.g. "stable") to a concrete diff --git a/test/host/Host.test.js b/test/host/Host.test.js index 36a7c0f6..e442bc76 100644 --- a/test/host/Host.test.js +++ b/test/host/Host.test.js @@ -1,4 +1,6 @@ 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"; import { fileURLToPath } from "node:url"; @@ -208,6 +210,43 @@ describe("host/src/Host", function() { assert.equal(host.config.restartRequired, true); }); }); + describe(".assignFreeGamePort()", function() { + let host; + let held; + let instanceConfig; + beforeEach(async function() { + const hostConfig = new lib.HostConfig("host"); + host = new Host(new HostConnector(hostConfig, []), hostConfig, []); + instanceConfig = new lib.InstanceConfig("host", { "instance.id": 1 }); + host.assignedInstances.set(1, { config: instanceConfig, path: "" }); + held = dgram.createSocket("udp4"); + held.bind(0, "127.0.0.1"); + await events.once(held, "listening"); + }); + afterEach(function() { + held.close(); + }); + it("replaces a stored port another process is bound to", async function() { + const port = held.address().port; + host.config.set("host.factorio_port_range", `${port}-${port + 1}`); + instanceConfig.set("factorio.host_assigned_game_port", port); + assert.equal(await host.assignFreeGamePort(1), port + 1); + assert.equal(instanceConfig.get("factorio.host_assigned_game_port"), port + 1); + }); + it("keeps a stored port that is free", async function() { + const port = held.address().port; + host.config.set("host.factorio_port_range", `${port}-${port + 1}`); + instanceConfig.set("factorio.host_assigned_game_port", port + 1); + assert.equal(await host.assignFreeGamePort(1), port + 1); + }); + it("rejects when every port in the range is taken", async function() { + host.config.set("host.factorio_port_range", String(held.address().port)); + await assert.rejects( + host.assignFreeGamePort(1), + /No available port left to assign from host.factorio_port_range \(1 in use by other processes\)/ + ); + }); + }); describe(".handlePluginInstallRequest()", function() { let host; let _fetch;