import * as fs from "fs/promises" import * as fsSync from "fs" import * as path from "path" import * as lockfile from "proper-lockfile" import { JsonStreamStringify } from "json-stream-stringify" /** * Options for safeWriteJson function */ export interface SafeWriteJsonOptions { /** * Whether to pretty-print the JSON output with indentation. * When true, uses tab characters for indentation. * When false or undefined, outputs compact JSON. * @default false */ prettyPrint?: boolean } /** * The stale timeout for locks in milliseconds (must match the value in safeWriteJson). * Locks older than this are considered abandoned and can be safely removed. */ const LOCK_STALE_MS = 31000 /** * Safely writes JSON data to a file. * - Creates parent directories if they don't exist * - Uses 'proper-lockfile' for inter-process advisory locking to prevent concurrent writes to the same path. * - Writes to a temporary file first. * - If the target file exists, it's backed up before being replaced. * - Attempts to roll back and clean up in case of errors. * - Supports pretty-printing with indentation while maintaining streaming efficiency. * * @param {string} filePath - The absolute path to the target file. * @param {any} data - The data to serialize to JSON and write. * @param {SafeWriteJsonOptions} options - Optional configuration for JSON formatting. * @returns {Promise} */ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJsonOptions): Promise { const absoluteFilePath = path.resolve(filePath) let releaseLock = async () => {} // Initialized to a no-op // For directory creation const dirPath = path.dirname(absoluteFilePath) // Ensure directory structure exists with improved reliability try { // Create directory with recursive option await fs.mkdir(dirPath, { recursive: true }) // Verify directory exists after creation attempt await fs.access(dirPath) } catch (dirError: any) { console.error(`Failed to create or access directory for ${absoluteFilePath}:`, dirError) throw dirError } // Acquire the lock before any file operations try { releaseLock = await lockfile.lock(absoluteFilePath, { stale: 31000, // Stale after 31 seconds update: 10000, // Update mtime every 10 seconds to prevent staleness if operation is long realpath: false, // the file may not exist yet, which is acceptable retries: { // Configuration for retrying lock acquisition retries: 5, // Number of retries after the initial attempt factor: 2, // Exponential backoff factor (e.g., 100ms, 200ms, 400ms, ...) minTimeout: 100, // Minimum time to wait before the first retry (in ms) maxTimeout: 1000, // Maximum time to wait for any single retry (in ms) }, onCompromised: (err) => { console.error(`Lock at ${absoluteFilePath} was compromised:`, err) throw err }, }) } catch (lockError) { // If lock acquisition fails, we throw immediately. // The releaseLock remains a no-op, so the finally block in the main file operations // try-catch-finally won't try to release an unacquired lock if this path is taken. console.error(`Failed to acquire lock for ${absoluteFilePath}:`, lockError) // Propagate the lock acquisition error throw lockError } // Variables to hold the actual paths of temp files if they are created. let actualTempNewFilePath: string | null = null let actualTempBackupFilePath: string | null = null try { // Step 1: Write data to a new temporary file. actualTempNewFilePath = path.join( path.dirname(absoluteFilePath), `.${path.basename(absoluteFilePath)}.new_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`, ) await _streamDataToFile(actualTempNewFilePath, data, options?.prettyPrint) // Step 2: Check if the target file exists. If so, rename it to a backup path. try { // Check for target file existence await fs.access(absoluteFilePath) // Target exists, create a backup path and rename. actualTempBackupFilePath = path.join( path.dirname(absoluteFilePath), `.${path.basename(absoluteFilePath)}.bak_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`, ) await fs.rename(absoluteFilePath, actualTempBackupFilePath) } catch (accessError: any) { // Explicitly type accessError if (accessError.code !== "ENOENT") { // An error other than "file not found" occurred during access check. throw accessError } // Target file does not exist, so no backup is made. actualTempBackupFilePath remains null. } // Step 3: Rename the new temporary file to the target file path. // This is the main "commit" step. await fs.rename(actualTempNewFilePath, absoluteFilePath) // If we reach here, the new file is successfully in place. // The original actualTempNewFilePath is now the main file, so we shouldn't try to clean it up as "temp". // Mark as "used" or "committed" actualTempNewFilePath = null // Step 4: If a backup was created, attempt to delete it. if (actualTempBackupFilePath) { try { await fs.unlink(actualTempBackupFilePath) // Mark backup as handled actualTempBackupFilePath = null } catch (unlinkBackupError) { // Log this error, but do not re-throw. The main operation was successful. // actualTempBackupFilePath remains set, indicating an orphaned backup. console.error( `Successfully wrote ${absoluteFilePath}, but failed to clean up backup ${actualTempBackupFilePath}:`, unlinkBackupError, ) } } } catch (originalError) { console.error(`Operation failed for ${absoluteFilePath}: [Original Error Caught]`, originalError) const newFileToCleanupWithinCatch = actualTempNewFilePath const backupFileToRollbackOrCleanupWithinCatch = actualTempBackupFilePath // Attempt rollback if a backup was made if (backupFileToRollbackOrCleanupWithinCatch) { try { await fs.rename(backupFileToRollbackOrCleanupWithinCatch, absoluteFilePath) // Mark as handled, prevent later unlink of this path actualTempBackupFilePath = null } catch (rollbackError) { // actualTempBackupFilePath (outer scope) remains pointing to backupFileToRollbackOrCleanupWithinCatch console.error( `[Catch] Failed to restore backup ${backupFileToRollbackOrCleanupWithinCatch} to ${absoluteFilePath}:`, rollbackError, ) } } // Cleanup the .new file if it exists if (newFileToCleanupWithinCatch) { try { await fs.unlink(newFileToCleanupWithinCatch) } catch (cleanupError) { console.error( `[Catch] Failed to clean up temporary new file ${newFileToCleanupWithinCatch}:`, cleanupError, ) } } // Cleanup the .bak file if it still needs to be (i.e., wasn't successfully restored) if (actualTempBackupFilePath) { try { await fs.unlink(actualTempBackupFilePath) } catch (cleanupError) { console.error( `[Catch] Failed to clean up temporary backup file ${actualTempBackupFilePath}:`, cleanupError, ) } } throw originalError // This MUST be the error that rejects the promise. } finally { // Release the lock in the main finally block. try { // releaseLock will be the actual unlock function if lock was acquired, // or the initial no-op if acquisition failed. await releaseLock() } catch (unlockError) { // Do not re-throw here, as the originalError from the try/catch (if any) is more important. console.error(`Failed to release lock for ${absoluteFilePath}:`, unlockError) } } } /** * Helper function to stream JSON data to a file. * @param targetPath The path to write the stream to. * @param data The data to stream. * @param prettyPrint Whether to format the JSON with indentation. * @returns Promise */ async function _streamDataToFile(targetPath: string, data: any, prettyPrint = false): Promise { // Stream data to avoid high memory usage for large JSON objects. const fileWriteStream = fsSync.createWriteStream(targetPath, { encoding: "utf8" }) // JsonStreamStringify traverses the object and streams tokens directly // The 'spaces' parameter adds indentation during streaming, not via a separate pass // Convert undefined to null for valid JSON serialization (undefined is not valid JSON) const stringifyStream = new JsonStreamStringify( data === undefined ? null : data, undefined, // replacer prettyPrint ? "\t" : undefined, // spaces for indentation ) return new Promise((resolve, reject) => { stringifyStream.on("error", reject) fileWriteStream.on("error", reject) fileWriteStream.on("finish", resolve) stringifyStream.pipe(fileWriteStream) }) } /** * Cleans up stale lock files/directories from a directory. * * The `proper-lockfile` library creates `.lock` directories for advisory locking. * On some filesystems (particularly btrfs with Copy-on-Write), these locks may not * be properly detected as stale, causing hangs during extension activation after * updates or crashes. * * This function scans a directory for `.lock` entries and removes any that are * older than the stale timeout, ensuring clean startup. * * @param directoryPath - The directory to scan for stale locks * @param options - Optional configuration * @param options.recursive - If true, scan subdirectories as well (default: true) * @param options.staleDurationMs - Custom stale duration in ms (default: LOCK_STALE_MS) * @returns Promise - Number of stale locks removed */ async function cleanupStaleLocks( directoryPath: string, options: { recursive?: boolean; staleDurationMs?: number } = {}, ): Promise { const { recursive = true, staleDurationMs = LOCK_STALE_MS } = options let removedCount = 0 try { // Check if directory exists const dirStat = await fs.stat(directoryPath).catch(() => null) if (!dirStat || !dirStat.isDirectory()) { return 0 } const entries = await fs.readdir(directoryPath, { withFileTypes: true }) const now = Date.now() for (const entry of entries) { const entryPath = path.join(directoryPath, entry.name) // Check for .lock files/directories created by proper-lockfile if (entry.name.endsWith(".lock")) { try { const stat = await fs.stat(entryPath) const age = now - stat.mtimeMs // Remove if older than stale duration // Use a slightly larger threshold to account for filesystem timing quirks if (age > staleDurationMs) { if (stat.isDirectory()) { await fs.rm(entryPath, { recursive: true, force: true }) } else { await fs.unlink(entryPath) } removedCount++ console.log( `[cleanupStaleLocks] Removed stale lock: ${entryPath} (age: ${Math.round(age / 1000)}s)`, ) } } catch (err) { // Ignore errors for individual lock files - they may have been // cleaned up by another process or the original operation completed console.warn(`[cleanupStaleLocks] Could not process ${entryPath}:`, err) } } else if (recursive && entry.isDirectory()) { // Recursively clean subdirectories (but not .lock directories themselves) removedCount += await cleanupStaleLocks(entryPath, options) } } } catch (err) { // Log but don't throw - cleanup failures shouldn't block extension activation console.error(`[cleanupStaleLocks] Error scanning ${directoryPath}:`, err) } return removedCount } export { safeWriteJson, cleanupStaleLocks, LOCK_STALE_MS }