From d48b731ea4269700fc7f99a983f843776038120a Mon Sep 17 00:00:00 2001 From: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com> Date: Thu, 8 Jan 2026 22:48:00 +0000 Subject: [PATCH] Add lockfile bypass --- packages/controller/controller.ts | 10 ++- packages/ctl/ctl.ts | 10 ++- packages/host/host.ts | 10 ++- test/integration/clusterio.js | 123 +++++++++++++++++++++++++++--- test/integration/index.js | 9 ++- test/lib/LockFile.test.js | 7 ++ 6 files changed, 148 insertions(+), 21 deletions(-) diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts index 3eabb814..5e3a4838 100644 --- a/packages/controller/controller.ts +++ b/packages/controller/controller.ts @@ -255,6 +255,7 @@ async function initialize(): Promise { 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 => { @@ -355,9 +356,14 @@ async function initialize(): Promise { lib.registerPluginMessages(pluginInfos); lib.addPluginConfigFields(pluginInfos); - let controllerConfig; const controllerConfigPath = args.config; - const controllerConfigLock = new lib.LockFile(`${controllerConfigPath}.lock`); + 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); diff --git a/packages/ctl/ctl.ts b/packages/ctl/ctl.ts index 6d2816f1..793dd1ab 100755 --- a/packages/ctl/ctl.ts +++ b/packages/ctl/ctl.ts @@ -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,9 +235,14 @@ export async function initialize( .parse(argv) as CtlArguments ; - let controlConfig; const controlConfigPath = args.config; - const controlConfigLock = new lib.LockFile(`${controlConfigPath}.lock`); + 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); diff --git a/packages/host/host.ts b/packages/host/host.ts index 259fb31c..c3f539a5 100755 --- a/packages/host/host.ts +++ b/packages/host/host.ts @@ -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,9 +168,14 @@ async function startHost() { lib.registerPluginMessages(pluginInfos); lib.addPluginConfigFields(pluginInfos); - let hostConfig; const hostConfigPath = args.config; - const hostConfigLock = new lib.LockFile(`${hostConfigPath}.lock`); + 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); diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 8532b57a..3fe1805f 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -14,7 +14,7 @@ const testStrings = require("../lib/factorio/test_strings"); const { TestControl, TestControlConnector, url, controlToken, slowTest, execCtl, execCtlProcess, execController, execHost, sendRcon, getControl, - spawnNode, instancesDir, factorioDir, + spawnNode, instancesDir, factorioDir, databaseDir, controllerConfigPath, } = require("./index"); @@ -120,22 +120,46 @@ describe("Integration of Clusterio", function() { }); describe("bootstrap create-admin", function() { - it("refuses to modify the database files", async 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("refuses to modify the config files", async function() { + it("should refuse to modify the config file while locked", async function() { await assert.rejects( - execController("confit set controller.name ConfigEditTest") + 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() { @@ -162,9 +186,38 @@ describe("Integration of Clusterio", function() { await events.once(connectorB, "resume"); await connectorB.close(1000, ""); }); - it("refuses to start a second process", async function() { + 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() { @@ -271,14 +324,62 @@ describe("Integration of Clusterio", function() { it("can read the config file", async function() { await execHost("config list"); }); - it("refuses to modify the config files", async function() { - await assert.rejects( - execHost("confit set host.name ConfigEditTest") - ); + 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"); }); }); - it("refuses to start a second process", async function() { - await assert.rejects(execHost("run")); + 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); + } + }); }); }); diff --git a/test/integration/index.js b/test/integration/index.js index 7d507040..c47f18f7 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -229,7 +229,8 @@ function getControl() { return control; } -function spawn(name, cmd, waitFor) { +/** @returns {Promise} */ +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() { diff --git a/test/lib/LockFile.test.js b/test/lib/LockFile.test.js index 7034ecaf..6bceeb06 100644 --- a/test/lib/LockFile.test.js +++ b/test/lib/LockFile.test.js @@ -79,6 +79,13 @@ describe("class LockFile", function () { 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 () {