Merge pull request #815 from Cooldude2606/feature/database-lock

Add extra startup warnings to CLI: User count and Lock file
This commit is contained in:
Daniel authored and GitHub committed 2026-01-25 20:08:36 +01:00
commit 22850b7cd0
10 files changed
+506 -27

No files matched your search

+1 -1
View File
@@ -232,7 +232,7 @@ Do not install the [Clusterio mod](https://mods.factorio.com/mod/clusterio), thi
## Running Clusterio
After completing the installation start up the controller and at least one host separately.
After completing the installation and creating your admin user, start up the controller and at least one host separately.
The installer provides the `run-controller` and `run-host` scripts to make this simple.
Once the controller process is running you can log into the web interface which is hosted by default on http://localhost:8080/ (adjust the port number if you changed it), use the admin authentication token provided from the installation to log in.
+26 -5
View File
@@ -127,7 +127,8 @@ void new lib.Gauge(
async function handleBootstrapCommand(
args: any,
controllerConfig: lib.ControllerConfig
controllerConfig: lib.ControllerConfig,
controllerConfigLock: lib.LockFile,
): Promise<void> {
let subCommand = args._[1];
@@ -141,6 +142,8 @@ async function handleBootstrapCommand(
await userManager.load(path.join(databaseDirectory, "users.json"));
if (subCommand === "create-admin") {
await controllerConfigLock.acquire(); // Also needed to write to the database files
if (!args.name) {
logger.error("name cannot be blank");
process.exitCode = 1;
@@ -156,6 +159,7 @@ async function handleBootstrapCommand(
admin.roleIds.add(adminRole.id);
admin.isAdmin = true;
await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
await controllerConfigLock.release();
} else if (subCommand === "generate-user-token") {
let user = userManager.getByName(args.name);
@@ -251,6 +255,7 @@ async function initialize(): Promise<InitializeParameters> {
default: "plugin-list.json",
type: "string",
})
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("config", "Manage Controller config", lib.configCommand)
.command("bootstrap", "Bootstrap access to cluster", yargs => {
@@ -281,6 +286,7 @@ async function initialize(): Promise<InitializeParameters> {
type: "boolean", nargs: 0, default: false,
describe: "Start the controller in recovery mode with all plugins disabled and hosts disconnected",
});
yargs.option("check-user-count", { hidden: true, type: "boolean", nargs: 0, default: true });
yargs.option("dev", { hidden: true, type: "boolean", nargs: 0 });
yargs.option("dev-plugin", { hidden: true, type: "array" });
})
@@ -350,8 +356,14 @@ async function initialize(): Promise<InitializeParameters> {
lib.registerPluginMessages(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
let controllerConfig;
const controllerConfigPath = args.config;
const controllerConfigLockPath = `${controllerConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(controllerConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
let controllerConfig;
const controllerConfigLock = new lib.LockFile(controllerConfigLockPath);
logger.info(`Loading config from ${controllerConfigPath}`);
try {
controllerConfig = await lib.ControllerConfig.fromFile("controller", controllerConfigPath);
@@ -377,10 +389,11 @@ async function initialize(): Promise<InitializeParameters> {
}
if (command === "config") {
await lib.handleConfigCommand(args, controllerConfig);
await lib.handleConfigCommand(args, controllerConfig, controllerConfigLock);
} else if (command === "bootstrap") {
await handleBootstrapCommand(args, controllerConfig);
await handleBootstrapCommand(args, controllerConfig, controllerConfigLock);
} else if (shouldRun) {
await controllerConfigLock.acquire(); // Hold the lock until process exit
}
return {
@@ -417,6 +430,14 @@ async function startup() {
...await Controller.bootstrap(controllerConfig)
);
// Refuse to start if there are no users loaded
if (args.checkUserCount && controller.userManager.users.size === 0) {
logger.fatal(
"Cannot start controller, no users loaded.\n" +
"Try `npx clusteriocontroller bootstrap create-admin <username>`");
process.exit(1);
}
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
+9 -2
View File
@@ -189,6 +189,7 @@ export async function initialize(
default: "plugin-list.json",
type: "string",
})
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("control-config", "Manage Control config", lib.configCommand)
.wrap(parser.terminalWidth())
@@ -234,8 +235,14 @@ export async function initialize(
.parse(argv) as CtlArguments
;
let controlConfig;
const controlConfigPath = args.config;
const controlConfigLockPath = `${controlConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(controlConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
let controlConfig;
const controlConfigLock = new lib.LockFile(controlConfigLockPath);
logger.verbose(`Loading config from ${controlConfigPath}`);
try {
controlConfig = await lib.ControlConfig.fromFile("control", controlConfigPath);
@@ -257,7 +264,7 @@ export async function initialize(
// Handle the control-config command before trying to connect.
if (args._[0] === "control-config") {
await lib.handleConfigCommand(args, controlConfig);
await lib.handleConfigCommand(args, controlConfig, controlConfigLock);
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: false };
}
+12 -2
View File
@@ -113,6 +113,7 @@ async function startHost() {
default: "plugin-list.json",
type: "string",
})
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("config", "Manage Host config", lib.configCommand)
.command("run", "Run host", yargs => {
@@ -167,8 +168,14 @@ async function startHost() {
lib.registerPluginMessages(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
let hostConfig;
const hostConfigPath = args.config;
const hostConfigLockPath = `${hostConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(hostConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
let hostConfig;
const hostConfigLock = new lib.LockFile(hostConfigLockPath);
logger.info(`Loading config from ${hostConfigPath}`);
try {
hostConfig = await lib.HostConfig.fromFile("host", hostConfigPath);
@@ -186,10 +193,13 @@ async function startHost() {
hostConfig.set("host.version", version); // Allows tracking last loaded version
if (command === "config") {
await lib.handleConfigCommand(args, hostConfig);
await lib.handleConfigCommand(args, hostConfig, hostConfigLock);
return;
}
// From this point on the host should run
await hostConfigLock.acquire();
// If we get here the command was run
await fs.ensureDir(hostConfig.get("host.instances_directory"));
await fs.ensureDir(hostConfig.get("host.mods_directory"));
+1
View File
@@ -32,6 +32,7 @@ export * from "./src/subscriptions";
export * from "./src/datastore";
export * from "./src/load_plugin_list";
export * from "./src/rce_ops";
export * from "./src/LockFile";
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
export { default as ModStore } from "./src/ModStore";
+163
View File
@@ -0,0 +1,163 @@
import { FileHandle, open, unlink, readFile } from "fs/promises";
import { unlinkSync } from "fs";
import assert from "assert";
/**
* Error thrown when the lock file already exists.
*/
export class LockFileExistsError extends Error {
constructor(
readonly filePath: string
) {
super(`Cannot acquire lock, a valid lock file exists at ${filePath}`);
this.name = "LockFileExistsError";
}
}
/**
* Error thrown when the acquire or release called during an invalid state.
*/
export class LockFileBusyError extends Error {
constructor(message: string) {
super(message);
this.name = "LockFileBusyError";
}
}
type LockState = "idle" | "acquiring" | "acquired" | "releasing";
/**
* Cross-platform lock file utility for Node.js.
*/
export class LockFile {
private handle: FileHandle | null = null;
private state: LockState = "idle";
constructor(
private readonly filePath: string
) {}
/**
* Acquire the lock file.
* Throws LockFileExistsError if already locked.
* Throws LockFileBusyError if not in idle state.
*/
async acquire(): Promise<void> {
if (this.state !== "idle") {
throw new LockFileBusyError(`Cannot acquire lock, current state is "${this.state}"`);
}
this.state = "acquiring";
if (await this.isStale()) {
await unlink(this.filePath).catch(() => {}); // ignore errors, may already be removed
}
try {
this.handle = await open(this.filePath, "wx");
await this.handle.write(`${process.pid}\n`);
this.attachExitHandlers();
this.state = "acquired";
} catch (err: any) {
this.state = "idle";
if (err?.code === "EEXIST") {
throw new LockFileExistsError(this.filePath);
}
throw err;
}
}
/**
* Release the lock file.
* Throws LockFileBusyError if not in acquired state.
*/
async release(): Promise<void> {
if (this.state !== "acquired") {
throw new LockFileBusyError(`Cannot release lock, current state is "${this.state}"`);
}
this.state = "releasing";
this.detachExitHandlers();
// Ignored: file may have already been removed by another process
try { await this.handle?.close(); } catch {}
try { await unlink(this.filePath); } catch {}
this.handle = null;
this.state = "idle";
}
/**
* Check whether the lock is currently held by this process.
* True only if the lock is acquired and the PID in the file
* matches the current process.
*/
async isHeld(): Promise<boolean> {
if (this.state !== "acquired") {
return false;
}
try {
const pidInLock = Number(await readFile(this.filePath, "utf8"));
return pidInLock === process.pid;
} catch {
// Ignored: file may not exist or be unreadable, so lock is not held
return false;
}
}
/**
* Determine whether an existing lock is stale.
*/
async isStale(): Promise<boolean> {
try {
const pidInLock = Number(await readFile(this.filePath, "utf8"));
if (!Number.isFinite(pidInLock)) { return true; }
// signal 0 is a special case to test if a process exists
process.kill(pidInLock, 0);
return false;
} catch {
return true;
}
}
/**
* Attach process handlers, called when transitioning to "acquired"
*/
private attachExitHandlers(): void {
assert(this.state === "acquiring");
process.on("SIGINT", this.handleExit);
process.on("SIGTERM", this.handleExit);
process.on("SIGHUP", this.handleExit);
process.on("uncaughtException", this.handleExit);
// eslint-disable-next-line node/no-sync
process.on("exit", this.handleExitSync);
}
/**
* Detach process handlers, called when transitioning to "idle"
*/
private async detachExitHandlers(): Promise<void> {
assert(this.state === "releasing");
process.off("SIGINT", this.handleExit);
process.off("SIGTERM", this.handleExit);
process.off("SIGHUP", this.handleExit);
process.off("uncaughtException", this.handleExit);
// eslint-disable-next-line node/no-sync
process.off("exit", this.handleExitSync);
}
// Event handler for async signals / exceptions
private handleExit = async (): Promise<void> => {
await this.release();
};
// Event handler for synchronous process exit
private handleExitSync = (): void => {
try {
unlinkSync(this.filePath);
} catch (err) {
// Ignored: file may have already been removed by another process
}
};
}
+7 -1
View File
@@ -8,6 +8,7 @@ import * as libConfig from "./config";
import * as libFileOps from "./file_ops";
import { logger } from "./logging";
import * as libHelpers from "./helpers";
import { LockFile } from "./LockFile";
function print(...content: any[]) {
@@ -130,11 +131,12 @@ export function configCommand(yargs: any) {
*
* @param args - yargs args object.
* @param instance - Config instance.
* @param configPath - Path to configuration file.
* @param lockFile - Lockfile required if attempting to write
*/
export async function handleConfigCommand(
args: Record<string, unknown>,
instance: libConfig.Config<any>,
lockFile: LockFile,
) {
let command = (args._ as string[])[1];
@@ -155,6 +157,8 @@ export async function handleConfigCommand(
}
} else if (command === "set") {
await lockFile.acquire();
if (args.stdin) {
args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
@@ -172,5 +176,7 @@ export async function handleConfigCommand(
throw err;
}
}
await lockFile.release();
}
}
+140 -4
View File
@@ -13,8 +13,8 @@ const { wait } = lib;
const testStrings = require("../lib/factorio/test_strings");
const {
TestControl, TestControlConnector, url, controlToken, slowTest,
exec, execCtl, execCtlProcess, execHost, sendRcon, getControl,
spawnNode, instancesDir, factorioDir,
execCtl, execCtlProcess, execController, execHost, sendRcon, getControl,
spawnNode, instancesDir, factorioDir, databaseDir, controllerConfigPath,
} = require("./index");
@@ -109,16 +109,59 @@ describe("Integration of Clusterio", function() {
describe("clusteriocontroller", function() {
describe("bootstrap generate-user-token", function() {
it("work for existing user", async function() {
await exec("node ../../packages/controller bootstrap generate-user-token test");
await execController("bootstrap generate-user-token test");
});
it("fails if user does not exist", async function() {
await assert.rejects(
exec("node ../../packages/controller bootstrap generate-user-token invalid")
execController("bootstrap generate-user-token invalid")
);
});
});
describe("bootstrap create-admin", function() {
it("should refuse to modify the database files when locked", async function() {
await assert.rejects(
execController("bootstrap create-admin BootstrapAdminTest")
);
});
it("should modify the database files when locked if bypass option is given", async function() {
const lockFilePath = `${controllerConfigPath}.lock`;
await fs.copyFile(lockFilePath, "temp.lock");
try {
await execController("bootstrap create-admin BootstrapAdminTest2 --bypass-lock-file");
} finally {
await fs.move("temp.lock", lockFilePath); // Replace the lockfile after it is deleted
}
const json = await fs.readJSON(path.join(databaseDir, "users.json"));
assert.equal(Object.values(json).some(user => user.name === "BootstrapAdminTest2"), true);
});
});
describe("config", function() {
it("can read the config file", async function() {
await execController("config list");
});
it("should refuse to modify the config file while locked", async function() {
await assert.rejects(
execController("config set controller.name ConfigEditTest")
);
});
it("should modify the config file while locked if bypass option is given", async function() {
const lockFilePath = `${controllerConfigPath}.lock`;
await fs.copyFile(lockFilePath, "temp.lock");
try {
await execController("config set controller.name ConfigEditTest2 --bypass-lock-file");
} finally {
await fs.move("temp.lock", lockFilePath); // Replace the lockfile after it is deleted
}
const json = await fs.readJSON(path.join(controllerConfigPath));
assert.equal(json["controller.name"], "ConfigEditTest2");
});
});
describe("run", function() {
it("should handle resume of an active connection", async function() {
slowTest(this);
@@ -143,6 +186,38 @@ describe("Integration of Clusterio", function() {
await events.once(connectorB, "resume");
await connectorB.close(1000, "");
});
it("should refuse to start a second process while locked", async function() {
await assert.rejects(execController("run"));
});
it("should start a second process while locked if bypass option is given", async function() {
slowTest(this);
const lockFilePath = `${controllerConfigPath}.lock`;
await fs.copyFile(lockFilePath, "temp.lock");
try {
await execController("run --bypass-lock-file");
} catch (err) {
assert.equal(/Error: Server listening failed/.test(err.stderr), true);
}
await fs.move("temp.lock", lockFilePath); // Replace the lockfile after it is deleted
});
it("should refuse to start when no users are loaded", async function() {
const dir = path.join("temp", "test", "empty_controller");
await fs.emptyDir(dir);
await assert.rejects(execController("run", { cwd: dir }));
});
it("should start when no users are loaded if bypass option given", async function() {
slowTest(this);
const dir = path.join("temp", "test", "empty_controller");
await fs.emptyDir(dir);
const child = await spawnNode(
"altController",
"../../../packages/controller run --no-check-user-count",
/Started controller/,
{ cwd: dir },
);
assert.ok(child.kill());
assert.equal(child.killed, true);
});
});
describe("queryLogRequestHandler", function() {
@@ -245,6 +320,67 @@ describe("Integration of Clusterio", function() {
await stopAltHost(hostProcess);
}
});
describe("config", function() {
it("can read the config file", async function() {
await execHost("config list");
});
it("should refuse to modify the config file while locked", async function() {
slowTest(this);
let hostProcess; // Alt host used since the process could get killed
const config = "alt-host-config.json";
try {
hostProcess = await startAltHost();
await assert.rejects(
execHost(`--config ${config} config set host.name ConfigEditTest`)
);
} finally {
await stopAltHost(hostProcess);
}
});
it("should modify the config file while locked if bypass option is given", async function() {
slowTest(this);
let hostProcess; // Alt host used since the process could get killed
const config = "alt-host-config.json";
try {
hostProcess = await startAltHost();
await execHost(`--config ${config} config set host.name ConfigEditTest2 --bypass-lock-file`);
} finally {
await stopAltHost(hostProcess);
}
const json = await fs.readJSON(path.join("temp", "test", config));
assert.equal(json["host.name"], "ConfigEditTest2");
});
});
describe("run", function() {
it("should refuse to start a second process while locked", async function() {
slowTest(this);
let hostProcess; // Alt host used since the process could get killed
const config = "alt-host-config.json";
try {
hostProcess = await startAltHost();
await assert.rejects(
execHost(`--config ${config} run`)
);
} finally {
await stopAltHost(hostProcess);
}
});
it("should start a second process while locked if bypass option is given", async function() {
slowTest(this);
let hostProcessA, hostProcessB; // Alt host used since the process could get killed
const config = "alt-host-config.json";
try {
hostProcessA = await startAltHost();
hostProcessB = await spawnNode(
"alt-host-b:", `../../packages/host run --config ${config} --bypass-lock-file`, /Started host/
);
} finally {
await stopAltHost(hostProcessA);
await stopAltHost(hostProcessB);
}
});
});
});
describe("clusterioctl", function() {
+14 -12
View File
@@ -173,26 +173,26 @@ let controllerConfigPath = path.join("temp", "test", "config-controller.json");
let hostConfigPath = path.join("temp", "test", "config-host.json");
let controlConfigPath = path.join("temp", "test", "config-control.json");
async function exec(command, options = {}) {
function exec(command, options = {}) {
// Uncomment to show commands run in tests
// console.log(command);
options = { cwd: path.join("temp", "test"), ...options };
return await util.promisify(child_process.exec)(command, options);
return util.promisify(child_process.exec)(command, options);
}
async function execController(...args) {
function execController(...args) {
args[0] = `node --enable-source-maps ../../packages/controller ${args[0]}`;
return await exec(...args);
return exec(...args);
}
async function execHost(...args) {
function execHost(...args) {
args[0] = `node --enable-source-maps ../../packages/host ${args[0]}`;
return await exec(...args);
return exec(...args);
}
async function execCtlProcess(...args) {
function execCtlProcess(...args) {
args[0] = `node --enable-source-maps ../../packages/ctl ${args[0]}`;
return await exec(...args);
return exec(...args);
}
let inExecCtl = false;
@@ -229,7 +229,8 @@ function getControl() {
return control;
}
function spawn(name, cmd, waitFor) {
/** @returns {Promise<child_process.ChildProcess>} */
function spawn(name, cmd, waitFor, options = {}) {
const silent = process.env.SILENT_TEST;
const bootstrap = !controllerProcess || !hostProcess;
@@ -242,7 +243,7 @@ function spawn(name, cmd, waitFor) {
return new Promise((resolve, reject) => {
log(cmd);
let parts = cmd.split(" ");
let process = child_process.spawn(parts[0], parts.slice(1), { cwd: path.join("temp", "test") });
let process = child_process.spawn(parts[0], parts.slice(1), { cwd: path.join("temp", "test"), ...options });
let stdout = new LineSplitter({ readableObjectMode: true });
let stderr = new LineSplitter({ readableObjectMode: true });
let onDataOut = line => {
@@ -266,8 +267,8 @@ function spawn(name, cmd, waitFor) {
});
}
async function spawnNode(name, cmd, waitFor) {
return await spawn(name, `node --enable-source-maps ${cmd}`, waitFor);
function spawnNode(name, cmd, waitFor, options) {
return spawn(name, `node --enable-source-maps ${cmd}`, waitFor, options);
}
before(async function() {
@@ -394,6 +395,7 @@ module.exports = {
TestControl,
TestControlConnector,
TestHostConnector,
execController,
execCtlProcess,
slowTest,
get,
+133
View File
@@ -0,0 +1,133 @@
/* eslint-disable node/no-sync */
"use strict";
const assert = require("assert").strict;
const fs = require("fs-extra");
const path = require("path");
const { LockFile } = require("@clusterio/lib");
const baseDir = path.join("temp", "test", "lock_file");
describe("class LockFile", function () {
/** @type {string} */
let filePath;
/** @type {LockFile} */
let lock;
before(async function () {
await fs.ensureDir(baseDir);
});
beforeEach(function() {
filePath = path.join(baseDir, `${Date.now()}-${Math.random()}.lock`);
lock = new LockFile(filePath);
});
afterEach(async function () {
await fs.unlink(filePath).catch(() => {});
});
describe("acquire()", function () {
it("should create the lock file when acquiring", async function () {
await lock.acquire();
assert.ok(fs.existsSync(filePath));
});
it("should throw LockFileBusyError if called when not idle", async function () {
await lock.acquire();
await assert.rejects(lock.acquire(), { name: "LockFileBusyError" });
});
it("should remove stale lock file and acquire it", async function () {
await fs.writeFile(filePath, "999999\n"); // stale lock
await lock.acquire();
assert.ok(fs.existsSync(filePath));
});
it("should throw if existing lock file is not stale", async function () {
await fs.writeFile(filePath, `${process.pid}\n`);
await assert.rejects(lock.acquire(), { name: "LockFileExistsError" });
});
});
describe("release()", function () {
it("should remove the lock file when releasing", async function () {
await lock.acquire();
await lock.release();
assert.ok(!fs.existsSync(filePath));
});
it("should throw LockFileBusyError if called when not acquired", async function () {
await assert.rejects(lock.release(), { name: "LockFileBusyError" });
});
});
describe("isHeld()", function () {
it("should return true only when lock file contains current PID", async function () {
await lock.acquire();
const held = await lock.isHeld();
assert.equal(held, true);
});
it("should return false when lock file does not exist", async function () {
const held = await lock.isHeld();
assert.equal(held, false);
});
it("should return false if lock file contains different PID", async function () {
await fs.writeFile(filePath, "999999\n"); // simulate another process
const held = await lock.isHeld();
assert.equal(held, false);
});
it("should return false if lock file was deleted while held", async function() {
await lock.acquire();
await fs.unlink(filePath);
const held = await lock.isHeld();
assert.equal(held, false);
});
});
describe("isStale()", function () {
it("should return true if lock file does not exist", async function () {
const stale = await lock.isStale();
assert.equal(stale, true);
});
it("should return true for invalid PID in lock file", async function () {
await fs.writeFile(filePath, "abc\n"); // non-numeric PID
const stale = await lock.isStale();
assert.equal(stale, true);
});
it("should return true for non-existent process PID", async function () {
await fs.writeFile(filePath, "999999\n"); // assume PID does not exist
const stale = await lock.isStale();
assert.equal(stale, true);
});
it("should return false for current process PID", async function () {
await fs.writeFile(filePath, `${process.pid}\n`);
const stale = await lock.isStale();
assert.equal(stale, false);
});
});
describe("attach/detach exit handlers", function () {
it("should attach exit handler on acquire and detach on release", async function () {
await lock.acquire();
const listenersBefore = process.listeners("exit").slice();
assert.ok(listenersBefore.includes(lock.handleExitSync));
await lock.release();
const listenersAfter = process.listeners("exit").slice();
assert.ok(!listenersAfter.includes(lock.handleExitSync));
});
});
describe("handleExitSync()", function () {
it("should not throw if file already removed", function () {
assert.doesNotThrow(() => lock.handleExitSync()); // simulate non-existent file
});
});
});