diff --git a/.gitignore b/.gitignore index d69f5ad..fccd5dd 100644 --- a/.gitignore +++ b/.gitignore @@ -209,4 +209,5 @@ dist-web .history .ionide -# End of https://www.toptal.com/developers/gitignore/api/macos,git,visualstudiocode,node \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/macos,git,visualstudiocode,node +.gstack/ diff --git a/src/main/__tests__/watcher.test.ts b/src/main/__tests__/watcher.test.ts new file mode 100644 index 0000000..ffc8eb7 --- /dev/null +++ b/src/main/__tests__/watcher.test.ts @@ -0,0 +1,103 @@ +import { EventEmitter } from 'events'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +vi.mock('chokidar'); +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })); + +const mockAppendUndoEntries = vi.fn().mockResolvedValue(undefined); +vi.mock('../store', () => ({ appendUndoEntries: mockAppendUndoEntries })); + +const mockQueueRenamedNotification = vi.fn().mockResolvedValue(undefined); +vi.mock('../notifier', () => ({ + queueRenamedNotification: mockQueueRenamedNotification, + notifyManualQueue: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../nanoid', () => ({ nanoid: () => 'test-id' })); + +const mockNormalizeEntry = vi.fn(); +vi.mock('../../core/normalizer', () => ({ normalizeEntry: mockNormalizeEntry })); + +import chokidar from 'chokidar'; +import type { WatchedDir } from '../../core/types'; + +// 테스트마다 고유 경로를 사용해 모듈 레벨 recentlyRenamed 상태 충돌 방지 +function makePaths(slug: string) { + const nfc = `/test/watch/${slug}.txt`; + return { nfc, nfd: nfc.normalize('NFD') }; +} + +function makeDir(id: string): WatchedDir { + return { id, path: '/test/watch', recursive: false, mode: 'auto', enabled: true, customRanges: [] }; +} + +describe('watcher — in-flight race condition', () => { + let mockWatcher: EventEmitter & { close: ReturnType }; + + beforeEach(() => { + vi.clearAllMocks(); + mockWatcher = Object.assign(new EventEmitter(), { close: vi.fn().mockResolvedValue(undefined) }); + vi.mocked(chokidar.watch).mockReturnValue(mockWatcher as unknown as ReturnType); + }); + + afterEach(async () => { + const { stopDir } = await import('../watcher'); + // 각 테스트에서 사용한 id 정리 + for (const id of ['dir-burst', 'dir-nfc-reentry', 'dir-nfd-reentry']) { + await stopDir(id); + } + }); + + it('동일 NFD 경로에 burst add 이벤트가 와도 normalizeEntry가 1번만 호출된다', async () => { + const { nfc, nfd } = makePaths('강의자료-burst'); + mockNormalizeEntry.mockResolvedValue({ type: 'file', status: 'renamed', oldPath: nfd, newPath: nfc }); + + const { startDir } = await import('../watcher'); + await startDir(makeDir('dir-burst')); + + // 같은 경로를 동기적으로 두 번 emit — race 시나리오 재현 + mockWatcher.emit('add', nfd); + mockWatcher.emit('add', nfd); + await new Promise((r) => setTimeout(r, 0)); + + expect(mockNormalizeEntry).toHaveBeenCalledTimes(1); + expect(mockAppendUndoEntries).toHaveBeenCalledTimes(1); + }); + + it('rename 완료 후 NFC 경로로 다시 add 이벤트가 와도 recentlyRenamed 가드에 막힌다', async () => { + const { nfc, nfd } = makePaths('강의자료-nfc-reentry'); + mockNormalizeEntry.mockResolvedValue({ type: 'file', status: 'renamed', oldPath: nfd, newPath: nfc }); + + const { startDir } = await import('../watcher'); + await startDir(makeDir('dir-nfc-reentry')); + + mockWatcher.emit('add', nfd); + await new Promise((r) => setTimeout(r, 0)); + expect(mockNormalizeEntry).toHaveBeenCalledTimes(1); + + vi.clearAllMocks(); + + // RENAME_TTL(2000ms) 이내에 NFC 경로로 재진입 — 가드에 막혀야 함 + mockWatcher.emit('add', nfc); + await new Promise((r) => setTimeout(r, 0)); + expect(mockNormalizeEntry).not.toHaveBeenCalled(); + }); + + it('rename 완료 후 NFD 경로로 재진입해도 recentlyRenamed 가드에 막힌다', async () => { + const { nfc, nfd } = makePaths('강의자료-nfd-reentry'); + mockNormalizeEntry.mockResolvedValue({ type: 'file', status: 'renamed', oldPath: nfd, newPath: nfc }); + + const { startDir } = await import('../watcher'); + await startDir(makeDir('dir-nfd-reentry')); + + mockWatcher.emit('add', nfd); + await new Promise((r) => setTimeout(r, 0)); + expect(mockNormalizeEntry).toHaveBeenCalledTimes(1); + + vi.clearAllMocks(); + + mockWatcher.emit('add', nfd); + await new Promise((r) => setTimeout(r, 0)); + expect(mockNormalizeEntry).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/watcher.ts b/src/main/watcher.ts index 38833a3..23185d6 100644 --- a/src/main/watcher.ts +++ b/src/main/watcher.ts @@ -12,6 +12,7 @@ type ActivityListener = (event: ActivityEvent) => void; const watchers = new Map(); const recentlyRenamed = new Map(); +const inFlight = new Set(); const RENAME_TTL = 2000; const listeners = new Set(); const manualQueue = new Map(); @@ -51,39 +52,51 @@ export async function startDir(dir: WatchedDir): Promise { persistent: true, depth: dir.recursive ? undefined : 0, ignored: /(^|[/\\])\../, + awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 }, }); async function handlePath(filePath: string, type: 'file' | 'directory'): Promise { 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) {