Report why the server exited from sendRcon

Waiting for exit alongside rcon-ready made sendRcon reject with the flat
"RCON connection lost" for every case, including the unexpected exits
that used to reject with the EnvironmentError saying why the server went
away, as events.once rejects on error events.

Have the paired wait capture the error emitted with the exit and hand it
back, so sendRcon throws that when there is one and falls through to the
old message when there is not, which is the kill path where no error is
emitted. stop() ignores the error as its job is done once the process is
gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5 committed 2026-09-13 17:09:51 +00:00
1 parent 49e8d6ecc5
commit 229bbf8eb8
2 files changed
+55 -9

No files matched your search

+24 -8
View File
@@ -1318,18 +1318,29 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
}
// Not using events.once here to avoid throwing on error events, which are
// not emitted when the process exits while stopping.
// Not using events.once here to avoid throwing on error events, as they are
// not emitted when the process exits while stopping. Returns the error
// emitted with the exit, if there was one, for the caller to report.
async _waitForRconReadyOrExit() {
await new Promise<void>(resolve => {
const onRconReady = () => {
return await new Promise<Error | undefined>(resolve => {
let exitError: Error | undefined;
const cleanup = () => {
this.off("error", onError);
this.off("rcon-ready", onRconReady);
this.off("exit", onExit);
resolve();
};
const onError = (err: Error) => {
exitError = err;
};
const onRconReady = () => {
cleanup();
resolve(undefined);
};
const onExit = () => {
this.off("rcon-ready", onRconReady);
resolve();
cleanup();
resolve(exitError);
};
this.on("error", onError);
this.once("rcon-ready", onRconReady);
this.once("exit", onExit);
});
@@ -1350,7 +1361,11 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
async sendRcon(message: string, expectEmpty?: boolean) {
this._check(["running", "stopping"]);
if (!this._rconReady) {
await this._waitForRconReadyOrExit();
// Prefer the reason the server gave for exiting over the flat message below.
const exitError = await this._waitForRconReadyOrExit();
if (exitError) {
throw exitError;
}
}
if (!this._rconClient) {
throw new Error("RCON connection lost");
@@ -1421,6 +1436,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
// If RCON is not yet fully connected that operation needs to
// complete before the RCON connection can be used
if (!this._rconReady) {
// An exit error is not reported here as stopping is done once the process is gone.
await this._waitForRconReadyOrExit();
}
+31 -1
View File
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import events from "node:events";
import path from "node:path";
import * as lib from "@clusterio/lib";
import { FactorioServer } from "@clusterio/host/dist/node/src/server.js";
@@ -19,6 +20,8 @@ function createStartedServer() {
server._rconReady = false;
server._server = new FakeProcess();
server._watchExit();
// Instance keeps an error listener on the server for as long as it runs.
server.on("error", () => {});
return server;
}
@@ -44,6 +47,18 @@ describe("host/src/server", function() {
await withTimeout(stopped, "stop() did not resolve");
assert.equal(server._state, "init");
assert.equal(server.listenerCount("rcon-ready"), 0);
assert.equal(server.listenerCount("error"), 1);
});
it("returns if the process fails before RCON is ready", async function() {
const server = createStartedServer();
const stopped = server.stop();
// Failing to spawn emits an error event followed by exit.
server._server.emit("error", Object.assign(new Error("spawn failed"), { code: "EACCES" }));
await withTimeout(stopped, "stop() did not resolve");
assert.equal(server._state, "init");
assert.equal(server.listenerCount("rcon-ready"), 0);
assert.equal(server.listenerCount("error"), 1);
});
it("stops the server if RCON becomes ready", async function() {
@@ -61,7 +76,7 @@ describe("host/src/server", function() {
});
describe("FactorioServer.sendRcon()", function() {
it("throws if the process exits before RCON is ready", async function() {
it("throws if the process is killed before RCON is ready", async function() {
const server = createStartedServer();
const sent = server.sendRcon("/version");
// kill() sets the state before killing, which suppresses the error event.
@@ -72,6 +87,21 @@ describe("host/src/server", function() {
new Error("RCON connection lost"),
);
assert.equal(server.listenerCount("rcon-ready"), 0);
assert.equal(server.listenerCount("error"), 1);
});
it("throws the exit error if the process exits before RCON is ready", async function() {
const server = createStartedServer();
const sent = server.sendRcon("/version");
// An unexpected exit emits why the server went away before the exit event.
server._server.emit("exit", 1, null);
await assert.rejects(
withTimeout(sent, "sendRcon() did not settle"),
new lib.EnvironmentError("Factorio server unexpectedly shut down with code 1"),
);
assert.equal(server.listenerCount("rcon-ready"), 0);
assert.equal(server.listenerCount("exit"), 0);
assert.equal(server.listenerCount("error"), 1);
});
});
});