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:
@@ -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(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user