diff --git a/packages/host/src/server.ts b/packages/host/src/server.ts index 1fc01afe..b7e426be 100644 --- a/packages/host/src/server.ts +++ b/packages/host/src/server.ts @@ -1318,18 +1318,29 @@ export class FactorioServer extends events.EventEmitter { } - // 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(resolve => { - const onRconReady = () => { + return await new Promise(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 { 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 { // 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(); } diff --git a/test/host/server_stop.js b/test/host/server_stop.js index c98aff21..26f52936 100644 --- a/test/host/server_stop.js +++ b/test/host/server_stop.js @@ -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); }); }); });