3 Commits

Author SHA1 Message Date
dependabot[bot]
9fa1bc04f4 build(deps-dev): Bump tmp from 0.2.5 to 0.2.7
Bumps [tmp](https://github.com/raszi/node-tmp) from 0.2.5 to 0.2.7.
- [Changelog](https://github.com/raszi/node-tmp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/raszi/node-tmp/compare/v0.2.5...v0.2.7)

---
updated-dependencies:
- dependency-name: tmp
  dependency-version: 0.2.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 03:24:48 +00:00
aa1b61cfb3 chore: 2.0.3 버전 범프
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 22:14:57 +09:00
7209486a71 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>
2026-05-20 22:06:46 +09:00
5 changed files with 148 additions and 69 deletions

1
.gitignore vendored
View File

@@ -210,3 +210,4 @@ dist-web
.ionide
# End of https://www.toptal.com/developers/gitignore/api/macos,git,visualstudiocode,node
.gstack/

49
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@pieroot/nfd2nfc",
"version": "2.0.2",
"version": "2.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@pieroot/nfd2nfc",
"version": "2.0.2",
"version": "2.0.3",
"license": "MIT",
"dependencies": {
"chokidar": "^3.6.0",
@@ -1640,9 +1640,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1657,9 +1654,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1674,9 +1668,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1691,9 +1682,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1708,9 +1696,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1725,9 +1710,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1742,9 +1724,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1759,9 +1738,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1776,9 +1752,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1793,9 +1766,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1810,9 +1780,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1827,9 +1794,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1844,9 +1808,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6975,9 +6936,9 @@
}
},
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"dev": true,
"license": "MIT",
"engines": {

View File

@@ -1,6 +1,6 @@
{
"name": "@pieroot/nfd2nfc",
"version": "2.0.2",
"version": "2.0.3",
"description": "macOS 한글 파일명 NFD→NFC 실시간 변환 트레이 앱 + CLI + 라이브러리",
"main": "out/lib/index.js",
"types": "out/lib/index.d.ts",

View File

@@ -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<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
mockWatcher = Object.assign(new EventEmitter(), { close: vi.fn().mockResolvedValue(undefined) });
vi.mocked(chokidar.watch).mockReturnValue(mockWatcher as unknown as ReturnType<typeof chokidar.watch>);
});
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();
});
});

View File

@@ -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) {