auto-claude: 197-roadmap-generation-stuck-at-50-file-locking-race-c (#1746)

* auto-claude: subtask-1-1 - Create atomic-file.ts utility with writeFileAtomic, writeFileWithRetry, and readFileWithRetry

Implements cross-platform atomic file write utilities for TypeScript:
- writeFileAtomic: temp file + rename pattern for atomic writes
- writeFileWithRetry: exponential backoff for Windows file locking errors (EACCES/EBUSY)
- readFileWithRetry: read with retry logic for transient errors
- writeJsonAtomic/writeJsonWithRetry: convenience wrappers for JSON operations

Follows pattern from apps/backend/core/file_utils.py

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-1-2 - Create unit tests for atomic file operations

- Created comprehensive test suite with 32 passing tests
- Tests cover writeFileAtomic, writeFileWithRetry, readFileWithRetry
- Tests cover writeJsonAtomic, writeJsonWithRetry
- Tests verify basic operations, retry logic, options handling
- Tests verify temp file cleanup and error handling
- Tests verify AtomicFileError custom error class
- All tests use integration-style approach to avoid ES module mocking issues

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-2-1 - Replace json.dump() in phases.py with write_json_atomic()

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* auto-claude: subtask-2-2 - Replace json.dump() in competitor_analyzer.py with write_json_atomic()

* auto-claude: subtask-2-3 - Replace json.dump() in graph_integration.py with write_json_atomic()

- Added import for write_json_atomic from core.file_utils
- Replaced all json.dump() calls in _create_disabled_hints_file(), _save_hints(), and _save_error_hints()
- Removed json module import as it's no longer needed
- All JSON writes now use atomic file operations to prevent corruption

* auto-claude: subtask-3-1 - Replace writeFileSync() in roadmap-handlers.ts with writeFileWithRetry()

* auto-claude: subtask-4-1 - Wrap persistRoadmapProgress() with debounce (300ms)

- Created debounce utility with leading + trailing edge support
- Wraps persistRoadmapProgress() with 300ms debounce
- Limits file writes to ~3-4 per second (leading: true, trailing: true)
- Ensures immediate first write and final state persistence after updates
- Reduces file system contention during rapid progress updates

* Fix file-locking race conditions and QA review findings

- Extract duplicated transientErrors to module-level TRANSIENT_ERROR_CODES constant
- Fix debounce double-invocation bug: single call with leading+trailing no longer fires twice
- Convert persistRoadmapProgress to async with writeFileWithRetry instead of writeFileSync
- Store debounce cancel handle; cancel pending writes before clearRoadmapProgress
- Replace readFileSync with readFileWithRetry in roadmap IPC handlers
- Add withFileLock mutex for read-modify-write operations (SAVE, UPDATE_FEATURE, CONVERT_TO_SPEC)
- Add comprehensive debounce.test.ts with 12 tests covering all modes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix atomic-file test failure on Windows

The 'should throw error when write fails' test used '/invalid/path/...'
which on Windows resolves to the current drive root (e.g. D:\invalid\...)
where mkdir({ recursive: true }) succeeds. Replace with a path inside a
regular file, which fails cross-platform since you can't mkdir inside a file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix leading-only debounce state reset and add retry tests

- Fix leading-only mode: schedule timeout to reset lastCallTime so
  subsequent bursts re-trigger leading edge (was 'invoke once ever')
- Add test verifying leading-only mode fires again after wait expires
- Add atomic-file-retry.test.ts with mocked transient error tests:
  EBUSY retry + succeed, EACCES exhaust retries, EAGAIN read retry,
  ENOENT non-transient immediate failure
- Add afterEach(vi.useRealTimers) to debounce tests to prevent leaks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-02-09 10:23:32 +01:00
committed by GitHub
parent bdff9141af
commit f41f15e592
10 changed files with 1320 additions and 127 deletions
@@ -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)
@@ -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(),
},
)
+2 -2
View File
@@ -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",
+35 -10
View File
@@ -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<void> {
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,
@@ -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<string, Promise<void>>();
async function withFileLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
// Wait for any existing lock on this file
while (fileLocks.has(filepath)) {
await fileLocks.get(filepath);
}
let resolve: (() => void) | undefined;
const lockPromise = new Promise<void>((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
@@ -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<void>) | null = null;
// biome-ignore lint/suspicious/noExplicitAny: mock functions need flexible types
let readFileMockFn: ((...args: any[]) => Promise<string | Buffer>) | null = null;
vi.mock('fs/promises', async (importOriginal) => {
const original = await importOriginal<typeof import('fs/promises')>();
return {
...original,
rename: (...args: Parameters<typeof originalRename>) => {
renameCallCount++;
if (renameMockFn) return renameMockFn(...args);
return original.rename(...args);
},
readFile: (...args: Parameters<typeof originalReadFile>) => {
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<typeof import('fs/promises')>('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<typeof import('fs/promises')>('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);
});
});
@@ -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);
});
});
@@ -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');
});
});
});
+263
View File
@@ -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<void> {
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<void> {
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<string | Buffer> {
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<void> {
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<void> {
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,
});
}
+96
View File
@@ -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<TArgs extends unknown[], TReturn = void>(
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 };
}