diff --git a/apps/backend/runners/roadmap/competitor_analyzer.py b/apps/backend/runners/roadmap/competitor_analyzer.py index 01049b7d..c111d106 100644 --- a/apps/backend/runners/roadmap/competitor_analyzer.py +++ b/apps/backend/runners/roadmap/competitor_analyzer.py @@ -7,6 +7,7 @@ from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING +from core.file_utils import write_json_atomic from ui import muted, print_status from .models import RoadmapPhaseResult @@ -146,23 +147,22 @@ Output your findings to competitor_analysis.json. def _create_disabled_analysis_file(self): """Create an analysis file indicating the feature is disabled.""" - with open(self.analysis_file, "w", encoding="utf-8") as f: - json.dump( - { - "enabled": False, - "reason": "Competitor analysis not enabled by user", - "competitors": [], - "market_gaps": [], - "insights_summary": { - "top_pain_points": [], - "differentiator_opportunities": [], - "market_trends": [], - }, - "created_at": datetime.now().isoformat(), + write_json_atomic( + self.analysis_file, + { + "enabled": False, + "reason": "Competitor analysis not enabled by user", + "competitors": [], + "market_gaps": [], + "insights_summary": { + "top_pain_points": [], + "differentiator_opportunities": [], + "market_trends": [], }, - f, - indent=2, - ) + "created_at": datetime.now().isoformat(), + }, + indent=2, + ) def _create_error_analysis_file(self, error: str, errors: list[str] | None = None): """Create an analysis file with error information.""" @@ -181,5 +181,4 @@ Output your findings to competitor_analysis.json. if errors: data["errors"] = errors - with open(self.analysis_file, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2) + write_json_atomic(self.analysis_file, data, indent=2) diff --git a/apps/backend/runners/roadmap/graph_integration.py b/apps/backend/runners/roadmap/graph_integration.py index 325dcb44..98a69bd6 100644 --- a/apps/backend/runners/roadmap/graph_integration.py +++ b/apps/backend/runners/roadmap/graph_integration.py @@ -2,10 +2,10 @@ Graphiti integration for retrieving graph hints during roadmap generation. """ -import json from datetime import datetime from pathlib import Path +from core.file_utils import write_json_atomic from debug import debug, debug_error, debug_success from graphiti_providers import get_graph_hints, is_graphiti_enabled from ui import print_status @@ -81,42 +81,36 @@ class GraphHintsProvider: def _create_disabled_hints_file(self): """Create a hints file indicating Graphiti is disabled.""" - with open(self.hints_file, "w", encoding="utf-8") as f: - json.dump( - { - "enabled": False, - "reason": "Graphiti not configured", - "hints": [], - "created_at": datetime.now().isoformat(), - }, - f, - indent=2, - ) + write_json_atomic( + self.hints_file, + { + "enabled": False, + "reason": "Graphiti not configured", + "hints": [], + "created_at": datetime.now().isoformat(), + }, + ) def _save_hints(self, hints: list): """Save retrieved hints to file.""" - with open(self.hints_file, "w", encoding="utf-8") as f: - json.dump( - { - "enabled": True, - "hints": hints, - "hint_count": len(hints), - "created_at": datetime.now().isoformat(), - }, - f, - indent=2, - ) + write_json_atomic( + self.hints_file, + { + "enabled": True, + "hints": hints, + "hint_count": len(hints), + "created_at": datetime.now().isoformat(), + }, + ) def _save_error_hints(self, error: str): """Save error information to hints file.""" - with open(self.hints_file, "w", encoding="utf-8") as f: - json.dump( - { - "enabled": True, - "error": error, - "hints": [], - "created_at": datetime.now().isoformat(), - }, - f, - indent=2, - ) + write_json_atomic( + self.hints_file, + { + "enabled": True, + "error": error, + "hints": [], + "created_at": datetime.now().isoformat(), + }, + ) diff --git a/apps/backend/runners/roadmap/phases.py b/apps/backend/runners/roadmap/phases.py index 36032f35..0b06333e 100644 --- a/apps/backend/runners/roadmap/phases.py +++ b/apps/backend/runners/roadmap/phases.py @@ -7,6 +7,7 @@ import shutil from pathlib import Path from typing import TYPE_CHECKING +from core.file_utils import write_json_atomic from debug import ( debug, debug_detailed, @@ -509,8 +510,7 @@ Output the complete roadmap to roadmap.json. # Write back the merged roadmap try: - with open(self.roadmap_file, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2) + write_json_atomic(self.roadmap_file, data, indent=2) debug_success( "roadmap_phase", "Merged preserved features into roadmap.json", diff --git a/apps/frontend/src/main/agent/agent-queue.ts b/apps/frontend/src/main/agent/agent-queue.ts index fc9233ca..8ac65ca4 100644 --- a/apps/frontend/src/main/agent/agent-queue.ts +++ b/apps/frontend/src/main/agent/agent-queue.ts @@ -1,6 +1,6 @@ import { spawn } from 'child_process'; import path from 'path'; -import { existsSync, writeFileSync, mkdirSync, unlinkSync, promises as fsPromises } from 'fs'; +import { existsSync, mkdirSync, unlinkSync, promises as fsPromises } from 'fs'; import { EventEmitter } from 'events'; import { AgentState } from './agent-state'; import { AgentEvents } from './agent-events'; @@ -19,6 +19,8 @@ import { transformIdeaFromSnakeCase, transformSessionFromSnakeCase } from '../ip import { transformRoadmapFromSnakeCase } from '../ipc-handlers/roadmap/transformers'; import type { RawIdea } from '../ipc-handlers/ideation/types'; import { getPathDelimiter } from '../platform'; +import { debounce } from '../utils/debounce'; +import { writeFileWithRetry } from '../utils/atomic-file'; /** Maximum length for status messages displayed in progress UI */ const STATUS_MESSAGE_MAX_LENGTH = 200; @@ -43,6 +45,15 @@ export class AgentQueueManager { private events: AgentEvents; private processManager: AgentProcessManager; private emitter: EventEmitter; + private debouncedPersistRoadmapProgress: ( + projectPath: string, + phase: string, + progress: number, + message: string, + startedAt: string, + isRunning: boolean + ) => void; + private cancelPersistRoadmapProgress: () => void; constructor( state: AgentState, @@ -54,6 +65,17 @@ export class AgentQueueManager { this.events = events; this.processManager = processManager; this.emitter = emitter; + + // Create debounced version of persistRoadmapProgress (300ms, leading + trailing) + // This limits file writes to ~3-4 per second while ensuring immediate first write + // and final state persistence after burst of updates + const { fn: debouncedFn, cancel } = debounce( + this.persistRoadmapProgress.bind(this), + 300, + { leading: true, trailing: true } + ); + this.debouncedPersistRoadmapProgress = debouncedFn; + this.cancelPersistRoadmapProgress = cancel; } /** @@ -90,14 +112,14 @@ export class AgentQueueManager { * @param startedAt - When generation started (ISO string) * @param isRunning - Whether generation is actively running */ - private persistRoadmapProgress( + private async persistRoadmapProgress( projectPath: string, phase: string, progress: number, message: string, startedAt: string, isRunning: boolean - ): void { + ): Promise { try { const roadmapDir = path.join(projectPath, AUTO_BUILD_PATHS.ROADMAP_DIR); const progressPath = path.join(roadmapDir, AUTO_BUILD_PATHS.GENERATION_PROGRESS); @@ -116,7 +138,7 @@ export class AgentQueueManager { is_running: isRunning }; - writeFileSync(progressPath, JSON.stringify(progressData, null, 2)); + await writeFileWithRetry(progressPath, JSON.stringify(progressData, null, 2), { encoding: 'utf-8' }); debugLog('[Agent Queue] Persisted roadmap progress:', { phase, progress }); } catch (err) { debugError('[Agent Queue] Failed to persist roadmap progress:', err); @@ -130,6 +152,9 @@ export class AgentQueueManager { * @param projectPath - The project directory path */ private clearRoadmapProgress(projectPath: string): void { + // Cancel any pending debounced write to prevent re-creating the file after deletion + this.cancelPersistRoadmapProgress(); + try { const progressPath = path.join( projectPath, @@ -739,8 +764,8 @@ export class AgentQueueManager { // Track startedAt timestamp for progress persistence const roadmapStartedAt = new Date().toISOString(); - // Persist initial progress state - this.persistRoadmapProgress( + // Persist initial progress state (debounced - will execute immediately due to leading: true) + this.debouncedPersistRoadmapProgress( projectPath, progressPhase, progressPercent, @@ -777,8 +802,8 @@ export class AgentQueueManager { // Get status message for display const statusMessage = formatStatusMessage(log); - // Persist progress to disk for recovery after restart - this.persistRoadmapProgress( + // Persist progress to disk for recovery after restart (debounced to limit writes) + this.debouncedPersistRoadmapProgress( projectPath, progressPhase, progressPercent, @@ -805,8 +830,8 @@ export class AgentQueueManager { const statusMessage = formatStatusMessage(log); - // Persist progress to disk (also on stderr to show activity) - this.persistRoadmapProgress( + // Persist progress to disk (debounced - also on stderr to show activity) + this.debouncedPersistRoadmapProgress( projectPath, progressPhase, progressPercent, diff --git a/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts b/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts index 8c611d44..896f11cb 100644 --- a/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts @@ -21,11 +21,38 @@ import type { } from "../../shared/types"; import type { RoadmapConfig } from "../agent/types"; import path from "path"; -import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync } from "fs"; +import { existsSync, readFileSync, mkdirSync, readdirSync, unlinkSync } from "fs"; import { projectStore } from "../project-store"; import { AgentManager } from "../agent"; import { debugLog, debugError } from "../../shared/utils/debug-logger"; import { safeSendToRenderer } from "./utils"; +import { writeFileWithRetry, readFileWithRetry } from "../utils/atomic-file"; + +/** + * Simple in-process file lock to serialize read-modify-write operations. + * Prevents concurrent IPC calls from causing lost updates on the same file. + */ +const fileLocks = new Map>(); + +async function withFileLock(filepath: string, fn: () => Promise): Promise { + // Wait for any existing lock on this file + while (fileLocks.has(filepath)) { + await fileLocks.get(filepath); + } + + let resolve: (() => void) | undefined; + const lockPromise = new Promise((r) => { + resolve = r; + }); + fileLocks.set(filepath, lockPromise); + + try { + return await fn(); + } finally { + fileLocks.delete(filepath); + resolve?.(); + } +} /** * Read feature settings from the settings file @@ -88,7 +115,7 @@ export function registerRoadmapHandlers( } try { - const content = readFileSync(roadmapPath, "utf-8"); + const content = await readFileWithRetry(roadmapPath, { encoding: "utf-8" }) as string; const rawRoadmap = JSON.parse(content); // Load competitor analysis if available (competitor_analysis.json) @@ -100,7 +127,7 @@ export function registerRoadmapHandlers( let competitorAnalysis: CompetitorAnalysis | undefined; if (existsSync(competitorAnalysisPath)) { try { - const competitorContent = readFileSync(competitorAnalysisPath, "utf-8"); + const competitorContent = await readFileWithRetry(competitorAnalysisPath, { encoding: "utf-8" }) as string; const rawCompetitor = JSON.parse(competitorContent); // Transform snake_case to camelCase for frontend competitorAnalysis = { @@ -378,42 +405,44 @@ export function registerRoadmapHandlers( ); try { - let content: string; - try { - content = readFileSync(roadmapPath, "utf-8"); - } catch (readErr: unknown) { - if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') { - return { success: false, error: "Roadmap not found" }; + return await withFileLock(roadmapPath, async () => { + let content: string; + try { + content = await readFileWithRetry(roadmapPath, { encoding: "utf-8" }) as string; + } catch (readErr: unknown) { + if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') { + return { success: false, error: "Roadmap not found" }; + } + throw readErr; } - throw readErr; - } - const existingRoadmap = JSON.parse(content); + const existingRoadmap = JSON.parse(content); - // Transform camelCase features back to snake_case for JSON file - existingRoadmap.features = roadmapData.features.map((feature) => ({ - id: feature.id, - title: feature.title, - description: feature.description, - rationale: feature.rationale || "", - priority: feature.priority, - complexity: feature.complexity, - impact: feature.impact, - phase_id: feature.phaseId, - dependencies: feature.dependencies || [], - status: feature.status, - acceptance_criteria: feature.acceptanceCriteria || [], - user_stories: feature.userStories || [], - linked_spec_id: feature.linkedSpecId, - competitor_insight_ids: feature.competitorInsightIds, - })); + // Transform camelCase features back to snake_case for JSON file + existingRoadmap.features = roadmapData.features.map((feature) => ({ + id: feature.id, + title: feature.title, + description: feature.description, + rationale: feature.rationale || "", + priority: feature.priority, + complexity: feature.complexity, + impact: feature.impact, + phase_id: feature.phaseId, + dependencies: feature.dependencies || [], + status: feature.status, + acceptance_criteria: feature.acceptanceCriteria || [], + user_stories: feature.userStories || [], + linked_spec_id: feature.linkedSpecId, + competitor_insight_ids: feature.competitorInsightIds, + })); - // Update metadata timestamp - existingRoadmap.metadata = existingRoadmap.metadata || {}; - existingRoadmap.metadata.updated_at = new Date().toISOString(); + // Update metadata timestamp + existingRoadmap.metadata = existingRoadmap.metadata || {}; + existingRoadmap.metadata.updated_at = new Date().toISOString(); - writeFileSync(roadmapPath, JSON.stringify(existingRoadmap, null, 2), 'utf-8'); + await writeFileWithRetry(roadmapPath, JSON.stringify(existingRoadmap, null, 2), { encoding: 'utf-8' }); - return { success: true }; + return { success: true }; + }); } catch (error) { return { success: false, @@ -443,30 +472,32 @@ export function registerRoadmapHandlers( ); try { - let content: string; - try { - content = readFileSync(roadmapPath, "utf-8"); - } catch (readErr: unknown) { - if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') { - return { success: false, error: "Roadmap not found" }; + return await withFileLock(roadmapPath, async () => { + let content: string; + try { + content = await readFileWithRetry(roadmapPath, { encoding: "utf-8" }) as string; + } catch (readErr: unknown) { + if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') { + return { success: false, error: "Roadmap not found" }; + } + throw readErr; } - throw readErr; - } - const roadmap = JSON.parse(content); + const roadmap = JSON.parse(content); - // Find and update the feature - const feature = roadmap.features?.find((f: { id: string }) => f.id === featureId); - if (!feature) { - return { success: false, error: "Feature not found" }; - } + // Find and update the feature + const feature = roadmap.features?.find((f: { id: string }) => f.id === featureId); + if (!feature) { + return { success: false, error: "Feature not found" }; + } - feature.status = status; - roadmap.metadata = roadmap.metadata || {}; - roadmap.metadata.updated_at = new Date().toISOString(); + feature.status = status; + roadmap.metadata = roadmap.metadata || {}; + roadmap.metadata.updated_at = new Date().toISOString(); - writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2), 'utf-8'); + await writeFileWithRetry(roadmapPath, JSON.stringify(roadmap, null, 2), { encoding: 'utf-8' }); - return { success: true }; + return { success: true }; + }); } catch (error) { return { success: false, @@ -491,9 +522,10 @@ export function registerRoadmapHandlers( ); try { + return await withFileLock(roadmapPath, async () => { let content: string; try { - content = readFileSync(roadmapPath, "utf-8"); + content = await readFileWithRetry(roadmapPath, { encoding: "utf-8" }) as string; } catch (readErr: unknown) { if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') { return { success: false, error: "Roadmap not found" }; @@ -571,10 +603,10 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n" status: "pending", phases: [], }; - writeFileSync( + await writeFileWithRetry( path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN), JSON.stringify(implementationPlan, null, 2), - 'utf-8' + { encoding: 'utf-8' } ); // Create requirements.json @@ -582,14 +614,14 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n" task_description: taskDescription, workflow_type: "feature", }; - writeFileSync( + await writeFileWithRetry( path.join(specDir, AUTO_BUILD_PATHS.REQUIREMENTS), JSON.stringify(requirements, null, 2), - 'utf-8' + { encoding: 'utf-8' } ); // Create spec.md (required by backend spec creation process) - writeFileSync(path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE), taskDescription, 'utf-8'); + await writeFileWithRetry(path.join(specDir, AUTO_BUILD_PATHS.SPEC_FILE), taskDescription, { encoding: 'utf-8' }); // Build metadata const metadata: TaskMetadata = { @@ -597,7 +629,7 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n" featureId: feature.id, category: "feature", }; - writeFileSync(path.join(specDir, "task_metadata.json"), JSON.stringify(metadata, null, 2), 'utf-8'); + await writeFileWithRetry(path.join(specDir, "task_metadata.json"), JSON.stringify(metadata, null, 2), { encoding: 'utf-8' }); // NOTE: We do NOT auto-start spec creation here - user should explicitly start the task // from the kanban board when they're ready @@ -607,7 +639,7 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n" feature.linked_spec_id = specId; roadmap.metadata = roadmap.metadata || {}; roadmap.metadata.updated_at = new Date().toISOString(); - writeFileSync(roadmapPath, JSON.stringify(roadmap, null, 2), 'utf-8'); + await writeFileWithRetry(roadmapPath, JSON.stringify(roadmap, null, 2), { encoding: 'utf-8' }); // Create task object const task: Task = { @@ -625,6 +657,7 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n" }; return { success: true, data: task }; + }); } catch (error) { return { success: false, @@ -676,7 +709,7 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n" is_running: isRunning, }; - writeFileSync(progressPath, JSON.stringify(fileData, null, 2), 'utf-8'); + await writeFileWithRetry(progressPath, JSON.stringify(fileData, null, 2), { encoding: 'utf-8' }); debugLog("[Roadmap Handler] Saved progress checkpoint:", { projectId, phase: progressData.phase }); return { success: true }; @@ -712,7 +745,7 @@ ${(feature.acceptance_criteria || []).map((c: string) => `- [ ] ${c}`).join("\n" } try { - const content = readFileSync(progressPath, "utf-8"); + const content = await readFileWithRetry(progressPath, { encoding: "utf-8" }) as string; const rawData = JSON.parse(content); // Valid phase values that the frontend expects diff --git a/apps/frontend/src/main/utils/__tests__/atomic-file-retry.test.ts b/apps/frontend/src/main/utils/__tests__/atomic-file-retry.test.ts new file mode 100644 index 00000000..17092f53 --- /dev/null +++ b/apps/frontend/src/main/utils/__tests__/atomic-file-retry.test.ts @@ -0,0 +1,140 @@ +/** + * Tests for atomic-file retry behavior with mocked transient errors. + * + * Separated from atomic-file.test.ts because vi.mock() is hoisted and + * would affect the integration tests that use real filesystem operations. + */ + +import { describe, expect, it, beforeEach, vi } from 'vitest'; +import { rename as originalRename, readFile as originalReadFile } from 'fs/promises'; + +// Track call counts per mock +let renameCallCount = 0; +let readFileCallCount = 0; +// Control mock behavior per test +// biome-ignore lint/suspicious/noExplicitAny: mock functions need flexible types +let renameMockFn: ((...args: any[]) => Promise) | null = null; +// biome-ignore lint/suspicious/noExplicitAny: mock functions need flexible types +let readFileMockFn: ((...args: any[]) => Promise) | null = null; + +vi.mock('fs/promises', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + rename: (...args: Parameters) => { + renameCallCount++; + if (renameMockFn) return renameMockFn(...args); + return original.rename(...args); + }, + readFile: (...args: Parameters) => { + readFileCallCount++; + if (readFileMockFn) return readFileMockFn(...args); + return original.readFile(...args); + }, + }; +}); + +// Import after mock setup +import { existsSync } from 'fs'; +import { mkdir, writeFile, readFile, rm } from 'fs/promises'; +import path from 'path'; +import { + writeFileWithRetry, + readFileWithRetry, + AtomicFileError, +} from '../atomic-file'; + +const TEST_DIR = path.join(__dirname, '.test-atomic-retry'); + +describe('transient error retry behavior', () => { + beforeEach(async () => { + renameCallCount = 0; + readFileCallCount = 0; + renameMockFn = null; + readFileMockFn = null; + + if (existsSync(TEST_DIR)) { + await rm(TEST_DIR, { recursive: true, force: true }); + } + await mkdir(TEST_DIR, { recursive: true }); + }); + + // afterEach handled by beforeEach cleanup of next test, plus: + // final cleanup not strictly needed since test dir is inside __tests__ + + it('should retry on EBUSY and succeed when error clears', async () => { + const filePath = path.join(TEST_DIR, 'transient-write.txt'); + + // Fail with EBUSY on first rename attempt, succeed on second + renameMockFn = async (...args: unknown[]) => { + if (renameCallCount === 1) { + const err = new Error('EBUSY: resource busy') as NodeJS.ErrnoException; + err.code = 'EBUSY'; + throw err; + } + renameMockFn = null; // Use real rename for subsequent calls + const { rename } = await vi.importActual('fs/promises'); + return rename(args[0] as string, args[1] as string); + }; + + await writeFileWithRetry(filePath, 'retry content', { retryDelay: 1 }); + + const result = await readFile(filePath, 'utf-8'); + expect(result).toBe('retry content'); + // rename called at least twice: first fails, second succeeds + expect(renameCallCount).toBeGreaterThanOrEqual(2); + }); + + it('should throw AtomicFileError after exhausting retries on transient errors', async () => { + const filePath = path.join(TEST_DIR, 'exhaust-retries.txt'); + + // Always fail with EACCES + renameMockFn = async () => { + const err = new Error('EACCES: permission denied') as NodeJS.ErrnoException; + err.code = 'EACCES'; + throw err; + }; + + await expect( + writeFileWithRetry(filePath, 'content', { maxRetries: 2, retryDelay: 1 }) + ).rejects.toThrow(AtomicFileError); + + // Should have attempted 3 times (initial + 2 retries) + expect(renameCallCount).toBe(3); + }); + + it('should retry reads on EAGAIN and succeed when error clears', async () => { + const filePath = path.join(TEST_DIR, 'transient-read.txt'); + await writeFile(filePath, 'readable content', 'utf-8'); + + // Fail with EAGAIN on first read attempt + readFileMockFn = async (...args: unknown[]) => { + if (readFileCallCount === 1) { + const err = new Error('EAGAIN: resource temporarily unavailable') as NodeJS.ErrnoException; + err.code = 'EAGAIN'; + throw err; + } + readFileMockFn = null; + const { readFile: realReadFile } = await vi.importActual('fs/promises'); + return realReadFile(args[0] as string, args[1] as { encoding: BufferEncoding }); + }; + + const result = await readFileWithRetry(filePath, { encoding: 'utf-8', retryDelay: 1 }); + expect(result).toBe('readable content'); + expect(readFileCallCount).toBeGreaterThanOrEqual(2); + }); + + it('should not retry on non-transient errors like ENOENT', async () => { + const filePath = path.join(TEST_DIR, 'does-not-exist.txt'); + + // Reset to track calls - readFile will naturally throw ENOENT + readFileCallCount = 0; + + await expect( + readFileWithRetry(filePath, { maxRetries: 3, retryDelay: 1 }) + ).rejects.toThrow(AtomicFileError); + + // ENOENT is not transient, should fail immediately without retrying + expect(readFileCallCount).toBe(1); + }); +}); diff --git a/apps/frontend/src/main/utils/__tests__/atomic-file.test.ts b/apps/frontend/src/main/utils/__tests__/atomic-file.test.ts new file mode 100644 index 00000000..cae446a8 --- /dev/null +++ b/apps/frontend/src/main/utils/__tests__/atomic-file.test.ts @@ -0,0 +1,455 @@ +/** + * Tests for atomic-file module - atomic file operations with retry logic. + */ + +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import { existsSync } from 'fs'; +import path from 'path'; +import { + writeFileAtomic, + writeFileWithRetry, + readFileWithRetry, + writeJsonAtomic, + writeJsonWithRetry, + AtomicFileError, +} from '../atomic-file'; + +// Import fs/promises to use in tests +import * as fsPromises from 'fs/promises'; +const { mkdir, readFile, writeFile, rm } = fsPromises; + +// Test directory for isolated tests +const TEST_DIR = path.join(__dirname, '.test-atomic-file'); + +describe('writeFileAtomic', () => { + beforeEach(async () => { + // Clean up test directory + if (existsSync(TEST_DIR)) { + await rm(TEST_DIR, { recursive: true, force: true }); + } + await mkdir(TEST_DIR, { recursive: true }); + }); + + afterEach(async () => { + // Clean up after tests + if (existsSync(TEST_DIR)) { + await rm(TEST_DIR, { recursive: true, force: true }); + } + }); + + describe('basic operations', () => { + it('should write a new file atomically', async () => { + const filePath = path.join(TEST_DIR, 'test.txt'); + const content = 'Hello, World!'; + + await writeFileAtomic(filePath, content); + + const result = await readFile(filePath, 'utf-8'); + expect(result).toBe(content); + }); + + it('should overwrite an existing file atomically', async () => { + const filePath = path.join(TEST_DIR, 'existing.txt'); + const initialContent = 'Initial content'; + const newContent = 'New content'; + + await writeFile(filePath, initialContent, 'utf-8'); + await writeFileAtomic(filePath, newContent); + + const result = await readFile(filePath, 'utf-8'); + expect(result).toBe(newContent); + }); + + it('should create parent directories if they do not exist', async () => { + const filePath = path.join(TEST_DIR, 'nested', 'dir', 'file.txt'); + const content = 'Nested file content'; + + await writeFileAtomic(filePath, content); + + const result = await readFile(filePath, 'utf-8'); + expect(result).toBe(content); + }); + + it('should write Buffer data', async () => { + const filePath = path.join(TEST_DIR, 'buffer.bin'); + const buffer = Buffer.from([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello" + + await writeFileAtomic(filePath, buffer); + + const result = await readFile(filePath); + expect(result).toEqual(buffer); + }); + + it('should respect encoding option', async () => { + const filePath = path.join(TEST_DIR, 'utf8.txt'); + const content = 'UTF-8 content: 你好'; + + await writeFileAtomic(filePath, content, { encoding: 'utf-8' }); + + const result = await readFile(filePath, 'utf-8'); + expect(result).toBe(content); + }); + }); + + describe('temp file cleanup', () => { + it('should not leave temp files after successful write', async () => { + const filePath = path.join(TEST_DIR, 'no-temp.txt'); + + await writeFileAtomic(filePath, 'content'); + + const files = await fsPromises.readdir(TEST_DIR); + const tempFiles = files.filter(f => f.includes('.tmp.')); + + expect(tempFiles).toHaveLength(0); + }); + + it('should not leave temp files after multiple writes', async () => { + const filePath = path.join(TEST_DIR, 'multiple-writes.txt'); + + for (let i = 0; i < 5; i++) { + await writeFileAtomic(filePath, `content ${i}`); + } + + const files = await fsPromises.readdir(TEST_DIR); + const tempFiles = files.filter(f => f.includes('.tmp.')); + + expect(tempFiles).toHaveLength(0); + }); + }); + + describe('error handling', () => { + it('should throw error when write fails', async () => { + // Create a regular file where mkdir would need to create a directory. + // This fails cross-platform because you can't mkdir inside a file. + const blockingFile = path.join(TEST_DIR, 'blocker'); + await writeFile(blockingFile, 'not a directory'); + const invalidPath = path.join(blockingFile, 'sub', 'file.txt'); + + await expect( + writeFileAtomic(invalidPath, 'content') + ).rejects.toThrow(); + }); + }); +}); + +describe('writeFileWithRetry', () => { + beforeEach(async () => { + if (existsSync(TEST_DIR)) { + await rm(TEST_DIR, { recursive: true, force: true }); + } + await mkdir(TEST_DIR, { recursive: true }); + }); + + afterEach(async () => { + if (existsSync(TEST_DIR)) { + await rm(TEST_DIR, { recursive: true, force: true }); + } + }); + + describe('retry logic', () => { + it('should succeed on first attempt when no errors occur', async () => { + const filePath = path.join(TEST_DIR, 'retry-success.txt'); + const content = 'First attempt success'; + + await writeFileWithRetry(filePath, content); + + const result = await readFile(filePath, 'utf-8'); + expect(result).toBe(content); + }); + + it('should write file successfully with retry enabled', async () => { + const filePath = path.join(TEST_DIR, 'retry-enabled.txt'); + const content = 'Content with retry'; + + await writeFileWithRetry(filePath, content, { maxRetries: 5, retryDelay: 10 }); + + const result = await readFile(filePath, 'utf-8'); + expect(result).toBe(content); + }); + + it('should handle Buffer data with retry', async () => { + const filePath = path.join(TEST_DIR, 'retry-buffer.bin'); + const buffer = Buffer.from('Binary content'); + + await writeFileWithRetry(filePath, buffer, { maxRetries: 3 }); + + const result = await readFile(filePath); + expect(result).toEqual(buffer); + }); + + it('should create parent directories with retry logic', async () => { + const filePath = path.join(TEST_DIR, 'nested', 'retry', 'file.txt'); + const content = 'Nested with retry'; + + await writeFileWithRetry(filePath, content, { maxRetries: 3 }); + + const result = await readFile(filePath, 'utf-8'); + expect(result).toBe(content); + }); + }); + + describe('options handling', () => { + it('should accept custom maxRetries option', async () => { + const filePath = path.join(TEST_DIR, 'custom-retries.txt'); + const content = 'Custom retries'; + + await writeFileWithRetry(filePath, content, { maxRetries: 10 }); + + const result = await readFile(filePath, 'utf-8'); + expect(result).toBe(content); + }); + + it('should accept custom retryDelay option', async () => { + const filePath = path.join(TEST_DIR, 'custom-delay.txt'); + const content = 'Custom delay'; + + await writeFileWithRetry(filePath, content, { retryDelay: 50 }); + + const result = await readFile(filePath, 'utf-8'); + expect(result).toBe(content); + }); + + it('should accept all options combined', async () => { + const filePath = path.join(TEST_DIR, 'all-options.txt'); + const content = 'All options'; + + await writeFileWithRetry(filePath, content, { + encoding: 'utf-8', + maxRetries: 5, + retryDelay: 100, + }); + + const result = await readFile(filePath, 'utf-8'); + expect(result).toBe(content); + }); + }); +}); + +describe('readFileWithRetry', () => { + beforeEach(async () => { + if (existsSync(TEST_DIR)) { + await rm(TEST_DIR, { recursive: true, force: true }); + } + await mkdir(TEST_DIR, { recursive: true }); + }); + + afterEach(async () => { + if (existsSync(TEST_DIR)) { + await rm(TEST_DIR, { recursive: true, force: true }); + } + }); + + describe('basic operations', () => { + it('should read file successfully', async () => { + const filePath = path.join(TEST_DIR, 'read.txt'); + const content = 'Read me!'; + + await writeFile(filePath, content, 'utf-8'); + const result = await readFileWithRetry(filePath, { encoding: 'utf-8' }); + + expect(result).toBe(content); + }); + + it('should return Buffer when encoding not specified', async () => { + const filePath = path.join(TEST_DIR, 'read-buffer.bin'); + const buffer = Buffer.from('Binary data'); + + await writeFile(filePath, buffer); + const result = await readFileWithRetry(filePath); + + expect(Buffer.isBuffer(result)).toBe(true); + expect(result).toEqual(buffer); + }); + }); + + describe('retry logic', () => { + it('should read file with retry enabled', async () => { + const filePath = path.join(TEST_DIR, 'read-retry.txt'); + const content = 'Retry content'; + await writeFile(filePath, content, 'utf-8'); + + const result = await readFileWithRetry(filePath, { encoding: 'utf-8', retryDelay: 10 }); + + expect(result).toBe(content); + }); + + it('should handle different retry options for reads', async () => { + const filePath = path.join(TEST_DIR, 'read-options.txt'); + const content = 'Options test'; + await writeFile(filePath, content, 'utf-8'); + + const result = await readFileWithRetry(filePath, { + encoding: 'utf-8', + maxRetries: 5, + retryDelay: 50, + }); + + expect(result).toBe(content); + }); + + it('should throw error for non-existent file after retries', async () => { + const filePath = path.join(TEST_DIR, 'non-existent.txt'); + + await expect( + readFileWithRetry(filePath, { maxRetries: 2, retryDelay: 10 }) + ).rejects.toThrow(AtomicFileError); + }); + }); +}); + +describe('writeJsonAtomic', () => { + beforeEach(async () => { + if (existsSync(TEST_DIR)) { + await rm(TEST_DIR, { recursive: true, force: true }); + } + await mkdir(TEST_DIR, { recursive: true }); + }); + + afterEach(async () => { + if (existsSync(TEST_DIR)) { + await rm(TEST_DIR, { recursive: true, force: true }); + } + }); + + describe('JSON operations', () => { + it('should write JSON data atomically', async () => { + const filePath = path.join(TEST_DIR, 'data.json'); + const data = { key: 'value', nested: { prop: 123 } }; + + await writeJsonAtomic(filePath, data); + + const result = JSON.parse(await readFile(filePath, 'utf-8')); + expect(result).toEqual(data); + }); + + it('should use default indent of 2 spaces', async () => { + const filePath = path.join(TEST_DIR, 'indented.json'); + const data = { key: 'value' }; + + await writeJsonAtomic(filePath, data); + + const content = await readFile(filePath, 'utf-8'); + expect(content).toContain(' "key"'); // 2 spaces + }); + + it('should respect custom indent option', async () => { + const filePath = path.join(TEST_DIR, 'custom-indent.json'); + const data = { key: 'value' }; + + await writeJsonAtomic(filePath, data, { indent: 4 }); + + const content = await readFile(filePath, 'utf-8'); + expect(content).toContain(' "key"'); // 4 spaces + }); + + it('should handle complex nested objects', async () => { + const filePath = path.join(TEST_DIR, 'complex.json'); + const data = { + string: 'text', + number: 42, + boolean: true, + null: null, + array: [1, 2, 3], + nested: { deep: { value: 'deep' } }, + }; + + await writeJsonAtomic(filePath, data); + + const result = JSON.parse(await readFile(filePath, 'utf-8')); + expect(result).toEqual(data); + }); + + it('should handle arrays', async () => { + const filePath = path.join(TEST_DIR, 'array.json'); + const data = [1, 2, 3, { key: 'value' }]; + + await writeJsonAtomic(filePath, data); + + const result = JSON.parse(await readFile(filePath, 'utf-8')); + expect(result).toEqual(data); + }); + }); +}); + +describe('writeJsonWithRetry', () => { + beforeEach(async () => { + if (existsSync(TEST_DIR)) { + await rm(TEST_DIR, { recursive: true, force: true }); + } + await mkdir(TEST_DIR, { recursive: true }); + }); + + afterEach(async () => { + if (existsSync(TEST_DIR)) { + await rm(TEST_DIR, { recursive: true, force: true }); + } + }); + + describe('JSON operations with retry', () => { + it('should write JSON data with retry logic', async () => { + const filePath = path.join(TEST_DIR, 'retry.json'); + const data = { status: 'success' }; + + await writeJsonWithRetry(filePath, data); + + const result = JSON.parse(await readFile(filePath, 'utf-8')); + expect(result).toEqual(data); + }); + + it('should write complex JSON with retry enabled', async () => { + const filePath = path.join(TEST_DIR, 'complex-retry.json'); + const data = { + nested: { deep: { value: 'test' } }, + array: [1, 2, 3], + boolean: true, + }; + + await writeJsonWithRetry(filePath, data, { maxRetries: 3, retryDelay: 10 }); + + const result = JSON.parse(await readFile(filePath, 'utf-8')); + expect(result).toEqual(data); + }); + + it('should use custom indent for JSON formatting', async () => { + const filePath = path.join(TEST_DIR, 'json-indent.json'); + const data = { formatted: true }; + + await writeJsonWithRetry(filePath, data, { indent: 4 }); + + const content = await readFile(filePath, 'utf-8'); + expect(content).toContain(' "formatted"'); // 4 spaces + }); + + it('should respect all retry options', async () => { + const filePath = path.join(TEST_DIR, 'json-options.json'); + const data = { options: 'test' }; + + await writeJsonWithRetry(filePath, data, { + indent: 2, + maxRetries: 5, + retryDelay: 50, + }); + + const result = JSON.parse(await readFile(filePath, 'utf-8')); + expect(result).toEqual(data); + }); + }); +}); + +describe('AtomicFileError', () => { + it('should be an instance of Error', () => { + const error = new AtomicFileError('Test error'); + expect(error).toBeInstanceOf(Error); + }); + + it('should have correct name', () => { + const error = new AtomicFileError('Test error'); + expect(error.name).toBe('AtomicFileError'); + }); + + it('should preserve error message', () => { + const message = 'Custom error message'; + const error = new AtomicFileError(message); + expect(error.message).toBe(message); + }); +}); diff --git a/apps/frontend/src/main/utils/__tests__/debounce.test.ts b/apps/frontend/src/main/utils/__tests__/debounce.test.ts new file mode 100644 index 00000000..4d6ab3eb --- /dev/null +++ b/apps/frontend/src/main/utils/__tests__/debounce.test.ts @@ -0,0 +1,188 @@ +/** + * Tests for debounce utility - leading/trailing edge debouncing with cancel support. + */ + +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import { debounce } from '../debounce'; + +describe('debounce', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('trailing-only mode (default)', () => { + it('should invoke after wait period', () => { + const spy = vi.fn(); + const { fn } = debounce(spy, 300); + + fn(); + expect(spy).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(300); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should only invoke once for rapid calls', () => { + const spy = vi.fn(); + const { fn } = debounce(spy, 300); + + fn('a'); + fn('b'); + fn('c'); + + vi.advanceTimersByTime(300); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('c'); + }); + + it('should reset timer on each call', () => { + const spy = vi.fn(); + const { fn } = debounce(spy, 300); + + fn(); + vi.advanceTimersByTime(200); + fn(); + vi.advanceTimersByTime(200); + + expect(spy).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(100); + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + describe('leading-only mode', () => { + it('should invoke immediately on first call', () => { + const spy = vi.fn(); + const { fn } = debounce(spy, 300, { leading: true, trailing: false }); + + fn(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should not invoke again during wait period', () => { + const spy = vi.fn(); + const { fn } = debounce(spy, 300, { leading: true, trailing: false }); + + fn(); + fn(); + fn(); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should invoke again after wait period expires (new burst)', () => { + const spy = vi.fn(); + const { fn } = debounce(spy, 300, { leading: true, trailing: false }); + + fn('first'); + expect(spy).toHaveBeenCalledTimes(1); + + // Wait for the debounce period to expire + vi.advanceTimersByTime(300); + + // New burst should trigger leading edge again + fn('second'); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenLastCalledWith('second'); + }); + }); + + describe('leading + trailing mode', () => { + it('should invoke immediately on first call (leading edge)', () => { + const spy = vi.fn(); + const { fn } = debounce(spy, 300, { leading: true, trailing: true }); + + fn('first'); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('first'); + }); + + it('should NOT double-invoke for a single call', () => { + const spy = vi.fn(); + const { fn } = debounce(spy, 300, { leading: true, trailing: true }); + + fn('only'); + expect(spy).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(300); + // Should still be 1 - no trailing invocation for a single call + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should invoke trailing edge when additional calls occur', () => { + const spy = vi.fn(); + const { fn } = debounce(spy, 300, { leading: true, trailing: true }); + + fn('first'); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('first'); + + fn('second'); + fn('third'); + + vi.advanceTimersByTime(300); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenLastCalledWith('third'); + }); + }); + + describe('cancel', () => { + it('should cancel pending trailing invocation', () => { + const spy = vi.fn(); + const { fn, cancel } = debounce(spy, 300); + + fn(); + cancel(); + + vi.advanceTimersByTime(300); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should allow new calls after cancel', () => { + const spy = vi.fn(); + const { fn, cancel } = debounce(spy, 300); + + fn('first'); + cancel(); + + fn('second'); + vi.advanceTimersByTime(300); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('second'); + }); + + it('should cancel pending trailing in leading+trailing mode', () => { + const spy = vi.fn(); + const { fn, cancel } = debounce(spy, 300, { leading: true, trailing: true }); + + fn('leading'); + expect(spy).toHaveBeenCalledTimes(1); + + fn('trailing'); + cancel(); + + vi.advanceTimersByTime(300); + // Only the leading call should have fired + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + describe('argument preservation', () => { + it('should pass the latest arguments to trailing invocation', () => { + const spy = vi.fn(); + const { fn } = debounce(spy, 300); + + fn(1, 'a'); + fn(2, 'b'); + fn(3, 'c'); + + vi.advanceTimersByTime(300); + expect(spy).toHaveBeenCalledWith(3, 'c'); + }); + }); +}); diff --git a/apps/frontend/src/main/utils/atomic-file.ts b/apps/frontend/src/main/utils/atomic-file.ts new file mode 100644 index 00000000..d3d8329f --- /dev/null +++ b/apps/frontend/src/main/utils/atomic-file.ts @@ -0,0 +1,263 @@ +/** + * Atomic File Operations + * ====================== + * + * Utilities for atomic file writes to prevent corruption. + * + * Uses temp file + fs.rename() pattern which is atomic on POSIX systems + * and atomic on Windows when source and destination are on the same volume. + * + * Usage: + * import { writeFileAtomic, writeFileWithRetry } from './atomic-file'; + * + * await writeFileAtomic('/path/to/file.json', JSON.stringify(data)); + * await writeFileWithRetry('/path/to/file.json', JSON.stringify(data)); + */ + +import { mkdir, rename, unlink, writeFile, readFile } from 'fs/promises'; +import { existsSync } from 'fs'; +import path from 'path'; +import { randomBytes } from 'crypto'; + +/** Error codes for transient filesystem errors that are safe to retry */ +const TRANSIENT_ERROR_CODES = ['EBUSY', 'EACCES', 'EAGAIN', 'EPERM', 'EMFILE', 'ENFILE'] as const; + +export class AtomicFileError extends Error { + constructor(message: string) { + super(message); + this.name = 'AtomicFileError'; + } +} + +/** + * Write data to file atomically using temp file and rename. + * + * This prevents file corruption by: + * 1. Writing to a temporary file first + * 2. Only replacing the target file if the write succeeds + * 3. Using fs.rename() for atomicity + * + * @param filepath - Target file path + * @param data - Data to write (string or Buffer) + * @param options - Write options (encoding, mode, etc.) + * + * @example + * await writeFileAtomic('/path/to/file.json', JSON.stringify(data)); + */ +export async function writeFileAtomic( + filepath: string, + data: string | Buffer, + options?: { encoding?: BufferEncoding; mode?: number } +): Promise { + const absolutePath = path.resolve(filepath); + const dir = path.dirname(absolutePath); + const filename = path.basename(absolutePath); + + // Ensure directory exists + await mkdir(dir, { recursive: true }); + + // Create temp file in same directory for atomic rename + const tempSuffix = randomBytes(8).toString('hex'); + const tempPath = path.join(dir, `.${filename}.tmp.${tempSuffix}`); + + try { + // Write to temp file + await writeFile(tempPath, data, { + encoding: options?.encoding, + mode: options?.mode, + }); + + // Atomic replace - only happens if write succeeded + await rename(tempPath, absolutePath); + } catch (error) { + // Clean up temp file on error + try { + if (existsSync(tempPath)) { + await unlink(tempPath); + } + } catch (cleanupError) { + // Best-effort cleanup, log but don't mask original error + console.warn(`Failed to cleanup temp file ${tempPath}:`, cleanupError); + } + throw error; + } +} + +/** + * Write data to file atomically with retry logic. + * + * Retries on transient errors like EBUSY, EACCES, EAGAIN. + * + * @param filepath - Target file path + * @param data - Data to write (string or Buffer) + * @param options - Write and retry options + * + * @example + * await writeFileWithRetry('/path/to/file.json', JSON.stringify(data), { + * maxRetries: 5, + * retryDelay: 100 + * }); + */ +export async function writeFileWithRetry( + filepath: string, + data: string | Buffer, + options?: { + encoding?: BufferEncoding; + mode?: number; + maxRetries?: number; + retryDelay?: number; + } +): Promise { + const maxRetries = options?.maxRetries ?? 3; + const retryDelay = options?.retryDelay ?? 100; + + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + await writeFileAtomic(filepath, data, { + encoding: options?.encoding, + mode: options?.mode, + }); + return; // Success + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + lastError = nodeError; + + // Check if this is a transient error we should retry + const isTransient = nodeError.code && (TRANSIENT_ERROR_CODES as readonly string[]).includes(nodeError.code); + + if (!isTransient || attempt === maxRetries) { + // Not transient or out of retries - throw + throw new AtomicFileError( + `Failed to write file ${filepath} after ${attempt + 1} attempts: ${nodeError.message}` + ); + } + + // Wait before retry with exponential backoff + const delay = retryDelay * 2 ** attempt; + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + // Should never reach here, but TypeScript doesn't know that + throw lastError || new AtomicFileError(`Failed to write file ${filepath}`); +} + +/** + * Read file with retry logic. + * + * Retries on transient errors like EBUSY, EACCES, EAGAIN. + * + * @param filepath - File path to read + * @param options - Read and retry options + * @returns File contents + * + * @example + * const data = await readFileWithRetry('/path/to/file.json', { + * encoding: 'utf-8', + * maxRetries: 5 + * }); + */ +export async function readFileWithRetry( + filepath: string, + options?: { + encoding?: BufferEncoding; + maxRetries?: number; + retryDelay?: number; + } +): Promise { + const maxRetries = options?.maxRetries ?? 3; + const retryDelay = options?.retryDelay ?? 100; + + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const data = await readFile(filepath, { encoding: options?.encoding }); + return data; + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + lastError = nodeError; + + // Check if this is a transient error we should retry + const isTransient = nodeError.code && (TRANSIENT_ERROR_CODES as readonly string[]).includes(nodeError.code); + + if (!isTransient || attempt === maxRetries) { + // Not transient or out of retries - throw + throw new AtomicFileError( + `Failed to read file ${filepath} after ${attempt + 1} attempts: ${nodeError.message}` + ); + } + + // Wait before retry with exponential backoff + const delay = retryDelay * 2 ** attempt; + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + // Should never reach here, but TypeScript doesn't know that + throw lastError || new AtomicFileError(`Failed to read file ${filepath}`); +} + +/** + * Write JSON data to file atomically. + * + * Convenience wrapper around writeFileAtomic for JSON data. + * + * @param filepath - Target file path + * @param data - Data to serialize as JSON + * @param options - JSON formatting and write options + * + * @example + * await writeJsonAtomic('/path/to/file.json', { key: 'value' }); + */ +export async function writeJsonAtomic( + filepath: string, + data: unknown, + options?: { + indent?: number; + mode?: number; + } +): Promise { + const indent = options?.indent ?? 2; + const jsonString = JSON.stringify(data, null, indent); + await writeFileAtomic(filepath, jsonString, { + encoding: 'utf-8', + mode: options?.mode, + }); +} + +/** + * Write JSON data to file atomically with retry logic. + * + * Convenience wrapper around writeFileWithRetry for JSON data. + * + * @param filepath - Target file path + * @param data - Data to serialize as JSON + * @param options - JSON formatting, write, and retry options + * + * @example + * await writeJsonWithRetry('/path/to/file.json', { key: 'value' }, { + * maxRetries: 5 + * }); + */ +export async function writeJsonWithRetry( + filepath: string, + data: unknown, + options?: { + indent?: number; + mode?: number; + maxRetries?: number; + retryDelay?: number; + } +): Promise { + const indent = options?.indent ?? 2; + const jsonString = JSON.stringify(data, null, indent); + await writeFileWithRetry(filepath, jsonString, { + encoding: 'utf-8', + mode: options?.mode, + maxRetries: options?.maxRetries, + retryDelay: options?.retryDelay, + }); +} diff --git a/apps/frontend/src/main/utils/debounce.ts b/apps/frontend/src/main/utils/debounce.ts new file mode 100644 index 00000000..1aba617f --- /dev/null +++ b/apps/frontend/src/main/utils/debounce.ts @@ -0,0 +1,96 @@ +/** + * Debounce utility with leading and trailing edge support + * + * Creates a debounced function that delays invoking `fn` until after `wait` milliseconds + * have elapsed since the last time it was invoked. + * + * @param fn - The function to debounce + * @param wait - The number of milliseconds to delay + * @param options - Configuration options + * @param options.leading - Invoke on the leading edge of the timeout (default: false) + * @param options.trailing - Invoke on the trailing edge of the timeout (default: true) + * @returns An object with the debounced function and a cancel method + * + * @example + * // Leading + trailing: execute immediately and after delay + * const { fn, cancel } = debounce(saveData, 300, { leading: true, trailing: true }); + * fn(); // Executes immediately + * fn(); // Schedules for 300ms later + * fn(); // Reschedules for 300ms later (only final call executes) + * + * @example + * // Trailing only (default): execute only after delay + * const { fn, cancel } = debounce(saveData, 300); + * fn(); // Schedules for 300ms later + * fn(); // Reschedules for 300ms later + */ +export function debounce( + fn: (...args: TArgs) => TReturn, + wait: number, + options: { leading?: boolean; trailing?: boolean } = {} +): { fn: (...args: TArgs) => void; cancel: () => void } { + const { leading = false, trailing = true } = options; + + let timeoutId: NodeJS.Timeout | null = null; + let lastCallTime: number | null = null; + let hasTrailingArgs = false; + + const invokeFunc = (args: TArgs) => { + fn(...args); + }; + + const debouncedFn = (...args: TArgs): void => { + const now = Date.now(); + const isFirstCall = lastCallTime === null; + + lastCallTime = now; + + // Clear existing timeout + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + + // Leading edge: invoke immediately on first call + if (leading && isFirstCall) { + invokeFunc(args); + hasTrailingArgs = false; + } else { + // Mark that there are pending args for trailing invocation + hasTrailingArgs = true; + } + + // Trailing edge: schedule invocation after wait period + if (trailing) { + timeoutId = setTimeout(() => { + // Only invoke trailing if there were calls after the leading invocation + if (hasTrailingArgs) { + invokeFunc(args); + } + lastCallTime = null; + timeoutId = null; + hasTrailingArgs = false; + }, wait); + } else if (leading) { + // Leading-only: schedule state reset so next burst triggers leading edge again + timeoutId = setTimeout(() => { + lastCallTime = null; + timeoutId = null; + }, wait); + } else { + // Reset state if neither leading nor trailing + lastCallTime = null; + } + }; + + const cancel = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + lastCallTime = null; + hasTrailingArgs = false; + }; + + return { fn: debouncedFn, cancel }; +}