fix(supervisor): pidfile lock prevents two supervisors racing on the nickname (#9)
User report 2026-05-25: launched 'npm run bot' fresh, MC server kicked
every login with "Игрок с данным никнеймом уже играет на сервере" and
the bot fell into a perpetual reconnect-then-kicked loop. Root cause:
a smoke-test supervisor from an earlier shell was still running in the
background, holding the pepa_bot session open. Two supervisors racing
on the same nickname is undefined behaviour from the server's side and
results in this exact failure mode.
Changes:
runtime/supervisor.js — acquires state/<host>/supervisor.pid before
spawning the child. If another supervisor is alive (kill -0 check), the
new one exits with a clear message telling the operator how to recover.
On SIGINT/SIGTERM/exit the lock is released; stale pidfiles are detected
when the recorded PID is no longer alive.
scripts/stop.sh — emergency cleanup helper:
- kills any supervisor or bot.js processes matching this repo
- removes pidfile + bot.sock
- reminds the operator to wait ~30s for the MC server to drop the old
session before re-launching
package.json — new `npm run stop` script.
Smoke-tested:
- first 'node runtime/supervisor.js' acquires lock, writes pid
- second call refuses with diagnostic message
- first SIGTERM ⇒ pidfile removed automatically
Co-authored-by: Yuriy Mayatnikov <mayatnikov@me.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #9.
This commit is contained in:
+51
-1
@@ -16,6 +16,8 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { stateDir } from "./config.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const RUNTIME_DIR = __dirname;
|
||||
@@ -25,6 +27,13 @@ export const RELOAD_EXIT_CODE = 42;
|
||||
const WATCH_DEBOUNCE_MS = 800;
|
||||
const MAX_RESTARTS_PER_MINUTE = 5;
|
||||
|
||||
// Pidfile prevents two supervisors from racing on the same MC nickname. The
|
||||
// Minecraft server refuses the second login ("Игрок с данным никнеймом уже
|
||||
// играет на сервере") and the loser enters a kick-reconnect loop forever.
|
||||
// Observed live 2026-05-25 when a smoke-test bot stayed alive in the
|
||||
// background after its operator window was closed.
|
||||
const PID_FILE = path.join(stateDir, "supervisor.pid");
|
||||
|
||||
let child = null;
|
||||
let restartingDueToWatch = false;
|
||||
const restartTimestamps = [];
|
||||
@@ -33,6 +42,42 @@ function nowMs() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function isProcessAlive(pid) {
|
||||
try {
|
||||
// Signal 0 doesn't kill — just probes existence + permission.
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return e.code === "EPERM"; // exists but we don't own it
|
||||
}
|
||||
}
|
||||
|
||||
function acquireLock() {
|
||||
try {
|
||||
const existing = Number.parseInt(fs.readFileSync(PID_FILE, "utf8").trim(), 10);
|
||||
if (Number.isFinite(existing) && existing !== process.pid && isProcessAlive(existing)) {
|
||||
console.error(
|
||||
`[supervisor] another supervisor (pid=${existing}) is already running. ` +
|
||||
`Stop it first ('kill ${existing}') or delete ${PID_FILE} if it's stale.`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code !== "ENOENT") {
|
||||
console.error(`[supervisor] could not read pidfile: ${e.message}`);
|
||||
}
|
||||
}
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
fs.writeFileSync(PID_FILE, String(process.pid));
|
||||
}
|
||||
|
||||
function releaseLock() {
|
||||
try {
|
||||
const pid = Number.parseInt(fs.readFileSync(PID_FILE, "utf8").trim(), 10);
|
||||
if (pid === process.pid) fs.unlinkSync(PID_FILE);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function spawnChild() {
|
||||
child = spawn(process.execPath, [BOT_ENTRY], {
|
||||
stdio: "inherit",
|
||||
@@ -88,6 +133,7 @@ function watchRuntime() {
|
||||
for (const sig of ["SIGINT", "SIGTERM"]) {
|
||||
process.on(sig, () => {
|
||||
console.log(`[supervisor] forwarding ${sig} to child`);
|
||||
releaseLock();
|
||||
if (!child) process.exit(0);
|
||||
child.once("exit", () => process.exit(0));
|
||||
child.kill(sig);
|
||||
@@ -96,6 +142,10 @@ for (const sig of ["SIGINT", "SIGTERM"]) {
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[supervisor] starting; watching ${RUNTIME_DIR} for *.js changes`);
|
||||
// Best-effort lock release on any other exit path (uncaught, exit 1, etc).
|
||||
process.on("exit", releaseLock);
|
||||
|
||||
acquireLock();
|
||||
console.log(`[supervisor] starting (pid=${process.pid}); watching ${RUNTIME_DIR} for *.js changes`);
|
||||
spawnChild();
|
||||
watchRuntime();
|
||||
|
||||
Reference in New Issue
Block a user