mirror of
https://github.com/jung-geun/NFD2NFC.git
synced 2026-06-21 12:25:14 +09:00
fix(watcher): 한글 NFD 파일 압축 해제 시 동일 파일 반복 변환 race condition 수정
- inFlight Set으로 handlePath 진입 즉시 경로를 잠가 burst add 이벤트가 몰려올 때 첫 번째 처리가 끝나기 전 두 번째 호출을 즉시 차단 - recentlyRenamed 키를 NFC 정규화로 통일하고 oldPath/newPath 양쪽 등록 (chokidar가 NFD·NFC 변형 중 어느 쪽으로 이벤트를 보내도 TTL 가드 적용) - chokidar awaitWriteFinish(stabilityThreshold 300ms) 추가로 압축 해제 중 부분 쓰기 단계의 add 폭주를 상류에서 차단 - race condition 회귀 방지를 위한 watcher 단위 테스트 3개 추가 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ type ActivityListener = (event: ActivityEvent) => void;
|
||||
|
||||
const watchers = new Map<string, FSWatcher>();
|
||||
const recentlyRenamed = new Map<string, number>();
|
||||
const inFlight = new Set<string>();
|
||||
const RENAME_TTL = 2000;
|
||||
const listeners = new Set<ActivityListener>();
|
||||
const manualQueue = new Map<string, string[]>();
|
||||
@@ -51,39 +52,51 @@ export async function startDir(dir: WatchedDir): Promise<void> {
|
||||
persistent: true,
|
||||
depth: dir.recursive ? undefined : 0,
|
||||
ignored: /(^|[/\\])\../,
|
||||
awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 },
|
||||
});
|
||||
|
||||
async function handlePath(filePath: string, type: 'file' | 'directory'): Promise<void> {
|
||||
const now = Date.now();
|
||||
const lastRenamed = recentlyRenamed.get(filePath);
|
||||
const key = filePath.normalize('NFC');
|
||||
|
||||
// 동일 경로가 이미 처리 중이면 즉시 return (burst add 이벤트로 인한 race 차단)
|
||||
if (inFlight.has(key)) return;
|
||||
const lastRenamed = recentlyRenamed.get(key);
|
||||
if (lastRenamed && now - lastRenamed < RENAME_TTL) return;
|
||||
|
||||
const basename = filePath.split('/').pop() ?? '';
|
||||
if (!shouldNormalize(basename, filterOpts)) return;
|
||||
|
||||
if (dir.mode === 'auto') {
|
||||
const result = await normalizeEntry(filePath, type, filterOpts);
|
||||
if (result.status === 'renamed' || result.status === 'noop-same-inode') {
|
||||
recentlyRenamed.set(result.newPath, Date.now());
|
||||
if (result.status === 'renamed') {
|
||||
await store.appendUndoEntries([
|
||||
{ id: nanoid(), ts: now, oldPath: result.oldPath, newPath: result.newPath, reverted: false },
|
||||
]);
|
||||
inFlight.add(key);
|
||||
try {
|
||||
if (dir.mode === 'auto') {
|
||||
const result = await normalizeEntry(filePath, type, filterOpts);
|
||||
if (result.status === 'renamed' || result.status === 'noop-same-inode') {
|
||||
// oldPath와 newPath 모두 등록 — chokidar가 NFD/NFC 변형 중 어느 쪽으로 이벤트를 보내도 막힘
|
||||
recentlyRenamed.set(result.oldPath.normalize('NFC'), Date.now());
|
||||
recentlyRenamed.set(result.newPath.normalize('NFC'), Date.now());
|
||||
if (result.status === 'renamed') {
|
||||
await store.appendUndoEntries([
|
||||
{ id: nanoid(), ts: now, oldPath: result.oldPath, newPath: result.newPath, reverted: false },
|
||||
]);
|
||||
}
|
||||
emit({ type: 'rename', ts: now, dirId: dir.id, message: `${result.oldPath} → ${result.newPath}`, result });
|
||||
// 알림은 배치로 처리 — 인터벌마다 총 건수 발송
|
||||
await notifier.queueRenamedNotification(dir.path);
|
||||
} else if (result.status === 'collision') {
|
||||
emit({ type: 'collision', ts: now, dirId: dir.id, message: `충돌: ${result.oldPath}`, result });
|
||||
}
|
||||
emit({ type: 'rename', ts: now, dirId: dir.id, message: `${result.oldPath} → ${result.newPath}`, result });
|
||||
// 알림은 배치로 처리 — 인터벌마다 총 건수 발송
|
||||
await notifier.queueRenamedNotification(dir.path);
|
||||
} else if (result.status === 'collision') {
|
||||
emit({ type: 'collision', ts: now, dirId: dir.id, message: `충돌: ${result.oldPath}`, result });
|
||||
} else {
|
||||
const queue = manualQueue.get(dir.id) ?? [];
|
||||
if (!queue.includes(filePath)) {
|
||||
queue.push(filePath);
|
||||
manualQueue.set(dir.id, queue);
|
||||
emit({ type: 'info', ts: now, dirId: dir.id, message: `수동 대기: ${filePath}` });
|
||||
}
|
||||
await notifier.notifyManualQueue(queue.length, dir.path);
|
||||
}
|
||||
} else {
|
||||
const queue = manualQueue.get(dir.id) ?? [];
|
||||
if (!queue.includes(filePath)) {
|
||||
queue.push(filePath);
|
||||
manualQueue.set(dir.id, queue);
|
||||
emit({ type: 'info', ts: now, dirId: dir.id, message: `수동 대기: ${filePath}` });
|
||||
}
|
||||
await notifier.notifyManualQueue(queue.length, dir.path);
|
||||
} finally {
|
||||
inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +160,8 @@ export async function applyManualQueue(dirId: string, dir: WatchedDir): Promise<
|
||||
const result = await normalizeEntry(filePath, type, filterOpts);
|
||||
results.push(result);
|
||||
if (result.status === 'renamed') {
|
||||
recentlyRenamed.set(result.newPath, Date.now());
|
||||
recentlyRenamed.set(result.oldPath.normalize('NFC'), Date.now());
|
||||
recentlyRenamed.set(result.newPath.normalize('NFC'), Date.now());
|
||||
undoEntries.push({ id: nanoid(), ts: now, oldPath: result.oldPath, newPath: result.newPath, reverted: false });
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user