mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-17 23:51:08 +00:00
303 lines
11 KiB
TypeScript
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 }
|