diff --git a/docs/managing-a-cluster.md b/docs/managing-a-cluster.md index 205a548d..dca36cff 100644 --- a/docs/managing-a-cluster.md +++ b/docs/managing-a-cluster.md @@ -42,6 +42,15 @@ List instances. Create a new instance. `name` is the instance name. `--id` is the optional id. +### Kill instance + + ctl> instance kill + +Terminates the running Factorio server without giving it a chance to save or cleanup after itself. + +Note: This may cause loss of data. + + ### Config management #### List Configuration diff --git a/packages/ctl/ctl.js b/packages/ctl/ctl.js index 9e48860f..b4cc3e5d 100755 --- a/packages/ctl/ctl.js +++ b/packages/ctl/ctl.js @@ -755,6 +755,19 @@ instanceCommands.add(new libCommand.Command({ }, })); +instanceCommands.add(new libCommand.Command({ + definition: ["kill ", "Kill instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to kill", type: "string" }); + }], + handler: async function(args, control) { + let instanceId = await libCommand.resolveInstance(control, args.instance); + await control.setLogSubscriptions({ instance_ids: [instanceId] }); + await libLink.messages.killInstance.send(control, { + instance_id: instanceId, + }); + }, +})); + instanceCommands.add(new libCommand.Command({ definition: ["delete ", "Delete instance", (yargs) => { yargs.positional("instance", { describe: "Instance to delete", type: "string" }); diff --git a/packages/lib/factorio/server.js b/packages/lib/factorio/server.js index 60070550..787b32d2 100644 --- a/packages/lib/factorio/server.js +++ b/packages/lib/factorio/server.js @@ -487,6 +487,7 @@ class FactorioServer extends events.EventEmitter { // Array of possible causes for an unexpected shutdown this._unexpected = []; + this._killed = false; // Track autosaving this._runningAutosave = null; @@ -694,28 +695,30 @@ class FactorioServer extends events.EventEmitter { _watchExit() { this._server.on("exit", (code, signal) => { if (this._state !== "stopping") { - if (this._unexpected.length === 0) { - if (signal === "SIGKILL") { + if (signal === "SIGKILL") { + if (this._killed) { + this.emit("error", new libErrors.EnvironmentError("Factorio server was killed")); + } else { this.emit("error", new libErrors.EnvironmentError( "Factorio server was unexpectedly killed, is the system low on memory?" )); - - } else { - let msg; - if (code !== null) { - msg = `Factorio server unexpectedly shut down with code ${code}`; - } else { - msg = `Factorio server was unexpectedly shut by down by signal ${signal}`; - } - - this.emit("error", new Error(msg)); } + } else if (this._unexpected.length === 0) { + let msg; + if (code !== null) { + msg = `Factorio server unexpectedly shut down with code ${code}`; + } else { + msg = `Factorio server was unexpectedly shut by down by signal ${signal}`; + } + + this.emit("error", new libErrors.EnvironmentError(msg)); + } else if (this._unexpected.length === 1) { - this.emit("error", new Error(this._unexpected[0])); + this.emit("error", new libErrors.EnvironmentError(this._unexpected[0])); } else { - this.emit("error", new Error( + this.emit("error", new libErrors.EnvironmentError( "Factorio server unexpectedly shut down. Possible causes:"+ `\n- ${this._unexpected.join("\n- ")}` )); @@ -1004,12 +1007,18 @@ class FactorioServer extends events.EventEmitter { * Kill the server * * Terminates the server without any cleanup or saving. + * @param {boolean} [unexpected=false] - + * If true raise an error event as a result of killing the Factorio + * process. */ - async kill() { + async kill(unexpected = false) { this._check(["running", "stopping"]); - this._state = "stopping"; + this._killed = true; + if (!unexpected) { + this._state = "stopping"; + } this._server.kill("SIGKILL"); - await events.once(this._server, "exit"); + await new Promise(resolve => this._server.once("exit", resolve)); } /** diff --git a/packages/lib/link/messages.js b/packages/lib/link/messages.js index ea3d0aae..06135ff4 100644 --- a/packages/lib/link/messages.js +++ b/packages/lib/link/messages.js @@ -636,6 +636,13 @@ messages.stopInstance = new Request({ forwardTo: "instance", }); +messages.killInstance = new Request({ + type: "kill_instance", + links: ["control-master", "master-slave", "slave-instance"], + permission: "core.instance.kill", + forwardTo: "instance", +}); + messages.deleteInstance = new Request({ type: "delete_instance", links: ["control-master", "master-slave"], diff --git a/packages/lib/users.js b/packages/lib/users.js index 4b42425b..f5f59f1a 100644 --- a/packages/lib/users.js +++ b/packages/lib/users.js @@ -427,6 +427,11 @@ definePermission({ title: "Stop instance", description: "Stop instances.", }); +definePermission({ + name: "core.instance.kill", + title: "Kill instance", + description: "Terminate running instances without saving or cleanup.", +}); definePermission({ name: "core.instance.delete", title: "Delete instance", diff --git a/packages/slave/slave.js b/packages/slave/slave.js index cbcb0098..ebdf00c4 100755 --- a/packages/slave/slave.js +++ b/packages/slave/slave.js @@ -186,7 +186,11 @@ class Instance extends libLink.Link { }); this.server.on("error", err => { - this.logger.error(`Error in instance ${this.name}:\n${err.stack}`); + if (err instanceof libErrors.EnvironmentError) { + this.logger.error(err.message); + } else { + this.logger.error(`${this.name}:\n${err.stack}`); + } }); this.server.on("autosave-finished", name => { @@ -697,6 +701,13 @@ class Instance extends libLink.Link { } } + async kill() { + if (this._status === "stopped") { + return; + } + await this.server.kill(true); + } + async masterConnectionEventEventHandler(message) { await libPlugin.invokeHook(this.plugins, "onMasterConnectionEvent", message.data.event); } @@ -833,6 +844,10 @@ class Instance extends libLink.Link { await this.stop(); } + async killInstanceRequestHandler() { + await this.kill(); + } + async sendRconRequestHandler(message) { let result = await this.sendRcon(message.data.command); return { result }; diff --git a/packages/web_ui/src/components/InstanceViewPage.jsx b/packages/web_ui/src/components/InstanceViewPage.jsx index 0ad1031e..3b3e26c0 100644 --- a/packages/web_ui/src/components/InstanceViewPage.jsx +++ b/packages/web_ui/src/components/InstanceViewPage.jsx @@ -1,7 +1,8 @@ import React, { useContext, useState } from "react"; import { useHistory, useParams } from "react-router-dom"; -import { Alert, Button, Descriptions, Popconfirm, Space, Spin, Typography } from "antd"; +import { Alert, Button, Descriptions, Dropdown, Menu, Modal, PageHeader, Space, Spin, Typography } from "antd"; import DeleteOutlined from "@ant-design/icons/DeleteOutlined"; +import DownOutlined from "@ant-design/icons/DownOutlined"; import { libLink } from "@clusterio/lib"; @@ -24,6 +25,36 @@ import InstanceStatusTag from "./InstanceStatusTag"; const { Title } = Typography; +function InstanceDescription(props) { + let account = useAccount(); + + const { slave, instance } = props; + let assigned = instance["assigned_slave"] !== null; + return + + {!assigned + ? Unassigned + : slave["name"] || instance["assigned_slave"] + } + {account.hasPermission("core.instance.assign") && } + + + ; +} + export default function InstanceViewPage(props) { let params = useParams(); let instanceId = Number(params.id); @@ -61,16 +92,9 @@ export default function InstanceViewPage(props) { ; } - let instanceButtons = - { - account.hasAnyPermission("core.instance.start", "core.instance.stop") - && - } - {account.hasPermission("core.instance.load_scenario") && } - {account.hasPermission("core.instance.export_data") && } - {account.hasPermission("core.instance.delete") && { - libLink.messages.deleteInstance.send( + + } else if (key === "kill") { + libLink.messages.killInstance.send( control, { instance_id: instanceId } - ).then(() => { - history.push("/instances"); - }).catch(notifyErrorHandler("Error deleting instance")); - }} - > - - } + danger + key="delete" + icon={} + >Delete + } + ; + let instanceButtons = + { + account.hasAnyPermission("core.instance.start", "core.instance.stop") + && + } + {account.hasPermission("core.instance.load_scenario") && } + {account.hasAnyPermission( + "core.instance.export_data", + "core.instance.kill", + "core.instance.delete", + ) && + + } ; - let assigned = instance["assigned_slave"] !== null; return - - - {!assigned - ? Unassigned - : slave["name"] || instance["assigned_slave"] - } - {account.hasPermission("core.instance.assign") && } - - - + /> + { account.hasAllPermission("core.instance.save.list", "core.instance.save.list_subscribe") diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 072f0180..ee8ea515 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -481,22 +481,26 @@ describe("Integration of Clusterio", function() { await execCtl("instance config set 44 research_sync.load_plugin false"); await execCtl("instance config set 44 statistics_exporter.load_plugin false"); await execCtl("instance config set 44 subspace_storage.load_plugin false"); - try { - let exchangeString = testStrings.modified.replace(/[\n\r]+/g, ""); - let args = `base/freeplay --seed 1234 --map-exchange-string "${exchangeString}"`; - await execCtl(`instance load-scenario test ${args}`); - await checkInstanceStatus(44, "running"); - await sendRcon(44, '/c game.print("disable achievements")'); - await sendRcon(44, '/c game.print("disable achievements")'); - assert.equal(await sendRcon(44, "/c rcon.print(game.default_map_gen_settings.seed)"), "1234\n"); - assert.equal(await sendRcon(44, "/c rcon.print(game.map_settings.pollution.ageing)"), "1.5\n"); - assert.equal( - await sendRcon(44, "/c rcon.print(game.difficulty_settings.research_queue_setting)"), "never\n" - ); - } finally { - await execCtl("instance stop test"); - } + let exchangeString = testStrings.modified.replace(/[\n\r]+/g, ""); + let args = `base/freeplay --seed 1234 --map-exchange-string "${exchangeString}"`; + await execCtl(`instance load-scenario test ${args}`); + await checkInstanceStatus(44, "running"); + await sendRcon(44, '/c game.print("disable achievements")'); + await sendRcon(44, '/c game.print("disable achievements")'); + assert.equal(await sendRcon(44, "/c rcon.print(game.default_map_gen_settings.seed)"), "1234\n"); + assert.equal(await sendRcon(44, "/c rcon.print(game.map_settings.pollution.ageing)"), "1.5\n"); + assert.equal( + await sendRcon(44, "/c rcon.print(game.difficulty_settings.research_queue_setting)"), "never\n" + ); + }); + }); + + describe("instance kill", function() { + it("kills the instance", async function() { + slowTest(this); + await execCtl("instance kill test"); + await checkInstanceStatus(44, "stopped"); }); });