diff --git a/src/extension.ts b/src/extension.ts index bd43bcbf8a..b06267821e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -39,6 +39,7 @@ import { CodeActionProvider, } from "./activate" import { initializeI18n } from "./i18n" +import { performStartupStaleLockRecovery } from "./utils/staleLockRecovery" /** * Built using https://github.com/microsoft/vscode-webview-ui-toolkit @@ -86,6 +87,18 @@ export async function activate(context: vscode.ExtensionContext) { // Initialize i18n for internationalization support initializeI18n(context.globalState.get("language") ?? formatLanguage(vscode.env.language)) + // Perform stale lock recovery on startup + try { + await performStartupStaleLockRecovery(context.globalStorageUri.fsPath, { + maxLockAge: 10 * 60 * 1000, // 10 minutes + autoRecover: true, + }) + } catch (error) { + outputChannel.appendLine( + `[StaleLockRecovery] Error during startup recovery: ${error instanceof Error ? error.message : String(error)}`, + ) + } + // Initialize terminal shell execution handlers. TerminalRegistry.initialize() diff --git a/src/utils/__tests__/safeWriteJson.staleLock.test.ts b/src/utils/__tests__/safeWriteJson.staleLock.test.ts new file mode 100644 index 0000000000..3d71623d46 --- /dev/null +++ b/src/utils/__tests__/safeWriteJson.staleLock.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import * as fs from "fs/promises" +import * as path from "path" +import * as os from "os" +import { safeWriteJson } from "../safeWriteJson" + +describe("safeWriteJson - stale lock handling", () => { + let tempDir: string + let testFilePath: string + + beforeEach(async () => { + // Create a temporary directory for testing + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-json-stale-lock-test-")) + testFilePath = path.join(tempDir, "test.json") + }) + + afterEach(async () => { + // Clean up temporary directory + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}) + // Clean up any stray lock files + try { + await fs.unlink(`${testFilePath}.lock`) + } catch { + // Ignore if doesn't exist + } + }) + + it("should create file before acquiring lock if file doesn't exist", async () => { + // Ensure file doesn't exist + await expect(fs.access(testFilePath)).rejects.toThrow() + + // Write data + const testData = { test: "data" } + await safeWriteJson(testFilePath, testData) + + // Verify file was created with correct content + const content = await fs.readFile(testFilePath, "utf8") + expect(JSON.parse(content)).toEqual(testData) + }) + + it("should handle missing target file by creating it first", async () => { + // Ensure file doesn't exist + await expect(fs.access(testFilePath)).rejects.toThrow() + + // Write data - should succeed by creating the file first + const testData = { test: "data for missing file" } + await safeWriteJson(testFilePath, testData) + + // Verify file was created with correct content + const content = await fs.readFile(testFilePath, "utf8") + expect(JSON.parse(content)).toEqual(testData) + }) + + it("should work normally when file already exists", async () => { + // Create file first + const initialData = { initial: "content" } + await fs.writeFile(testFilePath, JSON.stringify(initialData)) + + // Write new data + const newData = { updated: "content" } + await safeWriteJson(testFilePath, newData) + + // Verify file was updated + const content = await fs.readFile(testFilePath, "utf8") + expect(JSON.parse(content)).toEqual(newData) + }) + + it("should handle concurrent writes correctly", async () => { + // Create file first + await fs.writeFile(testFilePath, JSON.stringify({ initial: "data" })) + + // Attempt concurrent writes + const writes = [] + for (let i = 0; i < 5; i++) { + writes.push(safeWriteJson(testFilePath, { count: i })) + } + + // All writes should complete without error + await expect(Promise.all(writes)).resolves.toBeDefined() + + // File should contain data from one of the writes + const content = await fs.readFile(testFilePath, "utf8") + const data = JSON.parse(content) + expect(data).toHaveProperty("count") + expect(typeof data.count).toBe("number") + }) + + it("should handle temporary files correctly", async () => { + // Create initial file + const initialData = { initial: "data", important: true } + await fs.writeFile(testFilePath, JSON.stringify(initialData)) + + // Write new data multiple times to ensure temp files are cleaned up + for (let i = 0; i < 3; i++) { + const newData = { updated: "content", iteration: i } + await safeWriteJson(testFilePath, newData) + + // Verify file was updated + const content = await fs.readFile(testFilePath, "utf8") + expect(JSON.parse(content)).toEqual(newData) + } + + // Check that no temp files remain in the directory + const files = await fs.readdir(tempDir) + const tempFiles = files.filter((f) => f.includes(".tmp") || f.includes(".bak")) + expect(tempFiles).toHaveLength(0) + }) +}) diff --git a/src/utils/__tests__/staleLockRecovery.test.ts b/src/utils/__tests__/staleLockRecovery.test.ts new file mode 100644 index 0000000000..70020f6925 --- /dev/null +++ b/src/utils/__tests__/staleLockRecovery.test.ts @@ -0,0 +1,306 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import * as fs from "fs/promises" +import * as path from "path" +import * as os from "os" +import { detectStaleLocks, recoverStaleLocks, performStartupStaleLockRecovery } from "../staleLockRecovery" +import { GlobalFileNames } from "../../shared/globalFileNames" + +describe("staleLockRecovery", () => { + let tempDir: string + let globalStoragePath: string + let tasksDir: string + + beforeEach(async () => { + // Create a temporary directory for testing + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "stale-lock-test-")) + globalStoragePath = tempDir + tasksDir = path.join(globalStoragePath, "tasks") + await fs.mkdir(tasksDir, { recursive: true }) + }) + + afterEach(async () => { + // Clean up temporary directory + await fs.rm(tempDir, { recursive: true, force: true }) + }) + + describe("detectStaleLocks", () => { + it("should return empty array when no tasks exist", async () => { + const results = await detectStaleLocks(globalStoragePath) + expect(results).toEqual([]) + }) + + it("should detect task with lock files and missing ui_messages.json", async () => { + // Create a task directory with lock files but no ui_messages.json + const taskId = "test-task-1" + const taskPath = path.join(tasksDir, taskId) + await fs.mkdir(taskPath) + + // Create lock files + await fs.writeFile(path.join(taskPath, "file1.lock"), "") + await fs.writeFile(path.join(taskPath, "file2.lock"), "") + + // Make lock files old enough to be stale + const oldTime = new Date(Date.now() - 15 * 60 * 1000) // 15 minutes ago + await fs.utimes(path.join(taskPath, "file1.lock"), oldTime, oldTime) + await fs.utimes(path.join(taskPath, "file2.lock"), oldTime, oldTime) + + const results = await detectStaleLocks(globalStoragePath) + + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ + taskId, + taskPath, + hasLockFiles: true, + hasUiMessagesFile: false, + lockFiles: expect.arrayContaining(["file1.lock", "file2.lock"]), + isStale: true, + }) + }) + + it("should not mark as stale when ui_messages.json exists", async () => { + // Create a task directory with lock files and ui_messages.json + const taskId = "test-task-2" + const taskPath = path.join(tasksDir, taskId) + await fs.mkdir(taskPath) + + // Create lock files + await fs.writeFile(path.join(taskPath, "file1.lock"), "") + + // Create ui_messages.json + await fs.writeFile(path.join(taskPath, GlobalFileNames.uiMessages), "[]") + + // Make lock file old + const oldTime = new Date(Date.now() - 15 * 60 * 1000) + await fs.utimes(path.join(taskPath, "file1.lock"), oldTime, oldTime) + + const results = await detectStaleLocks(globalStoragePath) + + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ + taskId, + hasLockFiles: true, + hasUiMessagesFile: true, + isStale: false, + }) + }) + + it("should not mark as stale when lock files are recent", async () => { + // Create a task directory with recent lock files + const taskId = "test-task-3" + const taskPath = path.join(tasksDir, taskId) + await fs.mkdir(taskPath) + + // Create recent lock file + await fs.writeFile(path.join(taskPath, "file1.lock"), "") + + const results = await detectStaleLocks(globalStoragePath, { + maxLockAge: 10 * 60 * 1000, // 10 minutes + }) + + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ + taskId, + hasLockFiles: true, + hasUiMessagesFile: false, + isStale: false, + }) + }) + + it("should handle multiple tasks correctly", async () => { + // Create multiple tasks with different states + const task1 = "task-1" + const task1Path = path.join(tasksDir, task1) + await fs.mkdir(task1Path) + await fs.writeFile(path.join(task1Path, "file.lock"), "") + + const task2 = "task-2" + const task2Path = path.join(tasksDir, task2) + await fs.mkdir(task2Path) + await fs.writeFile(path.join(task2Path, "file.lock"), "") + await fs.writeFile(path.join(task2Path, GlobalFileNames.uiMessages), "[]") + + // Make task1 lock stale + const oldTime = new Date(Date.now() - 15 * 60 * 1000) + await fs.utimes(path.join(task1Path, "file.lock"), oldTime, oldTime) + + const results = await detectStaleLocks(globalStoragePath) + + expect(results).toHaveLength(2) + expect(results.find((r) => r.taskId === task1)?.isStale).toBe(true) + expect(results.find((r) => r.taskId === task2)?.isStale).toBe(false) + }) + }) + + describe("recoverStaleLocks", () => { + it("should remove lock files and create ui_messages.json for stale tasks", async () => { + // Create a stale task + const taskId = "stale-task" + const taskPath = path.join(tasksDir, taskId) + await fs.mkdir(taskPath) + + const lockFile1 = path.join(taskPath, "file1.lock") + const lockFile2 = path.join(taskPath, "file2.lock") + await fs.writeFile(lockFile1, "") + await fs.writeFile(lockFile2, "") + + const detectionResults = [ + { + taskId, + taskPath, + hasLockFiles: true, + hasUiMessagesFile: false, + lockFiles: ["file1.lock", "file2.lock"], + isStale: true, + oldestLockAge: 15 * 60 * 1000, + }, + ] + + await recoverStaleLocks(detectionResults) + + // Check that lock files were removed + await expect(fs.access(lockFile1)).rejects.toThrow() + await expect(fs.access(lockFile2)).rejects.toThrow() + + // Check that ui_messages.json was created + const uiMessagesPath = path.join(taskPath, GlobalFileNames.uiMessages) + const uiMessagesContent = await fs.readFile(uiMessagesPath, "utf8") + expect(uiMessagesContent).toBe("[]") + }) + + it("should not recover non-stale tasks", async () => { + // Create a non-stale task + const taskId = "non-stale-task" + const taskPath = path.join(tasksDir, taskId) + await fs.mkdir(taskPath) + + const lockFile = path.join(taskPath, "file.lock") + await fs.writeFile(lockFile, "") + + const detectionResults = [ + { + taskId, + taskPath, + hasLockFiles: true, + hasUiMessagesFile: false, + lockFiles: ["file.lock"], + isStale: false, + oldestLockAge: 5 * 60 * 1000, + }, + ] + + await recoverStaleLocks(detectionResults) + + // Check that lock file was NOT removed + await expect(fs.access(lockFile)).resolves.toBeUndefined() + + // Check that ui_messages.json was NOT created + const uiMessagesPath = path.join(taskPath, GlobalFileNames.uiMessages) + await expect(fs.access(uiMessagesPath)).rejects.toThrow() + }) + + it("should respect autoRecover config", async () => { + // Create a stale task + const taskId = "stale-task" + const taskPath = path.join(tasksDir, taskId) + await fs.mkdir(taskPath) + + const lockFile = path.join(taskPath, "file.lock") + await fs.writeFile(lockFile, "") + + const detectionResults = [ + { + taskId, + taskPath, + hasLockFiles: true, + hasUiMessagesFile: false, + lockFiles: ["file.lock"], + isStale: true, + oldestLockAge: 15 * 60 * 1000, + }, + ] + + // Disable auto recovery + await recoverStaleLocks(detectionResults, { autoRecover: false }) + + // Check that lock file was NOT removed + await expect(fs.access(lockFile)).resolves.toBeUndefined() + }) + + it("should handle errors gracefully", async () => { + // Create a mock console.error to suppress error output in tests + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + // Create invalid detection results + const detectionResults = [ + { + taskId: "invalid-task", + taskPath: "/invalid/path/that/does/not/exist", + hasLockFiles: true, + hasUiMessagesFile: false, + lockFiles: ["file.lock"], + isStale: true, + oldestLockAge: 15 * 60 * 1000, + }, + ] + + // Should not throw + await expect(recoverStaleLocks(detectionResults)).resolves.toBeUndefined() + + // Should have logged errors + expect(consoleErrorSpy).toHaveBeenCalled() + + consoleErrorSpy.mockRestore() + }) + }) + + describe("performStartupStaleLockRecovery", () => { + it("should detect and recover stale locks on startup", async () => { + // Create a stale task + const taskId = "startup-stale-task" + const taskPath = path.join(tasksDir, taskId) + await fs.mkdir(taskPath) + + const lockFile = path.join(taskPath, "file.lock") + await fs.writeFile(lockFile, "") + + // Make lock file old + const oldTime = new Date(Date.now() - 15 * 60 * 1000) + await fs.utimes(lockFile, oldTime, oldTime) + + // Mock console.log to verify logging + const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}) + + await performStartupStaleLockRecovery(globalStoragePath) + + // Check that lock file was removed + await expect(fs.access(lockFile)).rejects.toThrow() + + // Check that ui_messages.json was created + const uiMessagesPath = path.join(taskPath, GlobalFileNames.uiMessages) + const uiMessagesContent = await fs.readFile(uiMessagesPath, "utf8") + expect(uiMessagesContent).toBe("[]") + + // Check that appropriate logs were made + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Starting stale lock detection")) + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Found 1 task(s) with stale locks")) + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Recovery completed")) + + consoleLogSpy.mockRestore() + }) + + it("should handle errors gracefully", async () => { + // Mock console methods + const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + // Use invalid path + await performStartupStaleLockRecovery("/invalid/path/that/does/not/exist") + + // Should have logged error + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("No stale locks detected")) + + consoleLogSpy.mockRestore() + consoleErrorSpy.mockRestore() + }) + }) +}) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 719bbd7216..02870a902e 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -37,6 +37,24 @@ async function safeWriteJson(filePath: string, data: any): Promise { throw dirError } + // Check if the file exists before attempting to acquire lock + // This prevents issues where lock files exist but the target file doesn't + const fileExists = await fs + .access(absoluteFilePath) + .then(() => true) + .catch(() => false) + + // If file doesn't exist, create an empty file first to ensure lock can be acquired + if (!fileExists) { + try { + await fs.writeFile(absoluteFilePath, "", "utf8") + } catch (createError: any) { + // If we can't create the file, we can't proceed + console.error(`Failed to create file for locking at ${absoluteFilePath}:`, createError) + throw createError + } + } + // Acquire the lock before any file operations try { releaseLock = await lockfile.lock(absoluteFilePath, { diff --git a/src/utils/staleLockRecovery.ts b/src/utils/staleLockRecovery.ts new file mode 100644 index 0000000000..067424c240 --- /dev/null +++ b/src/utils/staleLockRecovery.ts @@ -0,0 +1,190 @@ +import * as fs from "fs/promises" +import * as path from "path" +import { fileExistsAtPath } from "./fs" +import { GlobalFileNames } from "../shared/globalFileNames" + +/** + * Configuration for stale lock detection + */ +interface StaleLockConfig { + /** + * Maximum age in milliseconds for a lock file to be considered stale + * Default: 10 minutes (600000ms) + */ + maxLockAge?: number + + /** + * Whether to automatically recover stale locks + * Default: true + */ + autoRecover?: boolean +} + +/** + * Result of stale lock detection for a task + */ +interface StaleLockDetectionResult { + taskId: string + taskPath: string + hasLockFiles: boolean + hasUiMessagesFile: boolean + lockFiles: string[] + isStale: boolean + oldestLockAge?: number +} + +/** + * Detects stale lock conditions in task directories + * A stale lock condition exists when: + * 1. Lock files exist in the task directory + * 2. ui_messages.json is missing + * 3. Lock files are older than the configured threshold + */ +export async function detectStaleLocks( + globalStoragePath: string, + config: StaleLockConfig = {}, +): Promise { + const { maxLockAge = 10 * 60 * 1000 } = config // Default: 10 minutes + const results: StaleLockDetectionResult[] = [] + + try { + // Get the tasks directory + const tasksDir = path.join(globalStoragePath, "tasks") + + // Check if tasks directory exists + if (!(await fileExistsAtPath(tasksDir))) { + return results + } + + // Read all task directories + const taskDirs = await fs.readdir(tasksDir) + const currentTime = Date.now() + + for (const taskId of taskDirs) { + const taskPath = path.join(tasksDir, taskId) + + // Skip if not a directory + const stat = await fs.stat(taskPath).catch(() => null) + if (!stat || !stat.isDirectory()) { + continue + } + + // Check for lock files + const files = await fs.readdir(taskPath) + const lockFiles = files.filter((f) => f.endsWith(".lock")) + const hasUiMessagesFile = await fileExistsAtPath(path.join(taskPath, GlobalFileNames.uiMessages)) + + if (lockFiles.length > 0) { + // Check age of lock files + let oldestLockAge = 0 + let isStale = false + + for (const lockFile of lockFiles) { + const lockPath = path.join(taskPath, lockFile) + const lockStat = await fs.stat(lockPath).catch(() => null) + + if (lockStat) { + const age = currentTime - lockStat.mtimeMs + oldestLockAge = Math.max(oldestLockAge, age) + + // Consider stale if missing ui_messages.json and lock is old enough + if (!hasUiMessagesFile && age > maxLockAge) { + isStale = true + } + } + } + + results.push({ + taskId, + taskPath, + hasLockFiles: true, + hasUiMessagesFile, + lockFiles, + isStale, + oldestLockAge, + }) + } + } + + return results + } catch (error) { + console.error("Error detecting stale locks:", error) + return results + } +} + +/** + * Recovers from stale lock conditions by: + * 1. Removing stale lock files + * 2. Creating an empty ui_messages.json if missing + */ +export async function recoverStaleLocks( + detectionResults: StaleLockDetectionResult[], + config: StaleLockConfig = {}, +): Promise { + const { autoRecover = true } = config + + if (!autoRecover) { + return + } + + for (const result of detectionResults) { + if (!result.isStale) { + continue + } + + try { + console.log(`[StaleLockRecovery] Recovering stale locks for task ${result.taskId}`) + + // Remove lock files + for (const lockFile of result.lockFiles) { + const lockPath = path.join(result.taskPath, lockFile) + try { + await fs.unlink(lockPath) + console.log(`[StaleLockRecovery] Removed stale lock: ${lockFile}`) + } catch (error) { + console.error(`[StaleLockRecovery] Failed to remove lock ${lockFile}:`, error) + } + } + + // Create empty ui_messages.json if missing + if (!result.hasUiMessagesFile) { + const uiMessagesPath = path.join(result.taskPath, GlobalFileNames.uiMessages) + try { + // Create an empty array to represent no messages + await fs.writeFile(uiMessagesPath, "[]", "utf8") + console.log(`[StaleLockRecovery] Created empty ui_messages.json for task ${result.taskId}`) + } catch (error) { + console.error(`[StaleLockRecovery] Failed to create ui_messages.json:`, error) + } + } + } catch (error) { + console.error(`[StaleLockRecovery] Error recovering task ${result.taskId}:`, error) + } + } +} + +/** + * Performs stale lock detection and recovery on startup + */ +export async function performStartupStaleLockRecovery( + globalStoragePath: string, + config: StaleLockConfig = {}, +): Promise { + try { + console.log("[StaleLockRecovery] Starting stale lock detection...") + + const detectionResults = await detectStaleLocks(globalStoragePath, config) + const staleCount = detectionResults.filter((r) => r.isStale).length + + if (staleCount > 0) { + console.log(`[StaleLockRecovery] Found ${staleCount} task(s) with stale locks`) + await recoverStaleLocks(detectionResults, config) + console.log("[StaleLockRecovery] Recovery completed") + } else { + console.log("[StaleLockRecovery] No stale locks detected") + } + } catch (error) { + console.error("[StaleLockRecovery] Error during startup recovery:", error) + } +}