cangku/backend/src/services/hsdsAccountLock.js

164 lines
3.7 KiB
JavaScript

'use strict';
const crypto = require('crypto');
const fs = require('fs/promises');
const os = require('os');
const path = require('path');
const DEFAULT_LOCK_PATH = path.resolve(__dirname, '../../runtime/hsds/hsds_account.lock.json');
const DEFAULT_STALE_MS = Math.max(
Number.parseInt(process.env.HSDS_ACCOUNT_LOCK_STALE_MS || '43200000', 10) || 43200000,
60000
);
function normalizeText(value, fallback = '') {
const text = value === undefined || value === null ? '' : String(value).trim();
return text || fallback;
}
function isProcessAlive(pid) {
const numericPid = Number.parseInt(pid, 10);
if (!Number.isFinite(numericPid) || numericPid <= 0) {
return false;
}
try {
process.kill(numericPid, 0);
return true;
} catch (error) {
return error?.code === 'EPERM';
}
}
async function ensureLockDirectory(lockPath) {
await fs.mkdir(path.dirname(lockPath), { recursive: true });
}
async function readLockPayload(lockPath) {
try {
const text = await fs.readFile(lockPath, 'utf8');
return JSON.parse(text);
} catch (_error) {
return null;
}
}
function isLockPayloadStale(payload) {
if (!payload || typeof payload !== 'object') {
return true;
}
if (isProcessAlive(payload.pid)) {
return false;
}
const currentHostname = os.hostname();
const lockHostname = normalizeText(payload.hostname);
if (!lockHostname || lockHostname === currentHostname) {
return true;
}
const acquiredAt = Date.parse(payload.acquiredAt || '');
if (Number.isFinite(acquiredAt)) {
return Date.now() - acquiredAt > DEFAULT_STALE_MS;
}
return true;
}
async function releaseOwnedLock(lockPath, token) {
if (!token) {
return;
}
const payload = await readLockPayload(lockPath);
if (payload?.token !== token) {
return;
}
await fs.rm(lockPath, { force: true });
}
async function acquireHsdsAccountLock(trigger = 'manual', options = {}) {
const lockPath = path.resolve(options.lockPath || DEFAULT_LOCK_PATH);
await ensureLockDirectory(lockPath);
for (let attempt = 0; attempt < 2; attempt += 1) {
const token = crypto.randomUUID();
const payload = {
token,
pid: process.pid,
hostname: os.hostname(),
trigger: normalizeText(trigger, 'manual'),
command: process.argv.join(' ').trim(),
acquiredAt: new Date().toISOString(),
};
try {
const handle = await fs.open(lockPath, 'wx');
try {
await handle.writeFile(`${JSON.stringify(payload, null, 2)}\n`, 'utf8');
} finally {
await handle.close();
}
return {
acquired: true,
lockPath,
payload,
async release() {
await releaseOwnedLock(lockPath, token);
},
};
} catch (error) {
if (error?.code !== 'EEXIST') {
throw error;
}
const holder = await readLockPayload(lockPath);
if (isLockPayloadStale(holder)) {
await fs.rm(lockPath, { force: true });
continue;
}
return {
acquired: false,
lockPath,
holder,
};
}
}
const holder = await readLockPayload(lockPath);
return {
acquired: false,
lockPath,
holder,
};
}
async function withHsdsAccountLock(trigger, runner, options = {}) {
const lockState = await acquireHsdsAccountLock(trigger, options);
if (!lockState.acquired) {
return {
success: false,
skipped: true,
reason: 'hsds_account_busy',
trigger: normalizeText(trigger, 'manual'),
lockPath: lockState.lockPath,
lockHolder: lockState.holder || null,
};
}
try {
return await runner();
} finally {
await lockState.release();
}
}
module.exports = {
acquireHsdsAccountLock,
withHsdsAccountLock,
};