Skip to content
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ cd scripts/parity && python3 -m http.server 8099 # http://127.0.0.1:8099/index.h
- **평범한 데이터 갱신에도 픽셀 `scrollTo`를 얹지 말 것**: refresh·`--watch` 갱신 경로는 `setItems` → `render`만 부르고 스크롤 복원을 엔진에 맡긴다. `reconcileItems`가 `markLayoutDirtyFromIndex`를 세우면 렌더 경로가 `layoutDirtyIndex != null`을 보고 scroll correction을 무조건 재무장하고, 캐시된 앵커가 없으면 `getScrollAnchor`가 `renderState`에서 새로 만들어 보정한다 — 즉 이미 의미론적으로 보존된다. 여기에 `scrollTo({type:"position"})`를 얹으면 잉여가 아니라 **해롭다**: ① `position` 타깃은 애초에 항등 복원이 아니다 — `resolveScrollTargetTop`이 클램프되지 않은 값에서 `getStickyHeaderOffset()`(= `diffHeaderHeight` 44, 우리가 `stickyHeaders: true`를 주고 `disableFileHeader`를 안 주므로)을 빼므로, 모든 refresh·watch 폴링마다 뷰포트가 44px씩 위로 밀렸다, ② 뷰포트 *위쪽* 파일 길이가 변하면 아래 내용이 통째로 밀리는데 옛 픽셀로 되돌아가 읽던 줄이 어긋난다(실측: 60줄 증가에 1244px 드리프트), ③ `scrollTo`가 세우는 `pendingScrollTarget`을 프레임이 앵커보다 우선해 적용하므로, 스타일 전환의 렌더가 아직 큐에만 있는 1프레임 창에 갱신이 겹치면(그 창에선 `renderedDiffStyle`이 이미 갱신돼 이 갱신 분기로 들어온다) 전환 전 픽셀값이 앵커를 덮어써 split 기준 거의 바닥으로 착지한다. 보정이 항상 도는 건 아니고 그럴 필요도 없다: `setItems`엔 아무것도 dirty로 안 세우는 append 전용 경로가 있고(위쪽이 안 움직였으니 보정할 게 없다), 앵커 아이템이 사라지거나 목록이 렌더 윈도우보다 작아져 `renderState`가 리셋되면 앵커 해석이 비어 돌아온다 — 그 폴백은 클램프된 현재 위치라 옛 "값 −44"보다 낫다. 회귀망: `update-anchor.e2e.ts`(위쪽 파일 증가 + rAF 게이트로 결정화한 한 프레임 레이스).
- **`#diff`에 innerHTML을 쓰기 전엔 살아 있는 CodeView가 없는지 확인**: 위와 같은 이유로 이 노드는 CodeView가 `setup()`에서 자기 컨테이너를 append한 스크롤 컨테이너라, 덮어쓰면 그 컨테이너가 문서에서 떨어져 나간다. `CodeView.setup()`은 이미 setup된 인스턴스의 재부착을 거부하므로(`already setup`) 인스턴스를 새로 만들기 전까지 패널이 영구히 빈 채로 남는다. 그래서 `load()`의 실패 카드는 `!codeView`로 가드한다 — `!lastFiles`가 아니다: 변경 없는 리포는 `lastFiles === []`(truthy)지만 `teardownViews()`로 이미 codeView가 비워진 뒤라 카드를 쓰는 게 안전하고, `!lastFiles`로 걸면 그 경우에 상태 라벨만 실패를 말하고 화면은 "No changes."를 계속 주장한다. 회귀망: `load-failure.e2e.ts`.
- **"Loading…" 자가 치유 — 세 부분이 함께여야 동작한다.** Bun 1.3.x의 `$`(ShellPromise)는 **64KB 파이프 버퍼를 넘는 stdout을 받는 호출에서** resolve도 reject도 없이 영구 pending이 될 수 있다 — 호출이 겹치면 거의 확정이고 완전 순차여도 결국 걸린다(자식은 사라지고 좀비도 없고 이벤트 루프도 정상인데 프라미스만 안 끝난다 — 실측; 1.3.12·1.3.14 재현, 업스트림은 1.4.0에서 수정). 예전엔 외부 프로세스 생성 경합이 필요하다고 봤지만 아니었다 — 200KB 파일 12개를 `BUILD_CONCURRENCY=8`로 읽으면 첫 호출에 죽고, 60KB는 멀쩡하다. 그래서 출력이 64KB를 넘을 수 있는 서버의 git 호출은 전부 `$`가 아니라 **`gitOutput.ts`의 `gitBytes`/`gitText`(`Bun.spawn`)**를 탄다 — `showBytes`(파일별 버스트), `summary.ts`, `getDiffFiles`의 `diff --name-status`·`ls-files --others`, 지문의 `status`, `refs.ts`의 `for-each-ref`·`worktree list`. 남은 `$`는 `rev-parse`·`merge-base`·`gh pr view`처럼 출력 크기가 리포 규모와 무관한 호출뿐이다. 크기는 필요조건일 뿐이다 — 같은 크기라도 호출에 따라 안 멈추기도 한다(`worktree list` 110KB, `for-each-ref` 606KB는 한 번도 안 멈췄다). **`$`로 되돌리지 말 것** — 회귀망 `diff-large-blob.test.ts`·`git-output.test.ts`·`git-large-output.test.ts`는 행업을 1.3.x에서만 잡으므로(최신 Bun에선 `$`도 통과한다) CI의 `test-bun13` 잡이 셋을 Bun 1.3.14로 고정해 돌린다. **다만 호출처마다 판별력이 다르다.** `git-large-output.test.ts`는 호출처마다 그 함수를 8번 동시에 불러 출력을 겹치게 만드는데, 지문·`name-status`·`ls-files`는 되돌리면 첫 라운드에 확실히 죽지만(각 3/3 — `ls-files` 케이스는 `name-status`도 거치므로 그걸 되돌려도 함께 죽는다) `for-each-ref`는 좁은 구간(참조 600~800개 ≈ 180~240KB)에서만 멈춰 8-way로는 20라운드를 돌려도 5번 중 3번만 잡혔고, 그 케이스만 16-way로 올려 11번 중 11번 잡는다(macOS). `worktree list`는 옮겼지만 멈춤을 재현하지 못해(죽은 워크트리 400개로 110KB, 8·16-way 10라운드씩 8번 무사) 테스트가 지키지 않는다. `summary.ts`는 순차 호출이라 아예 결정론적으로 재현되지 않아(437KB 출력에서 한 번은 18번째 호출에 걸리고 다른 한 번은 150회 무사했다) **그 파일이 `$`로 돌아가는 것은 테스트가 못 잡는다** — 헬퍼 자체의 행업만 `git-output.test.ts`가 잡는다(전부 실측). 큰 출력의 `$`는 이제 없지만 아래 세 부품은 그대로 둔다 — flight 타임아웃은 원인을 가리지 않는 안전망이고(예: `baseFlight` 안의 `gh pr view`는 네트워크를 기다린다), 남은 작은 `$`가 원리적으로 안전하다는 증명은 없다(60KB는 멀쩡했다는 관측뿐이다). **새 git 호출의 출력이 64KB를 넘을 수 있으면 `$`가 아니라 `gitBytes`/`gitText`로 쓴다.** `ShellPromise`엔 `.timeout()`/`.kill()`이 없어 `Promise.race`가 유일한 레버다. 예전엔 `singleFlight`가 키를 `.finally()`에서만 지워 그 키가 **영구 오염**되고 이후 모든 요청이 죽은 프라미스에 합류했다. 지금은 ① `singleFlight`가 flight를 타임아웃과 race해 키를 풀고(`SingleFlightTimeoutError`로 호출자가 타임아웃을 구분한다), ② `awaitFlight`가 **타임아웃만** 503+`Retry-After`로 흡수하며(그 외 에러는 다시 던져 기존 동작 보존), ③ `browser/main.ts`의 `fetchDiff`가 503·네트워크 실패를 1회 재시도한다(403·400은 terminal). **따로 넣으면 어느 쪽도 동작하지 않는다** — 키를 안 풀고 재시도하면 같은 죽은 프라미스에 다시 합류한다. **상수 제약은 per-flight가 아니라 합이다**: `/api/diff`가 `resolveBaseCached` → `diffFlight`를 순차로 두 번 기다리므로 45+45=90 < `idleTimeout` 120(슬랙 30초). 재시도가 1회인 이유도 `BUILD_CONCURRENCY=8`이 호출당이라(전역 세마포어 아님) 시도가 겹치면 동시 git 서브프로세스가 배로 늘어 재시도가 스스로를 느리게 만들기 때문이다. **덮지 않는 곳**: `isGitRepo`는 flight 앞에서, `getRepoSummary`·`getFileBytes`는 뒤에서 돌고 flight로 안 감싸였다 — 거기서 매달리면 예전 실패 모드 그대로 `idleTimeout`이 소켓을 닫으며, `/api/blob`은 클라이언트 재시도가 없어 이미지가 그냥 안 뜬다(단 둘의 큰 출력 읽기는 이제 `gitText`·`showBytes`라 출력 크기로는 더 걸리지 않는다 — `getRepoSummary`·`getFileBytes`에 남은 `$`는 둘 다 출력이 작은 `merge-base`(`resolveDiffBaseRev`)뿐이다). 회귀망: `diff-server.test.ts`의 실제 HTTP 503 2종 + `self-heal.e2e.ts`.
- **변경 폴의 old 쪽은 blob OID 캐시가 든다(`server/blobCache.ts`).** watch에서 한 파일만 바뀌어도 지문이 바뀌면 `getDiffFiles`가 diff 전체를 다시 만드는데, 파일마다 old 쪽을 `git show`로 다시 읽던 것이 변경 폴 비용의 대부분이었다(176파일 픽스처에서 230ms 중 176ms — 실측). 그래서 목록을 `git diff --raw -z --no-abbrev`로 뽑아 파일마다 blob OID를 얻고(`parseRawZ`), 그 OID를 키로 old 쪽(과 head 모드의 new 쪽) 바이트를 재사용한다. **계약 넷**: ① 키는 **전체 OID**다 — 이름(`HEAD`·브랜치)으로 잡으면 커밋 직후 같은 이름이 다른 내용을 가리켜 옛 내용이 나오고, `--full-index`는 여기서 7자 약어를 준다(실측). 키가 내용의 해시라 무효화 로직이 없다(`git show <rev>:<path>`의 출력은 OID만의 함수다 — textconv·`eol`·필터가 걸린 경로에서도 실측 동일). ② **`git show`의 종료 코드가 0일 때만 저장한다**(`gitRun`) — 잠깐 못 읽은 빈 결과를 저장하면 영구히 눌러앉는다. 이 가드가 실제로 지키는 곳은 목록이 blob을 읽지 않는 **head 모드**다: 워킹트리와 비교하는 `git diff <rev>`는 old blob이 없으면 목록 단계에서 먼저 `fatal: unable to read`로 죽는다(실측 — 그래서 회귀 테스트도 head 모드에서 재현한다). ③ **OID는 캐시 키로만** 쓴다 — old 쪽을 읽을지는 지금처럼 상태가 정한다. ④ 워킹트리 모드의 new 쪽은 캐시하지 않는다(디스크가 진실이고 전부 읽어도 4.3ms). 캐시는 `createHandler`마다 하나(prewarm과 `/api/diff`가 공유), 상한 64MB LRU(556파일 diff의 양쪽 합이 44.5MB였다). payload 캐시·지문·etag는 그대로다. 회귀망: `diff-blob-cache.test.ts` 4종(이름 키·실패 저장 뮤테이션을 각각 정확히 한 테스트가 잡는다) + `diff-raw.test.ts`(실제 git 출력 리터럴) + `blob-cache.test.ts` + `diff-server.test.ts`의 배선 2종(워킹트리·base 모드 호출을 따로 — 한 모드만 보면 다른 호출에서 캐시를 빼도 초록이다).
- **`server.ts`의 `baseCache`는 반드시 모듈 스코프에 남아야 한다** (`diffCache`와 달리 `createHandler` 안으로 옮기지 마라). `diff-server.test.ts`의 "diffFlight 타임아웃" 테스트가 기본 타임아웃 서버로 캐시를 데운 뒤 **별도로 새로 띄운** `flightTimeoutMs:1` 서버가 그 warm 항목을 그대로 봐야만, `baseFlight`가 마이크로태스크로 1ms 레이스를 이기고 제어가 `diffFlight`까지 도달한다. 옮기면(구조적 격리라는 그럴듯한 이유로 그럴 수 있다) 두 번째 서버가 빈 캐시로 시작해 `baseFlight`가 miss로 되돌아가고, 그 테스트는 **조용히** 첫 번째 테스트와 똑같은 `baseFlight` 가드만 다시 증명한다 — 실패가 아니라 무증상 퇴화다. 두 테스트가 **정반대 조건**(하나는 캐시 미스, 하나는 히트)에 의존하므로 공용 픽스처로 합치지도 마라.
- **빈 상태의 문구는 `/api/summary`가 와야 정해진다 — 먼저 그리면 말을 바꾼다.** `renderPatch`의 `files.length === 0` 분기는 한때 `<div id="empty">No changes.</div>`를 **동기로** 써 놓고, `enrichEmptyState()`가 요약을 받아 온 뒤 정보형 카드로 덮었다. 그 사이가 실측 60~80ms라 사용자에게는 "없다"고 한 번 말한 뒤 말을 바꾸는 것으로 보인다(실측: 첫 로드 673ms `No changes.` → 753ms `No tracked changes …`; head를 고르면 282ms → 340ms). **자동 base 전환이 걸리는 경우가 가장 나쁘다** — 볼 것이 있는데도 없다고 말한 뒤 diff가 뜬다. 그래서 지금은 자리만 잡고(`LOADING_MARKUP` — `load()`의 첫 로드 표시와 **같은 마크업이어야 한다**: 다른 것을 그리면 로딩에서 로딩으로 넘어가는 자리에서 한 번 더 깜박인다) 문구는 `enrichEmptyState`가 한 번만 쓴다. `No changes.`는 이제 **폴백**이고 요약 fetch가 실패했을 때만 나온다 — 그때 `data-loading`을 함께 걷어내지 않으면 화면이 영원히 로딩이다(노드 동일성은 유지해 marker 가드가 계속 맞게 둔다). **이 종류는 최종 상태로는 원리적으로 안 보인다**(끝나고 나면 옳은 카드가 떠 있다) — 회귀망은 `addInitScript`로 첫 페인트 전에 MutationObserver를 걸어 `#empty`가 거쳐 간 문구를 전부 기록한다(`empty-state.e2e.ts` ⑥⑦, 폴백은 ⑧이 `/api/summary`를 route abort로 막아 찌른다). 그 관찰자는 `document.documentElement`가 아니라 **`document`**를 봐야 한다 — document-start에는 `documentElement`가 아직 없어 기록이 통째로 빈다(실측). 뮤테이션으로 판별력 확인: 옛 동기 문구를 되돌리면 ⑥⑦만, 폴백 write를 지우면 ⑧만 죽는다.
- **커버리지 100%가 무엇을 뜻하지 않는지 알 것.** `bunfig.toml`의 게이트는 line·function·statement만 세고 **branch를 세지 않는다.** 그래서 `if (x instanceof Response) return x;` 같은 한 줄 가드는 `if`가 실행되기만 하면 covered로 찍히고, 그 `return`이 한 번도 안 나가도 100%가 유지된다(실제로 503 반환 경로 4개가 그 상태였다). 새 분기를 넣을 때 커버리지 초록을 증거로 받지 말고, **일부러 그 분기로 들어가는 테스트**를 따로 둘 것.
Expand Down
58 changes: 58 additions & 0 deletions apps/viewer/__tests__/blob-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { expect, test } from "bun:test";
import {
DEFAULT_BLOB_CACHE_BYTES,
createBlobCache,
} from "../server/blobCache.ts";

const bytes = (n: number): Uint8Array<ArrayBuffer> => new Uint8Array(n);

test("returns what was stored and counts hits and misses", () => {
const cache = createBlobCache({ maxBytes: 100 });
cache.set("a", bytes(10));
expect(cache.get("a")?.byteLength).toBe(10);
expect(cache.get("b")).toBeUndefined();
expect(cache.stats()).toEqual({ hits: 1, misses: 1, bytes: 10, entries: 1 });
});

test("stores an empty blob (a zero-length hit is still a hit)", () => {
const cache = createBlobCache({ maxBytes: 100 });
cache.set("empty", bytes(0));
expect(cache.get("empty")?.byteLength).toBe(0);
expect(cache.stats().hits).toBe(1);
});

test("evicts the least recently used entries once the byte total exceeds the cap", () => {
const cache = createBlobCache({ maxBytes: 100 });
cache.set("a", bytes(40));
cache.set("b", bytes(40));
cache.get("a"); // a가 최근 — 다음 퇴출 대상은 b
cache.set("c", bytes(40)); // 120 > 100 → b를 버려 80
expect(cache.get("b")).toBeUndefined();
expect(cache.get("a")?.byteLength).toBe(40);
expect(cache.get("c")?.byteLength).toBe(40);
expect(cache.stats()).toMatchObject({ bytes: 80, entries: 2 });
});

test("does not store an entry larger than the cap, and keeps what it had", () => {
const cache = createBlobCache({ maxBytes: 100 });
cache.set("a", bytes(40));
cache.set("huge", bytes(101));
expect(cache.get("huge")).toBeUndefined();
expect(cache.get("a")?.byteLength).toBe(40);
expect(cache.stats()).toMatchObject({ bytes: 40, entries: 1 });
});

test("overwriting a key replaces its bytes instead of counting them twice", () => {
const cache = createBlobCache({ maxBytes: 100 });
cache.set("a", bytes(40));
cache.set("a", bytes(30));
expect(cache.stats()).toMatchObject({ bytes: 30, entries: 1 });
});

test("defaults to a 64MB cap", () => {
const cache = createBlobCache();
cache.set("at-cap", bytes(DEFAULT_BLOB_CACHE_BYTES));
cache.set("over-cap", bytes(DEFAULT_BLOB_CACHE_BYTES + 1));
expect(cache.stats().entries).toBe(1);
expect(DEFAULT_BLOB_CACHE_BYTES).toBe(64 * 1024 * 1024);
});
Loading
Loading