Skip to content

Commit dd67221

Browse files
committed
워처가 변경 목록을 말없이 자르던 것을 고친다
`if (touched.size < MAX_TOUCHED) touched.add(...)` 한 줄이었다. 상한(2000)을 넘으면 경로를 조용히 버렸다. 앱 자신은 멀쩡하다 — 새로고침 때 트리를 통째로 다시 읽으니까. 손해는 확장의 파일 감시자가 본다. 잘려 나간 파일은 확장에게 "아무 일 없었다" 가 되고, 확장은 자기가 전부 안다고 여긴다. touchSet.cjs 로 자루를 옮긴다. 상한을 20000 으로 올리고, 그래도 넘치면 overflow 를 알림에 실어 보낸다. 렌더러는 그 신호를 받으면 이름 목록으로 좁히지 않고 앞뒤 트리 전체를 비교해 판정한다 — 잘려 나간 파일이 조용히 누락되지 않게. 트리까지 잘린 경우에만 정말 모르는 채 넘어가고 그때는 남긴다. 전제가 반쯤 틀렸다는 것도 적어 둔다. 파일 2500개를 한 번에 만들어 확인해 보니 워처 알림에 이름이 180개 남짓만 왔다. 우리 상한에 닿기 한참 전에 fs.watch 자체가 이벤트를 흘린다(윈도우 recursive 워처의 커널 버퍼). 실제 손실의 대부분은 거기서 일어난다. 그래서 이 수정이 보장하는 것은 "우리가 조용히 자르지 않는다" 까지고, "이름이 다 온다" 는 OS 가 정한다. 다행히 앱은 이미 견딘다. syncFromDisk 가 트리를 다시 읽고 classify 가 전체 비교로 만들어짐·지워짐을 잡는다. 놓치는 것은 changed 뿐이고, 그걸 메우려면 내용 해시나 폴링 대체 경로가 필요하다 — 별건으로 남긴다. 단위 9개 추가(981개 통과). 실제 앱 검증 3/3.
1 parent 1c6b7e1 commit dd67221

7 files changed

Lines changed: 153 additions & 9 deletions

File tree

ide/electron/main.cjs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1447,6 +1447,7 @@ ipcMain.on("schutz:termReconcile", (e, ids) => {
14471447
});
14481448

14491449
// ── 파일 워처 (외부 변경 감지) ──────────────────────────────────────────────
1450+
const touchSet = require("./touchSet.cjs");
14501451
const watchers = new Map(); // webContents.id → { watcher, timer }
14511452

14521453
ipcMain.on("schutz:watchStart", (e, root) => {
@@ -1461,15 +1462,15 @@ ipcMain.on("schutz:watchStart", (e, root) => {
14611462
// 어떤 파일이 움직였는지 모아 둔다. fs.watch 는 처음부터 이름을 주는데, 예전엔
14621463
// 무시 규칙에만 쓰고 버려서 렌더러에는 "뭔가 바뀌었다" 는 빈 신호만 갔다.
14631464
// 그래서 확장의 파일 감시자를 만들 수가 없었다.
1464-
let touched = new Set();
1465-
const MAX_TOUCHED = 2000; // 대량 변경(브랜치 전환)에서 무한히 쌓이지 않게
1465+
// 상한은 있되 넘치면 넘쳤다고 말한다 — touchSet.cjs 에 사연이 있다.
1466+
const touched = touchSet.makeTouchSet();
14661467
try {
14671468
const watcher = fs.watch ? require("fs").watch(root, { recursive: true }, (_type, filename) => {
14681469
if (filename) {
14691470
const parts = String(filename).replace(/\\/g, "/").split("/");
14701471
if (parts.some(seg => IGNORE_DIRS.has(seg))) return; // node_modules/.git 등 무시
14711472
if (parts[parts.length - 1].endsWith(".schutz-tmp")) return; // 우리가 방금 만든 임시 파일 — 새로고침을 유발할 이유가 없다
1472-
if (touched.size < MAX_TOUCHED) touched.add(String(filename).replace(/\\/g, "/"));
1473+
touched.add(filename);
14731474
}
14741475
dirty = true;
14751476
const cur = watchers.get(wid);
@@ -1478,9 +1479,10 @@ ipcMain.on("schutz:watchStart", (e, root) => {
14781479
cur.timer = setTimeout(() => {
14791480
if (dirty && !e.sender.isDestroyed()) {
14801481
dirty = false;
1481-
const rels = [...touched];
1482-
touched = new Set();
1483-
e.sender.send("schutz:fsChange", rels);
1482+
const d = touched.drain();
1483+
// overflow 를 함께 보낸다. 받는 쪽이 "이 목록이 전부" 라고 믿으면 안 되는
1484+
// 경우를 알아야, 개별 이름 대신 트리 비교로 판정할 수 있다.
1485+
e.sender.send("schutz:fsChange", d.rels, d.overflow);
14841486
}
14851487
}, 350);
14861488
}

ide/electron/preload.cjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ contextBridge.exposeInMainWorld("schutz", {
5252
onFsChange: (cb) => {
5353
// 바뀐 파일들의 상대 경로를 함께 넘긴다. 예전엔 인자 없이 불러서, 무엇이
5454
// 움직였는지 아는 쪽(메인)의 정보가 렌더러에 닿지 않았다.
55-
const h = (_e, rels) => cb(Array.isArray(rels) ? rels : []);
55+
// overflow=true 면 이 목록이 전부가 아니다(상한에 걸려 이름이 잘렸다).
56+
// 받는 쪽은 개별 이름 대신 트리 비교로 판정해야 한다.
57+
const h = (_e, rels, overflow) => cb(Array.isArray(rels) ? rels : [], !!overflow);
5658
ipcRenderer.on("schutz:fsChange", h);
5759
return () => ipcRenderer.removeListener("schutz:fsChange", h);
5860
},

ide/electron/touchSet.cjs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// 워처가 알려 준 경로를 한 번의 알림으로 모으는 자루.
2+
//
3+
// 예전엔 `if (touched.size < MAX_TOUCHED) touched.add(...)` 한 줄이었다. 상한을 넘으면
4+
// **말없이 버렸다.** 앱 자신은 멀쩡하다(새로고침 때 트리를 통째로 다시 읽으니까).
5+
// 손해는 확장의 파일 감시자가 본다 — 브랜치를 갈아타 파일 3000개가 바뀌면 확장은
6+
// 앞의 것만 통보받고 나머지는 영영 모른 채, 자기가 다 안다고 여긴다.
7+
//
8+
// 두 가지를 고친다. 상한을 실제 저장소 크기에 맞게 올리고(2000 → 20000), 그래도
9+
// 넘치면 넘쳤다고 말한다. 조용히 자르는 것만은 안 한다.
10+
11+
const DEFAULT_MAX = 20000;
12+
13+
function makeTouchSet(max = DEFAULT_MAX) {
14+
let set = new Set();
15+
let dropped = 0;
16+
return {
17+
add(rel) {
18+
const r = String(rel || "").replace(/\\/g, "/");
19+
if (!r) return;
20+
if (set.has(r)) return;
21+
if (set.size >= max) { dropped++; return; }
22+
set.add(r);
23+
},
24+
get size() { return set.size; },
25+
/** 상한에 걸려 버린 경로가 있었나 — 있으면 이 알림은 전부가 아니다. */
26+
get overflowed() { return dropped > 0; },
27+
get dropped() { return dropped; },
28+
/** 모은 것을 꺼내고 자루를 비운다. */
29+
drain() {
30+
const rels = [...set];
31+
const info = { rels, overflow: dropped > 0, dropped };
32+
set = new Set();
33+
dropped = 0;
34+
return info;
35+
},
36+
};
37+
}
38+
39+
module.exports = { makeTouchSet, DEFAULT_MAX };

ide/electron/touchSet.d.cts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// touchSet.cjs 의 타입. 메인 프로세스 코드지만 판단 부분은 테스트에서 그대로 부른다.
2+
export interface TouchDrain {
3+
rels: string[];
4+
/** 상한에 걸려 버린 경로가 있었다 — 이 목록은 전부가 아니다. */
5+
overflow: boolean;
6+
dropped: number;
7+
}
8+
export interface TouchSet {
9+
add(rel: unknown): void;
10+
readonly size: number;
11+
readonly overflowed: boolean;
12+
readonly dropped: number;
13+
drain(): TouchDrain;
14+
}
15+
export declare function makeTouchSet(max?: number): TouchSet;
16+
export declare const DEFAULT_MAX: number;

ide/src/App.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4464,7 +4464,10 @@ ${(r.output || "").slice(0, 2000)}`;
44644464
private _fsTouched = new Set<string>();
44654465
/** 사라졌는데 미저장 편집이 있어 남겨 둔 파일들 — 이미 알린 것을 되풀이하지 않게. */
44664466
private _goneTold = new Set<string>();
4467-
private onFsChange = (rels?: string[]) => {
4467+
/** 이번 알림 묶음에서 워처가 이름을 다 못 준 적이 있었나. syncFromDisk 가 지우고 간다. */
4468+
private _fsOverflow = false;
4469+
private onFsChange = (rels?: string[], overflow?: boolean) => {
4470+
if (overflow) this._fsOverflow = true;
44684471
for (const r of rels ?? []) this._fsTouched.add(r);
44694472
if (this._fsTimer) clearTimeout(this._fsTimer);
44704473
this._fsTimer = setTimeout(() => void this.syncFromDisk(), 250);
@@ -4489,10 +4492,21 @@ ${(r.output || "").slice(0, 2000)}`;
44894492
// 일 수 있다. 그때 트리 전체를 비교하면 멀쩡한 파일에 지워짐을 쏜다 — 그걸 받은
44904493
// 확장은 인덱스에서 실제로 지운다. 그래서 **워처가 이름을 준 경로만** 본다.
44914494
const capped = tree.truncated || ws.truncated;
4495+
const overflow = this._fsOverflow;
4496+
this._fsOverflow = false;
44924497
const t = new Set(touched);
4498+
// 워처가 이름을 다 못 줬으면(overflow) 이름 목록으로 좁히는 것이 위험하다 —
4499+
// 잘려 나간 파일이 "아무 일 없었다" 가 되기 때문이다. 트리가 온전한 한
4500+
// 앞뒤 전체를 비교하는 쪽이 만들어짐·지워짐을 놓치지 않는다.
4501+
// 트리까지 잘렸으면(capped) 얘기가 다르다: 그때 전체를 비교하면 멀쩡한 파일에
4502+
// 지워짐을 쏘게 되므로, 덜 아는 쪽(이름 목록)을 그대로 쓴다.
44934503
const delta = capped
44944504
? fsClassify(before.filter(r => t.has(r)), after.filter(r => t.has(r)), touched)
44954505
: fsClassify(before, after, touched);
4506+
if (overflow && capped) {
4507+
// 둘 다 잘린 경우에만 정말로 모르는 채 넘어간다. 조용히 넘기지는 않는다.
4508+
console.warn("[fs] 변경 이름과 트리가 모두 상한에 걸렸습니다 — 이번 알림은 일부만 반영됩니다.");
4509+
}
44964510
if (delta.created.length || delta.changed.length || delta.deleted.length) extHost.notifyFsDelta(delta);
44974511
}
44984512
// 사라진 파일(외부 삭제·브랜치 전환)의 stale 모델·진단·문제패널 항목 정리 — 트리 완전할 때만(truncated 면 실존 파일 오삭제 위험)

ide/src/engine/touchSet.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, it, expect } from "vitest";
2+
import { makeTouchSet, DEFAULT_MAX } from "../../electron/touchSet.cjs";
3+
4+
describe("makeTouchSet", () => {
5+
it("모은 것을 그대로 돌려준다", () => {
6+
const s = makeTouchSet();
7+
s.add("a.ts"); s.add("b/c.ts");
8+
const d = s.drain();
9+
expect(d.rels).toEqual(["a.ts", "b/c.ts"]);
10+
expect(d.overflow).toBe(false);
11+
});
12+
13+
it("역슬래시를 슬래시로 맞춘다 — 윈도우 워처가 주는 모양이다", () => {
14+
const s = makeTouchSet();
15+
s.add("src\\editor\\pane.ts");
16+
expect(s.drain().rels).toEqual(["src/editor/pane.ts"]);
17+
});
18+
19+
it("같은 경로를 두 번 세지 않는다", () => {
20+
const s = makeTouchSet();
21+
s.add("a.ts"); s.add("a.ts"); s.add("a.ts");
22+
expect(s.size).toBe(1);
23+
});
24+
25+
it("빈 값은 담지 않는다", () => {
26+
const s = makeTouchSet();
27+
s.add(""); s.add(null); s.add(undefined);
28+
expect(s.size).toBe(0);
29+
expect(s.drain().overflow).toBe(false);
30+
});
31+
32+
// 이것이 이 파일의 이유다. 예전엔 상한을 넘으면 말없이 버렸고, 그 알림을 받은
33+
// 확장은 자기가 전부 안다고 여겼다.
34+
it("상한을 넘으면 버렸다고 말한다", () => {
35+
const s = makeTouchSet(3);
36+
for (const r of ["a", "b", "c", "d", "e"]) s.add(r);
37+
const d = s.drain();
38+
expect(d.rels).toEqual(["a", "b", "c"]);
39+
expect(d.overflow).toBe(true);
40+
expect(d.dropped).toBe(2);
41+
});
42+
43+
it("상한 이하면 넘쳤다고 하지 않는다", () => {
44+
const s = makeTouchSet(3);
45+
s.add("a"); s.add("b"); s.add("c");
46+
expect(s.drain().overflow).toBe(false);
47+
});
48+
49+
it("중복은 상한을 깎지 않는다", () => {
50+
const s = makeTouchSet(2);
51+
s.add("a"); s.add("a"); s.add("b");
52+
const d = s.drain();
53+
expect(d.rels).toEqual(["a", "b"]);
54+
expect(d.overflow).toBe(false);
55+
});
56+
57+
it("꺼내면 비고, 넘침 표시도 함께 지워진다", () => {
58+
const s = makeTouchSet(1);
59+
s.add("a"); s.add("b");
60+
expect(s.drain().overflow).toBe(true);
61+
const second = s.drain();
62+
expect(second.rels).toEqual([]);
63+
expect(second.overflow).toBe(false);
64+
});
65+
66+
it("기본 상한은 실제 저장소 규모를 담을 만큼 크다", () => {
67+
// 2000 은 브랜치 하나만 갈아타도 넘쳤다.
68+
expect(DEFAULT_MAX).toBeGreaterThanOrEqual(20000);
69+
});
70+
});

ide/src/schutz.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,8 @@ interface SchutzApi {
147147
watchStop(): void;
148148
/** 바뀐 파일들의 워크스페이스 상대 경로. 감시자가 이름을 못 준 변경도 있으므로
149149
* 이 목록은 완전하지 않다 — 트리 비교로 보완해서 쓴다. */
150-
onFsChange(cb: (rels: string[]) => void): () => void;
150+
/** overflow=true 면 rels 가 전부가 아니다 — 이름이 상한에서 잘렸다. */
151+
onFsChange(cb: (rels: string[], overflow?: boolean) => void): () => void;
151152
/** 저장 안 한 파일 목록을 메인에 알려 둔다 — 종료를 붙잡을지 메인이 이걸로 정한다.
152153
* 종료를 누른 뒤에 물어보면 늦다(그때는 트레이가 이미 사라진 뒤다). */
153154
reportDirty?(files: string[]): void;

0 commit comments

Comments
 (0)