Compare commits

...

2 commits

Author SHA1 Message Date
dev
f0d6c62347 Merge pull request 'DIS-007: Lock-File gegen Doppelstart' (#27) from phase-0/startup-lockfile into main
Reviewed-on: #27
2026-04-09 10:55:29 +00:00
Nick Tabeling
dc4a1ef241 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.
2026-04-09 11:49:17 +02:00
3 changed files with 154 additions and 2 deletions

View file

@ -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<void> {
`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);
};

57
src/runtime/lockfile.ts Normal file
View file

@ -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;
}

View file

@ -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);
});
});