From dc4a1ef2412e5745855ca411c61a48a6536e3976 Mon Sep 17 00:00:00 2001 From: Nick Tabeling Date: Thu, 9 Apr 2026 11:49:17 +0200 Subject: [PATCH] feat(runtime): add startup lockfile to prevent double-start Implements acquireLock() in src/runtime/lockfile.ts that writes a PID-stamped lock file under workspaces_root, detects live vs stale locks via process.kill(pid, 0), and returns a release function. src/index.ts acquires the lock before DB init and releases it in the graceful-shutdown handler. Four unit tests cover acquire, duplicate- acquire rejection, stale-lock recovery, and release. --- src/index.ts | 11 ++++- src/runtime/lockfile.ts | 57 ++++++++++++++++++++++++ tests/unit/lockfile.test.ts | 88 +++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 src/runtime/lockfile.ts create mode 100644 tests/unit/lockfile.test.ts diff --git a/src/index.ts b/src/index.ts index b499a46..49fd4eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import * as path from "path"; import { loadEnv, loadDisclawConfig } from "./config/loader"; import { DisclawDatabase } from "./db/database"; import { startBot } from "./bot"; +import { acquireLock } from "./runtime/lockfile"; const ROOT_DIR = path.resolve(__dirname, ".."); @@ -15,18 +16,24 @@ async function main(): Promise { `Config loaded. Workspaces root: ${disclawConfig.workspaces_root}` ); - // 2. Initialize database + // 2. Acquire startup lock (before DB init) + const lockRelease = await acquireLock( + path.join(disclawConfig.workspaces_root, ".disclaw.lock") + ); + + // 3. Initialize database const dbPath = path.join(ROOT_DIR, "data", "disclaw.db"); const db = new DisclawDatabase(dbPath); console.log(`Database initialized at ${dbPath}`); - // 3. Start Discord bot + // 4. Start Discord bot await startBot(envConfig, disclawConfig, db); // Graceful shutdown const shutdown = (): void => { console.log("\nShutting down..."); db.close(); + lockRelease(); process.exit(0); }; diff --git a/src/runtime/lockfile.ts b/src/runtime/lockfile.ts new file mode 100644 index 0000000..5708ef8 --- /dev/null +++ b/src/runtime/lockfile.ts @@ -0,0 +1,57 @@ +import * as fs from "fs"; +import * as path from "path"; + +interface LockData { + pid: number; + started: string; +} + +function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +export async function acquireLock(lockPath: string): Promise<() => void> { + if (fs.existsSync(lockPath)) { + const raw = fs.readFileSync(lockPath, "utf-8"); + let data: LockData; + try { + data = JSON.parse(raw) as LockData; + } catch { + // Corrupted lock file — treat as stale, overwrite + data = { pid: 0, started: "" }; + } + + if (data.pid && isProcessRunning(data.pid)) { + throw new Error( + `DisClaw bereits aktiv (PID ${data.pid}). Lockfile: ${lockPath}` + ); + } + // Stale lock — fall through and overwrite + } + + // Ensure parent directory exists + const dir = path.dirname(lockPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const lockData: LockData = { + pid: process.pid, + started: new Date().toISOString(), + }; + + fs.writeFileSync(lockPath, JSON.stringify(lockData), "utf-8"); + + const release = (): void => { + if (fs.existsSync(lockPath)) { + fs.unlinkSync(lockPath); + } + }; + + return release; +} diff --git a/tests/unit/lockfile.test.ts b/tests/unit/lockfile.test.ts new file mode 100644 index 0000000..b702946 --- /dev/null +++ b/tests/unit/lockfile.test.ts @@ -0,0 +1,88 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { describe, it, expect, afterEach } from "vitest"; +import { acquireLock } from "../../src/runtime/lockfile"; + +function makeTmpDir(): string { + const suffix = Math.random().toString(36).slice(2); + const dir = path.join(os.tmpdir(), `disclaw-test-${suffix}`); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +describe("acquireLock", () => { + const dirs: string[] = []; + + afterEach(() => { + for (const dir of dirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + dirs.length = 0; + }); + + it("(a) first acquire writes lock file with correct PID", async () => { + const dir = makeTmpDir(); + dirs.push(dir); + const lockPath = path.join(dir, ".disclaw.lock"); + + const release = await acquireLock(lockPath); + + expect(fs.existsSync(lockPath)).toBe(true); + const data = JSON.parse(fs.readFileSync(lockPath, "utf-8")) as { + pid: number; + started: string; + }; + expect(data.pid).toBe(process.pid); + expect(typeof data.started).toBe("string"); + expect(data.started).toMatch(/^\d{4}-\d{2}-\d{2}T/); + + release(); + }); + + it("(b) second acquire on same live lock throws", async () => { + const dir = makeTmpDir(); + dirs.push(dir); + const lockPath = path.join(dir, ".disclaw.lock"); + + const release = await acquireLock(lockPath); + + await expect(acquireLock(lockPath)).rejects.toThrow( + `DisClaw bereits aktiv (PID ${process.pid})` + ); + + release(); + }); + + it("(c) stale lock (dead PID) is overwritten", async () => { + const dir = makeTmpDir(); + dirs.push(dir); + const lockPath = path.join(dir, ".disclaw.lock"); + + // Write a stale lock with a PID that almost certainly doesn't exist + const staleData = { pid: 99999999, started: new Date().toISOString() }; + fs.writeFileSync(lockPath, JSON.stringify(staleData), "utf-8"); + + const release = await acquireLock(lockPath); + + const data = JSON.parse(fs.readFileSync(lockPath, "utf-8")) as { + pid: number; + started: string; + }; + expect(data.pid).toBe(process.pid); + + release(); + }); + + it("(d) release() deletes the lock file", async () => { + const dir = makeTmpDir(); + dirs.push(dir); + const lockPath = path.join(dir, ".disclaw.lock"); + + const release = await acquireLock(lockPath); + expect(fs.existsSync(lockPath)).toBe(true); + + release(); + expect(fs.existsSync(lockPath)).toBe(false); + }); +}); -- 2.45.2