Roo-Code/src/utils/safeWriteJson.ts
2026-04-23 11:56:15 +00:00

303 lines
11 KiB
TypeScript

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<void>}
*/
async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJsonOptions): Promise<void> {
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<void>
*/
async function _streamDataToFile(targetPath: string, data: any, prettyPrint = false): Promise<void> {
// 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<void>((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> - Number of stale locks removed
*/
async function cleanupStaleLocks(
directoryPath: string,
options: { recursive?: boolean; staleDurationMs?: number } = {},
): Promise<number> {
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 }