Implement kill instance

Add ability to kill running instances in case it is desirable to stop
the server without saving over the current save file.
This commit is contained in:
Hornwitser committed 2022-03-25 20:52:31 +01:00
1 parent a93f048814
commit 1e4d709a06
8 files changed
+186 -90

No files matched your search

+9
View File
@@ -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 <name>
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
+13
View File
@@ -755,6 +755,19 @@ instanceCommands.add(new libCommand.Command({
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["kill <instance>", "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 <instance>", "Delete instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to delete", type: "string" });
+26 -17
View File
@@ -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));
}
/**
+7
View File
@@ -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"],
+5
View File
@@ -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",
+16 -1
View File
@@ -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 };
@@ -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 <Descriptions
bordered
size="small"
>
<Descriptions.Item label="Slave">
{!assigned
? <em>Unassigned</em>
: slave["name"] || instance["assigned_slave"]
}
{account.hasPermission("core.instance.assign") && <AssignInstanceModal
id={instance["id"]}
slaveId={instance["assigned_slave"]}
buttonProps={{
size: "small",
style: { float: "Right" },
type: assigned ? "default" : "primary",
disabled: !["unknown", "unassigned", "stopped"].includes(instance["status"]),
}}
buttonContent={assigned ? "Reassign" : "Assign"}
/>}
</Descriptions.Item>
<Descriptions.Item label="Status"><InstanceStatusTag status={instance["status"]} /></Descriptions.Item>
</Descriptions>;
}
export default function InstanceViewPage(props) {
let params = useParams();
let instanceId = Number(params.id);
@@ -61,16 +92,9 @@ export default function InstanceViewPage(props) {
</PageLayout>;
}
let instanceButtons = <Space>
{
account.hasAnyPermission("core.instance.start", "core.instance.stop")
&& <StartStopInstanceButton instance={instance} />
}
{account.hasPermission("core.instance.load_scenario") && <LoadScenarioModal instance={instance} />}
{account.hasPermission("core.instance.export_data") && <Button
loading={exportingData}
disabled={instance.status !== "stopped"}
onClick={() => {
let instanceButtonsMenu = <Menu
onClick={({ key }) => {
if (key === "export") {
setExportingData(true);
libLink.messages.exportData.send(
control, { instance_id: instanceId }
@@ -79,59 +103,69 @@ export default function InstanceViewPage(props) {
).finally(() => {
setExportingData(false);
});
}}
>
Export data
</Button>}
{account.hasPermission("core.instance.delete") && <Popconfirm
title="Permanently delete instance and server saves?"
okText="Delete"
placement="bottomRight"
okButtonProps={{ danger: true }}
onConfirm={() => {
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"));
}}
>
<Button
danger
).catch(notifyErrorHandler("Error killing instance"));
} else if (key === "delete") {
Modal.confirm({
autoFocusButton: "cancel",
content: "Permamently delete instance and server saves?",
okText: "Delete",
okButtonProps: { danger: true },
onOk: () => {
libLink.messages.deleteInstance.send(
control, { instance_id: instanceId }
).then(() => {
history.push("/instances");
}).catch(notifyErrorHandler("Error deleting instance"));
},
});
}
}}
>
{account.hasPermission("core.instance.export_data") && <Menu.Item
disabled={exportingData || instance.status !== "stopped"}
key="export"
>Export data</Menu.Item>}
{account.hasPermission("core.instance.kill") && <Menu.Item
disabled={["unknown", "unassigned", "stopped"].includes(instance["status"])}
key="kill"
>Kill process</Menu.Item>}
{account.hasPermission("core.instance.delete") && <>
<Menu.Divider />
<Menu.Item
disabled={!["unknown", "unassigned", "stopped"].includes(instance["status"])}
>
<DeleteOutlined />
</Button>
</Popconfirm>}
danger
key="delete"
icon={<DeleteOutlined />}
>Delete</Menu.Item>
</>}
</Menu>;
let instanceButtons = <Space>
{
account.hasAnyPermission("core.instance.start", "core.instance.stop")
&& <StartStopInstanceButton instance={instance} />
}
{account.hasPermission("core.instance.load_scenario") && <LoadScenarioModal instance={instance} />}
{account.hasAnyPermission(
"core.instance.export_data",
"core.instance.kill",
"core.instance.delete",
) && <Dropdown placement="bottomRight" trigger={["click"]} overlay={instanceButtonsMenu}>
<Button>More <DownOutlined /></Button>
</Dropdown>}
</Space>;
let assigned = instance["assigned_slave"] !== null;
return <PageLayout nav={nav}>
<Descriptions
bordered
size="small"
<PageHeader
className="site-page-header"
title={instance["name"]}
extra={instanceButtons}
>
<Descriptions.Item label="Slave">
{!assigned
? <em>Unassigned</em>
: slave["name"] || instance["assigned_slave"]
}
{account.hasPermission("core.instance.assign") && <AssignInstanceModal
id={instanceId}
slaveId={instance["assigned_slave"]}
buttonProps={{
size: "small",
style: { float: "Right" },
type: assigned ? "default" : "primary",
disabled: !["unknown", "unassigned", "stopped"].includes(instance["status"]),
}}
buttonContent={assigned ? "Reassign" : "Assign"}
/>}
</Descriptions.Item>
<Descriptions.Item label="Status"><InstanceStatusTag status={instance["status"]} /></Descriptions.Item>
</Descriptions>
/>
<InstanceDescription slave={slave} instance={instance} />
{
account.hasAllPermission("core.instance.save.list", "core.instance.save.list_subscribe")
+19 -15
View File
@@ -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");
});
});