fix(supervisor): pidfile lock to prevent nickname collision #9
+2
-1
@@ -14,7 +14,8 @@
|
|||||||
"bot": "node runtime/supervisor.js",
|
"bot": "node runtime/supervisor.js",
|
||||||
"bot:bare": "node runtime/bot.js",
|
"bot:bare": "node runtime/bot.js",
|
||||||
"tui": "tsx tui/tui.tsx",
|
"tui": "tsx tui/tui.tsx",
|
||||||
"propose:apply": "node scripts/propose-apply.js"
|
"propose:apply": "node scripts/propose-apply.js",
|
||||||
|
"stop": "bash scripts/stop.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
|
|||||||
+51
-1
@@ -16,6 +16,8 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { stateDir } from "./config.js";
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const RUNTIME_DIR = __dirname;
|
const RUNTIME_DIR = __dirname;
|
||||||
@@ -25,6 +27,13 @@ export const RELOAD_EXIT_CODE = 42;
|
|||||||
const WATCH_DEBOUNCE_MS = 800;
|
const WATCH_DEBOUNCE_MS = 800;
|
||||||
const MAX_RESTARTS_PER_MINUTE = 5;
|
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 child = null;
|
||||||
let restartingDueToWatch = false;
|
let restartingDueToWatch = false;
|
||||||
const restartTimestamps = [];
|
const restartTimestamps = [];
|
||||||
@@ -33,6 +42,42 @@ function nowMs() {
|
|||||||
return Date.now();
|
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() {
|
function spawnChild() {
|
||||||
child = spawn(process.execPath, [BOT_ENTRY], {
|
child = spawn(process.execPath, [BOT_ENTRY], {
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
@@ -88,6 +133,7 @@ function watchRuntime() {
|
|||||||
for (const sig of ["SIGINT", "SIGTERM"]) {
|
for (const sig of ["SIGINT", "SIGTERM"]) {
|
||||||
process.on(sig, () => {
|
process.on(sig, () => {
|
||||||
console.log(`[supervisor] forwarding ${sig} to child`);
|
console.log(`[supervisor] forwarding ${sig} to child`);
|
||||||
|
releaseLock();
|
||||||
if (!child) process.exit(0);
|
if (!child) process.exit(0);
|
||||||
child.once("exit", () => process.exit(0));
|
child.once("exit", () => process.exit(0));
|
||||||
child.kill(sig);
|
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();
|
spawnChild();
|
||||||
watchRuntime();
|
watchRuntime();
|
||||||
|
|||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Emergency stop: find any pepa-pi-bot supervisor/child processes plus any
|
||||||
|
# straggler TCP connection to the configured MC server, and terminate them.
|
||||||
|
# Useful when a smoke-test or a crashed instance left the nickname locked.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
|
||||||
|
echo "→ looking for supervisor + bot processes…"
|
||||||
|
PIDS=$(pgrep -f "$REPO_ROOT/runtime/(supervisor|bot)\.js" 2>/dev/null || true)
|
||||||
|
if [ -n "$PIDS" ]; then
|
||||||
|
echo " killing: $PIDS"
|
||||||
|
echo "$PIDS" | xargs kill -TERM 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
PIDS=$(pgrep -f "$REPO_ROOT/runtime/(supervisor|bot)\.js" 2>/dev/null || true)
|
||||||
|
if [ -n "$PIDS" ]; then
|
||||||
|
echo " still alive after TERM, sending KILL: $PIDS"
|
||||||
|
echo "$PIDS" | xargs kill -KILL 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " none found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "→ cleaning pidfile + bot.sock…"
|
||||||
|
find "$REPO_ROOT/state" -name "supervisor.pid" -delete 2>/dev/null || true
|
||||||
|
find "$REPO_ROOT/state" -name "bot.sock" -delete 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "→ done. Wait ~30s for the MC server to drop the old session before re-launching."
|
||||||
Reference in New Issue
Block a user