Fix stop hanging if Factorio exits before RCON connects

stop() waits for the rcon-ready event when RCON has not connected yet,
but nothing emits it once the process is gone, so the promise never
settles and the stop request stays pending until the session closes.
Wait for exit as well and drop the other listener when either fires.

Repro: send InstanceExportDataRequest to a stopped instance, then
InstanceStopRequest while the status is exporting_data. Instance.stop()
sees the server as running and calls server.stop(), then exportData
kills the process before RCON is up. The export finishes and the
instance reports stopped, but the stop request never gets a response.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5 committed 2026-09-13 01:52:22 +00:00
1 parent f4aa894fe7
commit 073ddf1421
2 files changed
+75 -1

No files matched your search

+13 -1
View File
@@ -1405,7 +1405,19 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
// complete before the RCON connection can be used
if (!this._rconReady) {
// Not using events.once here to avoid throwing on error events.
await new Promise<void>(resolve => this.once("rcon-ready", resolve));
// The process may also exit before RCON connects.
await new Promise<void>(resolve => {
const onRconReady = () => {
this.off("exit", onExit);
resolve();
};
const onExit = () => {
this.off("rcon-ready", onRconReady);
resolve();
};
this.once("rcon-ready", onRconReady);
this.once("exit", onExit);
});
}
// The Factorio server may have decided to get ahead of us and
+62
View File
@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import events from "node:events";
import path from "node:path";
import { FactorioServer } from "@clusterio/host/dist/node/src/server.js";
// Stand-in for the Factorio child process, only exit and kill are used here.
class FakeProcess extends events.EventEmitter {
killed = false;
kill() {
this.killed = true;
}
}
function createStartedServer() {
const server = new FactorioServer(path.join("test", "file", "factorio"), path.join("temp", "test", "server"), {});
server._state = "running";
server._rconReady = false;
server._server = new FakeProcess();
server._watchExit();
return server;
}
// Fail instead of hanging if stop() never resolves.
async function waitForStop(stopped) {
let timeoutId;
const timeout = new Promise((resolve, reject) => {
timeoutId = setTimeout(() => reject(new Error("stop() did not resolve")), 500);
});
try {
await Promise.race([stopped, timeout]);
} finally {
clearTimeout(timeoutId);
}
}
describe("host/src/server", function() {
describe("FactorioServer.stop()", function() {
it("returns if the process exits before RCON is ready", async function() {
const server = createStartedServer();
const stopped = server.stop();
server._server.emit("exit", 1, null);
await waitForStop(stopped);
assert.equal(server._state, "init");
assert.equal(server.listenerCount("rcon-ready"), 0);
});
it("stops the server if RCON becomes ready", async function() {
const server = createStartedServer();
const stopped = server.stop();
server._rconReady = true;
server.emit("rcon-ready");
await new Promise(resolve => setImmediate(resolve));
assert.equal(server.listenerCount("exit"), 0);
const fakeProcess = server._server;
assert.equal(fakeProcess.killed, true);
fakeProcess.emit("exit", 0, null);
await waitForStop(stopped);
});
});
});