mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 15:04:52 +09:00
Replace ESLint, typescript-eslint, eslint-plugin-node and the flat config helpers with vite-plus, which runs Oxlint for vp lint. The rules are carried over from eslint.config.mjs by @oxlint/migrate into the lint section of the root vite.config.ts. The formatting rules ESLint deprecated run as @stylistic rules through Oxlint's JS plugin support, and disable comments naming them are updated to the @stylistic names. @stylistic understands TypeScript syntax that the core rules skipped, so the autofix adds missing semicolons and spaces in type literals and aliases. Leading | and & in multiline types stay where they were. Oxlint's node/no-sync also reports identifiers ending in Sync that are not calls. The test helper techSync is renamed and the two other names are ignored. Refs #1127 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
277 lines
8.1 KiB
TypeScript
277 lines
8.1 KiB
TypeScript
import fs, { type FileHandle } from "node:fs/promises";
|
|
import { readFileSync, unlinkSync } from "node:fs";
|
|
import assert from "assert";
|
|
|
|
/**
|
|
* Error thrown when the lock file already exists.
|
|
*/
|
|
export class LockFileExistsError extends Error {
|
|
constructor(
|
|
readonly filePath: string
|
|
) {
|
|
super(`Cannot acquire lock, a valid lock file exists at ${filePath}`);
|
|
this.name = "LockFileExistsError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Error thrown when the acquire or release called during an invalid state.
|
|
*/
|
|
export class LockFileBusyError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "LockFileBusyError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Error thrown when a process exited while clearing a stale lock, which needs the recovery file removed by hand.
|
|
*/
|
|
export class LockFileRecoveryError extends Error {
|
|
constructor(
|
|
readonly recoveryPath: string
|
|
) {
|
|
super(`Lock file recovery was interrupted, remove ${recoveryPath} to allow the lock to be acquired`);
|
|
this.name = "LockFileRecoveryError";
|
|
}
|
|
}
|
|
|
|
type LockState = "idle" | "acquiring" | "acquired" | "releasing";
|
|
type LockSnapshot = { content: string, ino: bigint, mtimeNs: bigint };
|
|
|
|
/**
|
|
* Cross-platform lock file utility for Node.js.
|
|
*/
|
|
export class LockFile {
|
|
/**
|
|
* Content written to the lock file. The start time tells this process apart from an earlier one with
|
|
* the same pid, and is the same for every copy of lib loaded in this process.
|
|
*/
|
|
private static readonly content = `${process.pid} ${performance.timeOrigin}\n`;
|
|
// A lock file without a valid pid is left alone for this long, as it may still be being written
|
|
private static readonly writeGraceMs = 10e3;
|
|
|
|
private handle: FileHandle | null = null;
|
|
private state: LockState = "idle";
|
|
|
|
/**
|
|
* @param filePath - Path of the lock file
|
|
* @param handleSignals -
|
|
* Release the lock on SIGINT, SIGTERM, SIGHUP and uncaught exceptions. Listening to these
|
|
* changes how the process exits, so without it the lock is only removed on exit.
|
|
*/
|
|
constructor(
|
|
private readonly filePath: string,
|
|
private readonly handleSignals = true,
|
|
) {}
|
|
|
|
/** Content and identity of a lock file, read through one handle so both belong to the same file */
|
|
private static async readLock(filePath: string): Promise<LockSnapshot | undefined> {
|
|
let handle;
|
|
try {
|
|
handle = await fs.open(filePath, "r");
|
|
} catch (err: any) {
|
|
if (err.code === "ENOENT") {
|
|
return undefined;
|
|
}
|
|
throw err;
|
|
}
|
|
try {
|
|
const stat = await handle.stat({ bigint: true });
|
|
return { content: await handle.readFile("utf8"), ino: stat.ino, mtimeNs: stat.mtimeNs };
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
}
|
|
|
|
private static isSameLock(a: LockSnapshot, b: LockSnapshot) {
|
|
return a.content === b.content && a.ino === b.ino && a.mtimeNs === b.mtimeNs;
|
|
}
|
|
|
|
private static lockPid(lock: LockSnapshot) {
|
|
const pid = Number(lock.content.split(" ")[0]);
|
|
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
}
|
|
|
|
/** True if the lock names a process that is still running, or may still be written to */
|
|
private static lockIsLive(lock: LockSnapshot) {
|
|
const pid = LockFile.lockPid(lock);
|
|
if (pid === undefined) {
|
|
return Date.now() - Number(lock.mtimeNs / 1000000n) < LockFile.writeGraceMs;
|
|
}
|
|
if (pid === process.pid) {
|
|
// Held by this process, or left by an earlier process with the same pid
|
|
return lock.content === LockFile.content;
|
|
}
|
|
try {
|
|
// Signal 0 only checks that the process exists
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (err: any) {
|
|
return err.code !== "ESRCH";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Acquire the lock file.
|
|
* Throws LockFileExistsError if already locked.
|
|
* Throws LockFileBusyError if not in idle state.
|
|
* Throws LockFileRecoveryError if clearing a stale lock was interrupted.
|
|
*/
|
|
async acquire(): Promise<void> {
|
|
if (this.state !== "idle") {
|
|
throw new LockFileBusyError(`Cannot acquire lock, current state is "${this.state}"`);
|
|
}
|
|
|
|
this.state = "acquiring";
|
|
|
|
try {
|
|
await this.removeStale();
|
|
this.handle = await fs.open(this.filePath, "wx");
|
|
await this.handle.write(LockFile.content);
|
|
this.attachExitHandlers();
|
|
this.state = "acquired";
|
|
} catch (err: any) {
|
|
this.state = "idle";
|
|
if (err?.code === "EEXIST") {
|
|
throw new LockFileExistsError(this.filePath);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Release the lock file.
|
|
* Throws LockFileBusyError if not in acquired state.
|
|
*/
|
|
async release(): Promise<void> {
|
|
if (this.state !== "acquired") {
|
|
throw new LockFileBusyError(`Cannot release lock, current state is "${this.state}"`);
|
|
}
|
|
|
|
this.state = "releasing";
|
|
this.detachExitHandlers();
|
|
|
|
// Ignored: file may have already been removed by another process
|
|
try { await this.handle?.close(); } catch {}
|
|
if (await this.isHeldFile()) {
|
|
await fs.unlink(this.filePath).catch(() => {});
|
|
}
|
|
|
|
this.handle = null;
|
|
this.state = "idle";
|
|
}
|
|
|
|
/**
|
|
* Check whether the lock is currently held by this process.
|
|
* True only if the lock is acquired and the lock file was written by this process.
|
|
*/
|
|
async isHeld(): Promise<boolean> {
|
|
if (this.state !== "acquired") {
|
|
return false;
|
|
}
|
|
return await this.isHeldFile();
|
|
}
|
|
|
|
private async isHeldFile() {
|
|
try {
|
|
return (await LockFile.readLock(this.filePath))?.content === LockFile.content;
|
|
} catch {
|
|
// Ignored: file may be unreadable, so lock is not held
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determine whether an existing lock is stale.
|
|
*/
|
|
async isStale(): Promise<boolean> {
|
|
const lock = await LockFile.readLock(this.filePath);
|
|
return lock === undefined || !LockFile.lockIsLive(lock);
|
|
}
|
|
|
|
/** Pid of the process holding the lock, if it has written it */
|
|
async holderPid(): Promise<number | undefined> {
|
|
const lock = await LockFile.readLock(this.filePath);
|
|
return lock && LockFile.lockPid(lock);
|
|
}
|
|
|
|
/**
|
|
* Remove a stale lock file if it is still the same file. Clearing is done while holding a separate
|
|
* recovery lock, so a lock another process took after the stale check is never removed.
|
|
*/
|
|
private async removeStale(): Promise<void> {
|
|
const stale = await LockFile.readLock(this.filePath);
|
|
if (!stale || LockFile.lockIsLive(stale)) {
|
|
return;
|
|
}
|
|
const recoveryPath = `${this.filePath}.recovery`;
|
|
try {
|
|
await fs.writeFile(recoveryPath, LockFile.content, { flag: "wx" });
|
|
} catch (err: any) {
|
|
if (err.code !== "EEXIST") {
|
|
throw err;
|
|
}
|
|
const holder = await LockFile.readLock(recoveryPath);
|
|
if (holder && !LockFile.lockIsLive(holder)) {
|
|
throw new LockFileRecoveryError(recoveryPath);
|
|
}
|
|
return; // Another process is clearing it
|
|
}
|
|
try {
|
|
const current = await LockFile.readLock(this.filePath);
|
|
if (current && LockFile.isSameLock(current, stale)) {
|
|
await fs.unlink(this.filePath);
|
|
}
|
|
} finally {
|
|
await fs.unlink(recoveryPath).catch(() => {});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Attach process handlers, called when transitioning to "acquired"
|
|
*/
|
|
private attachExitHandlers(): void {
|
|
assert(this.state === "acquiring");
|
|
if (this.handleSignals) {
|
|
process.on("SIGINT", this.handleExit);
|
|
process.on("SIGTERM", this.handleExit);
|
|
process.on("SIGHUP", this.handleExit);
|
|
process.on("uncaughtException", this.handleExit);
|
|
}
|
|
// eslint-disable-next-line node/no-sync
|
|
process.on("exit", this.handleExitSync);
|
|
}
|
|
|
|
/**
|
|
* Detach process handlers, called when transitioning to "idle"
|
|
*/
|
|
private detachExitHandlers(): void {
|
|
assert(this.state === "releasing");
|
|
process.off("SIGINT", this.handleExit);
|
|
process.off("SIGTERM", this.handleExit);
|
|
process.off("SIGHUP", this.handleExit);
|
|
process.off("uncaughtException", this.handleExit);
|
|
// eslint-disable-next-line node/no-sync
|
|
process.off("exit", this.handleExitSync);
|
|
}
|
|
|
|
// Event handler for async signals / exceptions
|
|
private handleExit = async (): Promise<void> => {
|
|
await this.release();
|
|
};
|
|
|
|
// Event handler for synchronous process exit, readLock can't be used as it is async
|
|
private handleExitSync = (): void => {
|
|
try {
|
|
// eslint-disable-next-line node/no-sync
|
|
if (readFileSync(this.filePath, "utf8") === LockFile.content) {
|
|
// eslint-disable-next-line node/no-sync
|
|
unlinkSync(this.filePath);
|
|
}
|
|
} catch (err) {
|
|
// Ignored: file may have already been removed by another process
|
|
}
|
|
};
|
|
}
|