@socketsecurity/lib
Version:
Core utilities and infrastructure for Socket.dev security tools
294 lines (293 loc) • 9.56 kB
JavaScript
;
/* Socket Lib - Built with esbuild */
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var process_lock_exports = {};
__export(process_lock_exports, {
processLock: () => processLock
});
module.exports = __toCommonJS(process_lock_exports);
var import_fs = require("fs");
var import_fs2 = require("./fs");
var import_logger = require("./logger");
var import_promises = require("./promises");
var import_signal_exit = require("./signal-exit");
const logger = (0, import_logger.getDefaultLogger)();
class ProcessLockManager {
activeLocks = /* @__PURE__ */ new Set();
touchTimers = /* @__PURE__ */ new Map();
exitHandlerRegistered = false;
/**
* Ensure process exit handler is registered for cleanup.
* Registers a handler that cleans up all active locks when the process exits.
*/
ensureExitHandler() {
if (this.exitHandlerRegistered) {
return;
}
(0, import_signal_exit.onExit)(() => {
for (const timer of this.touchTimers.values()) {
clearInterval(timer);
}
this.touchTimers.clear();
for (const lockPath of this.activeLocks) {
try {
if ((0, import_fs.existsSync)(lockPath)) {
(0, import_fs2.safeDeleteSync)(lockPath, { recursive: true });
}
} catch {
}
}
});
this.exitHandlerRegistered = true;
}
/**
* Touch a lock file to update its mtime.
* This prevents the lock from being detected as stale during long operations.
*
* @param lockPath - Path to the lock directory
*/
touchLock(lockPath) {
try {
if ((0, import_fs.existsSync)(lockPath)) {
const now = /* @__PURE__ */ new Date();
(0, import_fs.utimesSync)(lockPath, now, now);
}
} catch (error) {
logger.warn(
`Failed to touch lock ${lockPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Start periodic touching of a lock file.
* Aligned with npm npx strategy to prevent false stale detection.
*
* @param lockPath - Path to the lock directory
* @param intervalMs - Touch interval in milliseconds
*/
startTouchTimer(lockPath, intervalMs) {
if (intervalMs <= 0 || this.touchTimers.has(lockPath)) {
return;
}
const timer = setInterval(() => {
this.touchLock(lockPath);
}, intervalMs);
timer.unref();
this.touchTimers.set(lockPath, timer);
}
/**
* Stop periodic touching of a lock file.
*
* @param lockPath - Path to the lock directory
*/
stopTouchTimer(lockPath) {
const timer = this.touchTimers.get(lockPath);
if (timer) {
clearInterval(timer);
this.touchTimers.delete(lockPath);
}
}
/**
* Check if a lock is stale based on mtime.
* Uses second-level granularity to avoid APFS floating-point precision issues.
* Aligned with npm's npx locking strategy.
*
* @param lockPath - Path to the lock directory
* @param staleMs - Stale timeout in milliseconds
* @returns True if lock exists and is stale
*/
isStale(lockPath, staleMs) {
try {
if (!(0, import_fs.existsSync)(lockPath)) {
return false;
}
const stats = (0, import_fs.statSync)(lockPath);
const ageSeconds = Math.floor((Date.now() - stats.mtime.getTime()) / 1e3);
const staleSeconds = Math.floor(staleMs / 1e3);
return ageSeconds > staleSeconds;
} catch {
return false;
}
}
/**
* Acquire a lock using mkdir for atomic operation.
* Handles stale locks and includes exit cleanup.
*
* This method attempts to create a lock directory atomically. If the lock
* already exists, it checks if it's stale and removes it before retrying.
* Uses exponential backoff with jitter for retry attempts.
*
* @param lockPath - Path to the lock directory
* @param options - Lock acquisition options
* @returns Release function to unlock
* @throws Error if lock cannot be acquired after all retries
*
* @example
* ```typescript
* const release = await processLock.acquire('/tmp/my-lock')
* try {
* // Critical section
* } finally {
* release()
* }
* ```
*/
async acquire(lockPath, options = {}) {
const {
baseDelayMs = 100,
maxDelayMs = 1e3,
retries = 3,
staleMs = 5e3,
touchIntervalMs = 2e3
} = options;
this.ensureExitHandler();
return await (0, import_promises.pRetry)(
async () => {
try {
if ((0, import_fs.existsSync)(lockPath) && this.isStale(lockPath, staleMs)) {
logger.log(`Removing stale lock: ${lockPath}`);
try {
(0, import_fs2.safeDeleteSync)(lockPath, { recursive: true });
} catch {
}
}
if ((0, import_fs.existsSync)(lockPath)) {
throw new Error(`Lock already exists: ${lockPath}`);
}
(0, import_fs.mkdirSync)(lockPath, { recursive: true });
this.activeLocks.add(lockPath);
this.startTouchTimer(lockPath, touchIntervalMs);
return () => this.release(lockPath);
} catch (error) {
const code = error.code;
if (code === "EEXIST") {
if (this.isStale(lockPath, staleMs)) {
throw new Error(`Stale lock detected: ${lockPath}`);
}
throw new Error(`Lock already exists: ${lockPath}`);
}
if (code === "EACCES" || code === "EPERM") {
throw new Error(
`Permission denied creating lock: ${lockPath}. Check directory permissions or run with appropriate access.`,
{ cause: error }
);
}
if (code === "EROFS") {
throw new Error(
`Cannot create lock on read-only filesystem: ${lockPath}`,
{ cause: error }
);
}
if (code === "ENOTDIR") {
const parentDir = lockPath.slice(0, lockPath.lastIndexOf("/"));
throw new Error(
`Cannot create lock directory: ${lockPath}
A path component is a file when it should be a directory.
Parent path: ${parentDir}
To resolve:
1. Check if "${parentDir}" contains a file instead of a directory
2. Remove any conflicting files in the path
3. Ensure the full parent directory structure exists`,
{ cause: error }
);
}
if (code === "ENOENT") {
const parentDir = lockPath.slice(0, lockPath.lastIndexOf("/"));
throw new Error(
`Cannot create lock directory: ${lockPath}
Parent directory does not exist: ${parentDir}
To resolve:
1. Ensure the parent directory "${parentDir}" exists
2. Create the directory structure: mkdir -p "${parentDir}"
3. Check filesystem permissions allow directory creation`,
{ cause: error }
);
}
throw new Error(`Failed to acquire lock: ${lockPath}`, {
cause: error
});
}
},
{
retries,
baseDelayMs,
maxDelayMs,
jitter: true
}
);
}
/**
* Release a lock and remove from tracking.
* Stops periodic touching and removes the lock directory.
*
* @param lockPath - Path to the lock directory
*
* @example
* ```typescript
* processLock.release('/tmp/my-lock')
* ```
*/
release(lockPath) {
this.stopTouchTimer(lockPath);
try {
if ((0, import_fs.existsSync)(lockPath)) {
(0, import_fs2.safeDeleteSync)(lockPath, { recursive: true });
}
this.activeLocks.delete(lockPath);
} catch (error) {
logger.warn(
`Failed to release lock ${lockPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Execute a function with exclusive lock protection.
* Automatically handles lock acquisition, execution, and cleanup.
*
* This is the recommended way to use process locks, as it guarantees
* cleanup even if the callback throws an error.
*
* @param lockPath - Path to the lock directory
* @param fn - Function to execute while holding the lock
* @param options - Lock acquisition options
* @returns Result of the callback function
* @throws Error from callback or lock acquisition failure
*
* @example
* ```typescript
* const result = await processLock.withLock('/tmp/my-lock', async () => {
* // Critical section
* return someValue
* })
* ```
*/
async withLock(lockPath, fn, options) {
const release = await this.acquire(lockPath, options);
try {
return await fn();
} finally {
release();
}
}
}
const processLock = new ProcessLockManager();
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
processLock
});