From 6d09bb6a85526bf9ff73362d5b0cb117deb78bae Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Mon, 25 May 2026 16:47:17 +0300 Subject: [PATCH] fix(supervisor): pidfile lock prevents two supervisors racing on the nickname MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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: Claude Opus 4.7 (1M context) --- package.json | 3 ++- runtime/supervisor.js | 52 ++++++++++++++++++++++++++++++++++++++++++- scripts/stop.sh | 29 ++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100755 scripts/stop.sh diff --git a/package.json b/package.json index 406f7ba..ed24a63 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "bot": "node runtime/supervisor.js", "bot:bare": "node runtime/bot.js", "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": { "dotenv": "^16.4.5", diff --git a/runtime/supervisor.js b/runtime/supervisor.js index dcf65c5..f87573a 100644 --- a/runtime/supervisor.js +++ b/runtime/supervisor.js @@ -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(); diff --git a/scripts/stop.sh b/scripts/stop.sh new file mode 100755 index 0000000..dac718e --- /dev/null +++ b/scripts/stop.sh @@ -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." -- 2.54.0