b7fea83ed6
Deliver the initial local bridge, Noctalia v4/v5 adapters, desktop client, service unit, tests, and implementation documentation for persistent Pi status and control.
127 lines
3.1 KiB
JavaScript
127 lines
3.1 KiB
JavaScript
import { randomUUID } from "node:crypto";
|
|
import { chmod, open, readFile, unlink } from "node:fs/promises";
|
|
|
|
const LOCK_MODE = 0o600;
|
|
|
|
export class BridgeAlreadyRunningError extends Error {
|
|
constructor(metadata) {
|
|
super(`bridge instance is already running with pid ${metadata.pid}`);
|
|
this.name = "BridgeAlreadyRunningError";
|
|
this.metadata = metadata;
|
|
}
|
|
}
|
|
|
|
export class BridgeLockError extends Error {
|
|
constructor(message) {
|
|
super(message);
|
|
this.name = "BridgeLockError";
|
|
}
|
|
}
|
|
|
|
function parseLockMetadata(text, lockPath) {
|
|
let metadata;
|
|
try {
|
|
metadata = JSON.parse(text);
|
|
} catch {
|
|
throw new BridgeLockError(`lock file is unreadable: ${lockPath}`);
|
|
}
|
|
if (
|
|
metadata === null ||
|
|
typeof metadata !== "object" ||
|
|
!Number.isSafeInteger(metadata.pid) ||
|
|
metadata.pid < 1 ||
|
|
typeof metadata.token !== "string" ||
|
|
metadata.token.length < 1 ||
|
|
typeof metadata.socketPath !== "string" ||
|
|
metadata.socketPath.length < 1 ||
|
|
typeof metadata.startedAt !== "string" ||
|
|
Number.isNaN(Date.parse(metadata.startedAt))
|
|
) {
|
|
throw new BridgeLockError(`lock file has an invalid format: ${lockPath}`);
|
|
}
|
|
return metadata;
|
|
}
|
|
|
|
export function isProcessAlive(pid) {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
if (error?.code === "ESRCH") return false;
|
|
if (error?.code === "EPERM") return true;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function readLockMetadata(lockPath) {
|
|
try {
|
|
return parseLockMetadata(await readFile(lockPath, "utf8"), lockPath);
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return undefined;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function acquireBridgeLock({
|
|
lockPath,
|
|
socketPath,
|
|
pid = process.pid,
|
|
now = () => new Date(),
|
|
processAlive = isProcessAlive,
|
|
}) {
|
|
if (!lockPath || !socketPath)
|
|
throw new TypeError("lockPath and socketPath are required");
|
|
|
|
const metadata = {
|
|
pid,
|
|
socketPath,
|
|
startedAt: now().toISOString(),
|
|
token: randomUUID(),
|
|
};
|
|
|
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
let handle;
|
|
let createdLock = false;
|
|
try {
|
|
handle = await open(lockPath, "wx", LOCK_MODE);
|
|
createdLock = true;
|
|
await handle.writeFile(`${JSON.stringify(metadata)}\n`, "utf8");
|
|
await handle.sync();
|
|
await chmod(lockPath, LOCK_MODE);
|
|
await handle.close();
|
|
return {
|
|
metadata,
|
|
async release() {
|
|
const current = await readLockMetadata(lockPath);
|
|
if (current?.token !== metadata.token) return false;
|
|
await unlink(lockPath);
|
|
return true;
|
|
},
|
|
};
|
|
} catch (error) {
|
|
await handle?.close().catch(() => {});
|
|
if (createdLock) {
|
|
await unlink(lockPath).catch(() => {});
|
|
throw error;
|
|
}
|
|
if (error?.code !== "EEXIST") throw error;
|
|
|
|
const existing = await readLockMetadata(lockPath);
|
|
if (!existing) continue;
|
|
if (processAlive(existing.pid))
|
|
throw new BridgeAlreadyRunningError(existing);
|
|
|
|
// Only a lock with a readable PID that has exited may be reclaimed.
|
|
await unlink(lockPath).catch((unlinkError) => {
|
|
if (unlinkError?.code !== "ENOENT") throw unlinkError;
|
|
});
|
|
}
|
|
}
|
|
|
|
throw new BridgeLockError(
|
|
`could not acquire lock after stale-owner recovery: ${lockPath}`,
|
|
);
|
|
}
|
|
|
|
export const lockMode = LOCK_MODE;
|