simple-task-master
Version:
A simple command-line task management tool
371 lines • 14.9 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.LockManager = void 0;
const fs = __importStar(require("fs/promises"));
const path = __importStar(require("path"));
const fs_1 = require("fs");
class LockManager {
lockPath;
LOCK_TIMEOUT_MS = 30000; // 30 seconds default
LOCK_CHECK_INTERVAL_MS = 100; // Retry interval
MAX_LOCK_RETRIES = 100; // 10 seconds total wait
// Global registry of all LockManager instances to clean up on exit
static instances = new Set();
static globalCleanupHandlersSetup = false;
static cleanupListeners = null;
constructor(projectRoot) {
this.lockPath = path.join(projectRoot, '.simple-task-master', 'lock');
// Register this instance for cleanup
LockManager.instances.add(this);
// Set up global cleanup handlers once
if (!LockManager.globalCleanupHandlersSetup) {
this.setupGlobalCleanupHandlers();
LockManager.globalCleanupHandlersSetup = true;
}
}
/**
* Acquire a lock for exclusive operations.
* Uses atomic file creation with O_EXCL flag.
* Retries with 100ms intervals up to 50 times (5 seconds total).
* Automatically cleans up stale locks.
*/
async acquire() {
const lockData = {
pid: process.pid,
command: process.argv.join(' '),
timestamp: Date.now()
};
let retries = 0;
while (retries < this.MAX_LOCK_RETRIES) {
try {
// Check for stale locks before attempting to create
if (await this.exists()) {
const existingLock = await this.read();
const age = Date.now() - existingLock.timestamp;
if (age > this.LOCK_TIMEOUT_MS) {
console.warn(`Removing stale lock (age: ${age}ms, pid: ${existingLock.pid})`);
await this.forceRelease();
}
else if (!this.isProcessAlive(existingLock.pid)) {
// Process is dead, but lock is recent - give it a moment to clean up
if (age > 500) {
console.warn(`Removing stale lock from dead process (age: ${age}ms, pid: ${existingLock.pid})`);
await this.forceRelease();
}
else {
// Lock is fresh but process is dead - wait a bit for cleanup
retries++;
if (retries >= this.MAX_LOCK_RETRIES) {
throw new Error(`Failed to acquire lock after ${this.MAX_LOCK_RETRIES} retries (${this.MAX_LOCK_RETRIES * this.LOCK_CHECK_INTERVAL_MS}ms). ` +
'Another process is holding the lock.');
}
await this.sleep(this.LOCK_CHECK_INTERVAL_MS);
continue;
}
}
else {
// Lock is still valid, wait and retry
retries++;
if (retries >= this.MAX_LOCK_RETRIES) {
throw new Error(`Failed to acquire lock after ${this.MAX_LOCK_RETRIES} retries (${this.MAX_LOCK_RETRIES * this.LOCK_CHECK_INTERVAL_MS}ms). ` +
'Another process is holding the lock.');
}
await this.sleep(this.LOCK_CHECK_INTERVAL_MS);
continue;
}
}
// Attempt to create lock file atomically with O_EXCL flag
const lockContent = JSON.stringify(lockData, null, 2);
const fileHandle = await fs.open(this.lockPath, fs_1.constants.O_CREAT | fs_1.constants.O_EXCL | fs_1.constants.O_WRONLY);
await fileHandle.write(lockContent);
await fileHandle.close();
// Lock acquired successfully
return;
}
catch (error) {
const nodeError = error;
if (nodeError.code === 'EEXIST') {
// Lock file exists, retry after checking if it's stale
retries++;
if (retries >= this.MAX_LOCK_RETRIES) {
const error = new Error(`Failed to acquire lock after ${this.MAX_LOCK_RETRIES} retries (${this.MAX_LOCK_RETRIES * this.LOCK_CHECK_INTERVAL_MS}ms). ` +
'Another process is holding the lock.');
throw error;
}
await this.sleep(this.LOCK_CHECK_INTERVAL_MS);
}
else if (nodeError.code === 'ENOENT') {
// Directory doesn't exist, create it and retry
await fs.mkdir(path.dirname(this.lockPath), { recursive: true });
// Don't increment retries for directory creation
}
else {
// Other errors should be thrown
throw error;
}
}
}
throw new Error(`Failed to acquire lock after ${this.MAX_LOCK_RETRIES} retries (${this.MAX_LOCK_RETRIES * this.LOCK_CHECK_INTERVAL_MS}ms). ` +
'Another process is holding the lock.');
}
/**
* Release the lock by removing the lock file.
* Only releases if the current process owns the lock.
*/
async release() {
try {
const lockData = await this.read();
// Only release if we own the lock
if (lockData.pid === process.pid) {
await fs.unlink(this.lockPath);
}
}
catch (error) {
const nodeError = error;
if (nodeError.code !== 'ENOENT') {
throw error;
}
// Lock file doesn't exist, nothing to release
}
}
/**
* Check if a lock file exists
*/
async exists() {
try {
await fs.access(this.lockPath, fs_1.constants.F_OK);
return true;
}
catch {
return false;
}
}
/**
* Read and parse the lock file
*/
async read() {
try {
const content = await fs.readFile(this.lockPath, 'utf8');
const parsed = JSON.parse(content);
// Validate required fields and provide defaults
const lockFile = {
pid: parsed.pid || 0,
command: parsed.command || 'unknown',
timestamp: parsed.timestamp || 0
};
// If any critical field is missing or invalid, treat as stale
if (!lockFile.pid || !lockFile.timestamp) {
return {
pid: 99999, // Non-existent PID
command: 'stale-corrupted',
timestamp: 0 // Very old timestamp
};
}
return lockFile;
}
catch {
// If we can't parse the file, treat it as stale
return {
pid: 99999, // Non-existent PID
command: 'stale-corrupted',
timestamp: 0 // Very old timestamp
};
}
}
/**
* Force release a lock without checking ownership
*/
async forceRelease() {
try {
await fs.unlink(this.lockPath);
}
catch (error) {
const nodeError = error;
if (nodeError.code !== 'ENOENT') {
throw error;
}
}
}
/**
* Check if a process with the given PID is still alive
*/
isProcessAlive(pid) {
try {
// Sending signal 0 checks if process exists without actually sending a signal
process.kill(pid, 0);
return true;
}
catch (error) {
const nodeError = error;
// ESRCH means "No such process"
if (nodeError.code === 'ESRCH') {
return false;
}
// EPERM means "Operation not permitted" - process exists but we can't signal it
if (nodeError.code === 'EPERM') {
return true;
}
// Other errors indicate the process might exist
return true;
}
}
/**
* Sleep for the specified number of milliseconds
*/
async sleep(ms) {
return new Promise((resolve) => {
const timeoutId = global.setTimeout(resolve, ms);
// Node.js will keep the event loop alive until the timeout fires
if (timeoutId && typeof timeoutId === 'object' && 'unref' in timeoutId) {
// Don't prevent process exit if this is the only thing keeping it alive
timeoutId.unref();
}
});
}
/**
* Setup global cleanup handlers to release all locks on process exit
*/
setupGlobalCleanupHandlers() {
const cleanup = async () => {
// Clean up all registered lock managers
const cleanupPromises = Array.from(LockManager.instances).map(async (instance) => {
try {
await instance.release();
}
catch {
// Ignore errors during cleanup
}
});
await Promise.all(cleanupPromises);
};
// Check if we're in a test environment
const isTestEnvironment = process.env.NODE_ENV === 'test' ||
process.env.VITEST === 'true' ||
process.env.JEST_WORKER_ID !== undefined ||
process.argv.some((arg) => arg.includes('vitest') || arg.includes('jest'));
// Create listener functions to store references for cleanup
const exitListener = () => {
// Synchronous cleanup for exit event
try {
// Use sync operations for exit
for (const instance of LockManager.instances) {
try {
const lockData = require('fs').readFileSync(instance.lockPath, 'utf8');
const parsed = JSON.parse(lockData);
if (parsed.pid === process.pid) {
require('fs').unlinkSync(instance.lockPath);
}
}
catch {
// Ignore errors during exit cleanup
}
}
}
catch {
// Ignore errors during exit cleanup
}
};
const sigintListener = async () => {
await cleanup();
if (!isTestEnvironment) {
process.exit(0);
}
};
const sigtermListener = async () => {
await cleanup();
if (!isTestEnvironment) {
process.exit(0);
}
};
const uncaughtExceptionListener = async (error) => {
console.error('Uncaught exception:', error);
await cleanup();
if (!isTestEnvironment) {
process.exit(1);
}
};
const unhandledRejectionListener = async (reason) => {
console.error('Unhandled rejection:', reason);
await cleanup();
// Don't exit the process in test environments - let test frameworks handle the error
if (!isTestEnvironment) {
process.exit(1);
}
};
// Store references for cleanup
LockManager.cleanupListeners = {
exit: exitListener,
sigint: sigintListener,
sigterm: sigtermListener,
uncaughtException: uncaughtExceptionListener,
unhandledRejection: unhandledRejectionListener
};
// Handle various exit scenarios
process.once('exit', exitListener);
process.once('SIGINT', sigintListener);
process.once('SIGTERM', sigtermListener);
process.once('uncaughtException', uncaughtExceptionListener);
process.once('unhandledRejection', unhandledRejectionListener);
}
/**
* Remove this instance from cleanup registry (for testing)
*/
dispose() {
LockManager.instances.delete(this);
}
/**
* Clean up all global event listeners (for testing)
*/
static disposeGlobalListeners() {
if (LockManager.cleanupListeners) {
try {
process.removeListener('exit', LockManager.cleanupListeners.exit);
process.removeListener('SIGINT', LockManager.cleanupListeners.sigint);
process.removeListener('SIGTERM', LockManager.cleanupListeners.sigterm);
process.removeListener('uncaughtException', LockManager.cleanupListeners.uncaughtException);
process.removeListener('unhandledRejection', LockManager.cleanupListeners.unhandledRejection);
}
catch {
// Ignore errors during cleanup
}
LockManager.cleanupListeners = null;
}
LockManager.globalCleanupHandlersSetup = false;
LockManager.instances.clear();
}
}
exports.LockManager = LockManager;
//# sourceMappingURL=lock-manager.js.map