/** * Port Lock Manager * Provides atomic port allocation using file-based locks to prevent race conditions * during concurrent container deployments */ const fs = require('fs'); const path = require('path'); const lockfile = require('proper-lockfile'); const platformPaths = require('../../platform-paths'); const { log } = require('../utils/logging'); const LOCK_DIR = process.env.PORT_LOCK_DIR || path.join(platformPaths.dataDir, '.port-locks'); const LOCK_TIMEOUT = 120000; // 2 minutes const LOCK_STALE_THRESHOLD = 120000; // 2 minutes const LOCK_RETRY_OPTIONS = { retries: { retries: 10, minTimeout: 100, maxTimeout: 1000, randomize: true }, stale: LOCK_STALE_THRESHOLD, realpath: false }; class PortLockManager { constructor() { this.activeLocks = new Map(); // Map of lockId -> { ports: [], release: fn } this.ensureLockDirectory(); } /** * Ensure lock directory exists */ ensureLockDirectory() { if (!fs.existsSync(LOCK_DIR)) { fs.mkdirSync(LOCK_DIR, { recursive: true }); log.info('portlock', 'Created lock directory', { dir: LOCK_DIR }); } } /** * Get lock file path for a port */ getLockFilePath(port) { return path.join(LOCK_DIR, `port-${port}.lock`); } /** * Acquire locks for multiple ports atomically * Ports are sorted to prevent deadlocks * @param {string[]} ports - Array of port numbers as strings * @returns {Promise} Lock ID for releasing locks later */ async acquirePorts(ports) { if (!Array.isArray(ports) || ports.length === 0) { throw new Error('Ports must be a non-empty array'); } const lockId = `lock-${Date.now()}-${Math.random().toString(36).substring(7)}`; const sortedPorts = [...new Set(ports)].sort((a, b) => parseInt(a) - parseInt(b)); const acquiredLocks = []; const releaseFunctions = []; try { log.info('portlock', 'Acquiring locks', { ports: sortedPorts }); // Acquire locks in sorted order to prevent deadlocks for (const port of sortedPorts) { const lockFilePath = this.getLockFilePath(port); // Create lock file if it doesn't exist if (!fs.existsSync(lockFilePath)) { fs.writeFileSync(lockFilePath, JSON.stringify({ created: new Date().toISOString(), port })); } // Acquire lock with retry const release = await lockfile.lock(lockFilePath, LOCK_RETRY_OPTIONS); acquiredLocks.push(port); releaseFunctions.push(release); log.info('portlock', 'Locked port', { port }); } // Store lock information this.activeLocks.set(lockId, { ports: sortedPorts, releases: releaseFunctions, timestamp: Date.now() }); log.info('portlock', 'Acquired all locks', { lockId }); return lockId; } catch (error) { // Release any locks we managed to acquire log.error('portlock', error, { operation: 'acquire', lockId }); for (const release of releaseFunctions) { try { await release(); } catch (releaseError) { log.error('portlock', releaseError, { operation: 'releaseCleanup', lockId }); } } throw new Error(`Failed to acquire port locks: ${error.message}`); } } /** * Release locks for a lock ID * @param {string} lockId - Lock ID returned from acquirePorts */ async releasePorts(lockId) { const lockInfo = this.activeLocks.get(lockId); if (!lockInfo) { log.warn('portlock', 'Lock ID not found', { lockId }); return; } log.info('portlock', 'Releasing locks', { lockId, ports: lockInfo.ports }); const errors = []; for (const release of lockInfo.releases) { try { await release(); } catch (error) { errors.push(error.message); log.error('portlock', error, { operation: 'release', lockId }); } } this.activeLocks.delete(lockId); if (errors.length > 0) { log.warn('portlock', 'Released locks with errors', { lockId, errorCount: errors.length }); } else { log.info('portlock', 'Released all locks', { lockId }); } } /** * Clean up stale lock files * Removes locks older than LOCK_STALE_THRESHOLD */ async cleanupStaleLocks() { log.info('portlock', 'Cleaning up stale locks'); this.ensureLockDirectory(); let cleaned = 0; let errors = 0; try { const files = fs.readdirSync(LOCK_DIR); for (const file of files) { if (!file.endsWith('.lock')) continue; const lockFilePath = path.join(LOCK_DIR, file); try { // Check if lock is stale using proper-lockfile's built-in check const isLocked = await lockfile.check(lockFilePath, { realpath: false, stale: LOCK_STALE_THRESHOLD }); if (!isLocked) { // Lock is stale or not locked, safe to remove fs.unlinkSync(lockFilePath); cleaned++; log.info('portlock', 'Removed stale lock', { file }); } } catch (error) { // File might not exist or might have been removed by another process if (error.code !== 'ENOENT') { errors++; log.warn('portlock', 'Error checking lock', { file, error: error.message }); } } } log.info('portlock', 'Cleanup complete', { cleaned, errors }); } catch (error) { log.error('portlock', error, { operation: 'cleanup' }); } } /** * Get current lock status */ getStatus() { const activeLocks = Array.from(this.activeLocks.entries()).map(([lockId, info]) => ({ lockId, ports: info.ports, age: Date.now() - info.timestamp, timestamp: new Date(info.timestamp).toISOString() })); return { activeLocks: activeLocks.length, locks: activeLocks, lockDirectory: LOCK_DIR }; } /** * Check if a port is currently locked * @param {string} port - Port number as string * @returns {Promise} */ async isPortLocked(port) { const lockFilePath = this.getLockFilePath(port); if (!fs.existsSync(lockFilePath)) { return false; } try { return await lockfile.check(lockFilePath, { realpath: false, stale: LOCK_STALE_THRESHOLD }); } catch (error) { // If we can't check, assume it's not locked return false; } } } // Singleton instance const portLockManager = new PortLockManager(); module.exports = portLockManager;