Merge pull request #1067 from ILLISIS/fix-game-port-in-use

Replace a stored game port that another process holds
This commit is contained in:
ILLISIS authored and GitHub committed 2026-10-13 20:23:46 +01:00
1 parent 48e8c53776
commit 137211d55d
3 files changed
+100 -2

No files matched your search

+58 -2
View File
@@ -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<void>((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<number>()) {
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<number>();
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
*
+3
View File
@@ -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
+39
View File
@@ -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;