From f8802bb86aa4949120dceb7c6aba4d06c6d452cc Mon Sep 17 00:00:00 2001 From: Penguin Date: Mon, 21 Sep 2026 11:52:35 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=ED=81=B0=20blob=EC=9D=B4=20?= =?UTF-8?q?=EC=84=9E=EC=9D=B8=20diff=EA=B0=80=2045=EC=B4=88=20=EB=92=A4=20?= =?UTF-8?q?503=EC=9C=BC=EB=A1=9C=20=EB=96=A8=EC=96=B4=EC=A7=80=EB=8D=98=20?= =?UTF-8?q?=EA=B2=83=EC=9D=84=20=EA=B3=A0=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit showBytes가 Bun `$`로 `git show`를 부르고 있었는데, Bun 1.3.x의 `$`는 64KB 파이프 버퍼를 넘는 stdout을 받는 호출이 겹치면 자식이 이미 끝났는데도 promise가 영영 settle하지 않는다. getDiffFiles의 8-way 버스트가 정확히 그 모양이라, 큰 파일이 섞인 diff는 매번 45초 flight 타임아웃 → 503이 되고 클라이언트는 1회 재시도 후 "Failed to load diff."에서 멈췄다(실측: raycast-extensions의 556파일 diff가 매번 503/45s). - showBytes를 Bun.spawn(argv, stdout pipe, stderr ignore)으로 바꾼다. 동작은 `$ … 2>/dev/null` + .nothrow()와 같다 — 종료 코드를 보지 않고 stdout만 읽고, 스폰 자체가 실패하면(cwd 삭제) 둘 다 throw한다. - 트리거 실측: 200KB 파일 12개를 8-way로 읽으면 첫 라운드에 죽고, 60KB는 27,000 스폰에도 멀쩡하다. 외부 프로세스 경합은 필요 없다(예전 진단 정정). 1.3.12·1.3.14 재현, 업스트림은 1.4.0에서 수정. Bun.spawn은 1.3.12에서 수천 번 돌려도 행 0. - 자가 치유 3부품은 그대로 둔다: 64KB를 넘을 수 있는 단발 `$`가 남아 있다 (큰 diff의 name-status, ls-files --others, 지문의 status -uall). 회귀망: diff-large-blob.test.ts — 200KB × 16파일을 3회 연속 읽어 settle과 바이트 동일성을 단언한다. 행업 판별력은 1.3.x에서만 있다(CI의 setup-bun은 버전 미지정이라 최신을 쓰고, 거기선 `$`도 통과). 내용 단언은 버전 무관 (첫 청크만 읽는 뮤테이션 → 200009 기대, 98304 수신으로 실패). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK --- CLAUDE.md | 2 +- apps/viewer/__tests__/diff-large-blob.test.ts | 84 +++++++++++++++++++ apps/viewer/server/diff.ts | 18 +++- 3 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 apps/viewer/__tests__/diff-large-blob.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 2963777..d1a497a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,7 +132,7 @@ cd scripts/parity && python3 -m http.server 8099 # http://127.0.0.1:8099/index.h - **CodeView 인스턴스 수명 — 재생성은 `!codeView`일 때만**: `renderPatch`가 CodeView를 새로 만드는 건 첫 렌더와 빈 상태 복귀뿐이다. unified↔split 전환은 살아 있는 인스턴스에 `setOptions(codeViewOptions())` → `setItems` → `render`로 태운다. 재생성하면 `diffMount.replaceChildren()`이 **스크롤 컨테이너 자신**(`#diff`가 곧 `diffMount`)을 비워 scrollHeight가 무너지고 브라우저가 scrollTop을 0으로 클램프해, 읽던 위치를 잃는다. 엔진은 `diffStyle`을 item-layout 옵션으로 취급하고(`hasItemLayoutOptionChanged`) `setOptions` 진입 즉시 `capturePendingLayoutAnchor()`로 앵커를 잡아 렌더 경로에서 `resolveAnchoredScrollTop()`으로 뷰포트를 붙든다(픽셀이 아니라 의미론적 앵커 — split은 scrollHeight가 대략 절반이라 픽셀 복원은 엉뚱한 파일에 착지한다). **호출 순서 자체가 계약이다**: `setOptions`가 `setItems`보다 먼저여야 앵커가 전환 전 레이아웃을 본다. `setOptions`가 건 인덱스 0 리셋이 뒤이은 `setItems`의 부분 리셋에 지워지지 않는 건 `markLayoutDirtyFromIndex()`가 기존 인덱스와 min을 취하기 때문이라, 순서를 뒤집으면 테스트가 못 잡는 채로 조용히 깨진다. `config.overscrollSize`를 생성 분기에서만 세팅해도 되는 이유도 여기 있다 — `setOptions`는 `config`를 건드리지 않아 이후 옵션 변경을 전부 살아남는다. 회귀망: `diffstyle-scroll.e2e.ts`(양방향 앵커 오프셋 + `data-diff-type`). - **평범한 데이터 갱신에도 픽셀 `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.12의 `$`(ShellPromise)는 `diff.ts`의 `BUILD_CONCURRENCY=8` git 서브프로세스 버스트가 외부 프로세스 생성 경합과 겹치면 resolve도 reject도 없이 영구 pending이 된다(자식은 사라지고 좀비도 없고 이벤트 루프도 정상인데 프라미스만 안 끝난다 — 실측). `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`은 클라이언트 재시도가 없어 이미지가 그냥 안 뜬다. 회귀망: `diff-server.test.ts`의 실제 HTTP 503 2종 + `self-heal.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는 멀쩡하다. 그 버스트의 주인인 `showBytes`는 그래서 `$`가 아니라 **`Bun.spawn`**이다. **`$`로 되돌리지 말 것** — 회귀망 `diff-large-blob.test.ts`는 행업을 1.3.x에서만 잡는다(CI의 `setup-bun`은 버전 미지정이라 최신을 쓰고, 거기선 `$`도 통과한다). 아래 세 부품은 남은 `$`(출력이 64KB를 넘을 수 있는 단발 호출 — 큰 diff의 `git diff --name-status`, `ls-files --others`, 지문의 `status -uall`)를 위해 그대로 둔다. `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`은 클라이언트 재시도가 없어 이미지가 그냥 안 뜬다. 회귀망: `diff-server.test.ts`의 실제 HTTP 503 2종 + `self-heal.e2e.ts`. - **`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` 분기는 한때 `
No changes.
`를 **동기로** 써 놓고, `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개가 그 상태였다). 새 분기를 넣을 때 커버리지 초록을 증거로 받지 말고, **일부러 그 분기로 들어가는 테스트**를 따로 둘 것. diff --git a/apps/viewer/__tests__/diff-large-blob.test.ts b/apps/viewer/__tests__/diff-large-blob.test.ts new file mode 100644 index 0000000..9bcb744 --- /dev/null +++ b/apps/viewer/__tests__/diff-large-blob.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { $ } from "bun"; +import { getDiffFiles } from "../server/diff.ts"; + +/** + * 64KB 파이프 버퍼를 넘는 old 쪽 blob 여럿을 한 번에 읽는 경로의 회귀망. + * + * Bun 1.3.x(1.3.12·1.3.14 실측)의 `$`는 64KB를 넘는 stdout을 받는 호출이 + * 겹치면 자식이 이미 끝났는데도 promise가 영영 settle하지 않는다. + * `getDiffFiles`의 8-way `showBytes` 버스트가 정확히 그 모양이라 큰 diff의 + * `/api/diff`가 45초 flight 타임아웃 → 503으로 떨어졌다(실측: 556파일 리포에서 + * 매번). 200KB 파일 12개면 첫 호출에서 죽고, 60KB(버퍼 미만)는 멀쩡하다. + * + * **판별력은 Bun 버전에 달렸다**: 업스트림이 1.4.0에서 고쳐, 1.4 이상에서는 + * `showBytes`를 `$`로 되돌려도 이 테스트가 통과한다(CI의 setup-bun은 버전 + * 미지정이라 최신을 쓴다). 1.3.x에서는 되돌리면 타임아웃으로 죽는다. 내용 + * 단언은 버전과 무관하게 큰 blob을 끝까지 읽는지를 지킨다. + */ + +const FILES = 16; +const LINES = 2000; // 100바이트 × 2000줄 = 200KB — 64KB 버퍼의 세 배 +const ROUNDS = 3; +const SETTLE_MS = 10_000; + +const original = (name: string): string => + `${name}\n${`${"x".repeat(99)}\n`.repeat(LINES)}`; + +let repo: string; + +beforeEach(async () => { + repo = mkdtempSync(join(tmpdir(), "cc-large-blob-")); + await $`git -C ${repo} init -q`; + await $`git -C ${repo} config user.email t@t.co`; + await $`git -C ${repo} config user.name test`; + for (let i = 0; i < FILES; i++) { + const name = `big${i}.txt`; + writeFileSync(join(repo, name), original(name)); + } + await $`git -C ${repo} add -A`; + await $`git -C ${repo} commit -qm base`; + for (let i = 0; i < FILES; i++) { + const name = `big${i}.txt`; + writeFileSync(join(repo, name), `${original(name)}tail\n`); + } +}); + +afterEach(() => { + rmSync(repo, { recursive: true, force: true }); +}); + +const settleWithin = async (work: Promise, ms: number): Promise => { + let timer: ReturnType | undefined; + const deadline = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`getDiffFiles가 ${ms}ms 안에 settle하지 않았다`)), + ms, + ); + }); + try { + return await Promise.race([work, deadline]); + } finally { + clearTimeout(timer); + } +}; + +test( + "reads many >64KB old-side blobs to the end, repeatedly, without hanging", + async () => { + for (let round = 0; round < ROUNDS; round++) { + const files = await settleWithin(getDiffFiles(repo), SETTLE_MS); + expect(files).toHaveLength(FILES); + for (const f of files) { + expect(f.status).toBe("modified"); + expect(f.oldContents.length).toBe(f.name.length + 1 + 100 * LINES); + expect(f.oldContents).toBe(original(f.name)); + expect(f.newContents).toBe(`${original(f.name)}tail\n`); + } + } + }, + ROUNDS * SETTLE_MS + 5_000, +); diff --git a/apps/viewer/server/diff.ts b/apps/viewer/server/diff.ts index aea9a7f..7f5da4f 100644 --- a/apps/viewer/server/diff.ts +++ b/apps/viewer/server/diff.ts @@ -132,14 +132,26 @@ export interface DiffFile { // Uint8Array로 명시: fetch Response body(BodyInit)는 // SharedArrayBuffer 기반 뷰를 받지 않으므로 넓은 ArrayBufferLike면 안 된다. +// +// `$`가 아니라 `Bun.spawn`이다. Bun 1.3.x의 `$`는 64KB를 넘는 stdout을 받는 +// 호출이 겹치면 자식이 이미 끝났는데도 promise가 영영 settle하지 않는다 +// (1.3.12·1.3.14 실측, 업스트림은 1.4.0에서 수정). 여기가 `getDiffFiles`의 +// 8-way 버스트라 큰 blob이 섞인 diff는 통째로 45초 flight 타임아웃 → 503이 +// 됐다. 같은 작업을 `Bun.spawn`으로는 수천 번 돌려도 걸리지 않았다. +// 동작은 `$ … 2>/dev/null` + `.nothrow()`와 같다: 종료 코드를 보지 않고 +// stdout만 읽고(없는 rev:path는 빈 바이트), 스폰 자체가 실패하면(cwd 삭제) +// 둘 다 throw한다. 회귀망: `diff-large-blob.test.ts`. const showBytes = async ( repo: string, rev: string, path: string, ): Promise> => { - const buf = await $`git -C ${repo} show ${`${rev}:${path}`} 2>/dev/null` - .nothrow() - .arrayBuffer(); + const proc = Bun.spawn(["git", "-C", repo, "show", `${rev}:${path}`], { + stdout: "pipe", + stderr: "ignore", + }); + const buf = await new Response(proc.stdout).arrayBuffer(); + await proc.exited; return new Uint8Array(buf); }; From ccf1ae7ef17a460ce9e852476aaf62e7b94b705a Mon Sep 17 00:00:00 2001 From: Penguin Date: Mon, 21 Sep 2026 12:00:08 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=E2=80=94=20never-settle=20=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=EA=B1=B0=20=EC=84=9C=EC=88=A0=EC=9D=84=20=EC=A0=95=EC=A0=95?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=EB=82=A8=EC=9D=80=20`$`=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=84=20=EC=B1=84=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - singleFlight.ts 머리 주석이 여전히 "외부 프로세스 생성 경합과 겹치면"이라는 기각된 원인을 말하고 있었다. 이 모듈의 존재 이유가 그 행이라, CLAUDE.md만 고치면 두 곳이 서로 다른 원인을 주장한다. - "호출이 겹치면"은 트리거를 너무 좁게 말했다. 완전 순차(limit=1)도 200KB × 32를 14라운드째에 걸었다 — 겹침은 확률만 올린다. 좁게 적어 두면 남은 단발 `$`를 안전하다고 읽고 자가 치유를 걷어낼 근거가 된다. diff.ts 주석·테스트 docblock·CLAUDE.md 세 곳을 같은 서술로 맞춘다. - CLAUDE.md의 남은 `$` 목록에 refs.ts의 for-each-ref와 flight 밖에서 도는 summary.ts의 diff --name-only·ls-files --others를 더하고 예시임을 밝힌다. 주석·문서만 바뀌고 동작 변경은 없다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK --- CLAUDE.md | 2 +- apps/viewer/__tests__/diff-large-blob.test.ts | 5 +++-- apps/viewer/server/diff.ts | 9 +++++---- apps/viewer/server/singleFlight.ts | 14 ++++++++------ 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d1a497a..9d03d1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,7 +132,7 @@ cd scripts/parity && python3 -m http.server 8099 # http://127.0.0.1:8099/index.h - **CodeView 인스턴스 수명 — 재생성은 `!codeView`일 때만**: `renderPatch`가 CodeView를 새로 만드는 건 첫 렌더와 빈 상태 복귀뿐이다. unified↔split 전환은 살아 있는 인스턴스에 `setOptions(codeViewOptions())` → `setItems` → `render`로 태운다. 재생성하면 `diffMount.replaceChildren()`이 **스크롤 컨테이너 자신**(`#diff`가 곧 `diffMount`)을 비워 scrollHeight가 무너지고 브라우저가 scrollTop을 0으로 클램프해, 읽던 위치를 잃는다. 엔진은 `diffStyle`을 item-layout 옵션으로 취급하고(`hasItemLayoutOptionChanged`) `setOptions` 진입 즉시 `capturePendingLayoutAnchor()`로 앵커를 잡아 렌더 경로에서 `resolveAnchoredScrollTop()`으로 뷰포트를 붙든다(픽셀이 아니라 의미론적 앵커 — split은 scrollHeight가 대략 절반이라 픽셀 복원은 엉뚱한 파일에 착지한다). **호출 순서 자체가 계약이다**: `setOptions`가 `setItems`보다 먼저여야 앵커가 전환 전 레이아웃을 본다. `setOptions`가 건 인덱스 0 리셋이 뒤이은 `setItems`의 부분 리셋에 지워지지 않는 건 `markLayoutDirtyFromIndex()`가 기존 인덱스와 min을 취하기 때문이라, 순서를 뒤집으면 테스트가 못 잡는 채로 조용히 깨진다. `config.overscrollSize`를 생성 분기에서만 세팅해도 되는 이유도 여기 있다 — `setOptions`는 `config`를 건드리지 않아 이후 옵션 변경을 전부 살아남는다. 회귀망: `diffstyle-scroll.e2e.ts`(양방향 앵커 오프셋 + `data-diff-type`). - **평범한 데이터 갱신에도 픽셀 `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는 멀쩡하다. 그 버스트의 주인인 `showBytes`는 그래서 `$`가 아니라 **`Bun.spawn`**이다. **`$`로 되돌리지 말 것** — 회귀망 `diff-large-blob.test.ts`는 행업을 1.3.x에서만 잡는다(CI의 `setup-bun`은 버전 미지정이라 최신을 쓰고, 거기선 `$`도 통과한다). 아래 세 부품은 남은 `$`(출력이 64KB를 넘을 수 있는 단발 호출 — 큰 diff의 `git diff --name-status`, `ls-files --others`, 지문의 `status -uall`)를 위해 그대로 둔다. `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`은 클라이언트 재시도가 없어 이미지가 그냥 안 뜬다. 회귀망: `diff-server.test.ts`의 실제 HTTP 503 2종 + `self-heal.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는 멀쩡하다. 그 버스트의 주인인 `showBytes`는 그래서 `$`가 아니라 **`Bun.spawn`**이다. **`$`로 되돌리지 말 것** — 회귀망 `diff-large-blob.test.ts`는 행업을 1.3.x에서만 잡는다(CI의 `setup-bun`은 버전 미지정이라 최신을 쓰고, 거기선 `$`도 통과한다). 아래 세 부품은 남은 `$`(출력이 64KB를 넘을 수 있는 단발 호출 — 예: 큰 diff의 `git diff --name-status`, `ls-files --others`, 지문의 `status -uall`, 원격 브랜치가 수천 개인 리포의 `refs.ts` `for-each-ref`, 그리고 flight 밖에서 도는 `summary.ts`의 `diff --name-only`·`ls-files --others`)를 위해 그대로 둔다. `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`은 클라이언트 재시도가 없어 이미지가 그냥 안 뜬다. 회귀망: `diff-server.test.ts`의 실제 HTTP 503 2종 + `self-heal.e2e.ts`. - **`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` 분기는 한때 `
No changes.
`를 **동기로** 써 놓고, `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개가 그 상태였다). 새 분기를 넣을 때 커버리지 초록을 증거로 받지 말고, **일부러 그 분기로 들어가는 테스트**를 따로 둘 것. diff --git a/apps/viewer/__tests__/diff-large-blob.test.ts b/apps/viewer/__tests__/diff-large-blob.test.ts index 9bcb744..7b6f15d 100644 --- a/apps/viewer/__tests__/diff-large-blob.test.ts +++ b/apps/viewer/__tests__/diff-large-blob.test.ts @@ -8,8 +8,9 @@ import { getDiffFiles } from "../server/diff.ts"; /** * 64KB 파이프 버퍼를 넘는 old 쪽 blob 여럿을 한 번에 읽는 경로의 회귀망. * - * Bun 1.3.x(1.3.12·1.3.14 실측)의 `$`는 64KB를 넘는 stdout을 받는 호출이 - * 겹치면 자식이 이미 끝났는데도 promise가 영영 settle하지 않는다. + * Bun 1.3.x(1.3.12·1.3.14 실측)의 `$`는 64KB를 넘는 stdout을 받는 호출에서 + * 자식이 이미 끝났는데도 promise가 영영 settle하지 않을 수 있다 — 겹치면 + * 거의 확정이고, 완전 순차여도 결국 걸린다(200KB × 32를 하나씩 읽어 14라운드째). * `getDiffFiles`의 8-way `showBytes` 버스트가 정확히 그 모양이라 큰 diff의 * `/api/diff`가 45초 flight 타임아웃 → 503으로 떨어졌다(실측: 556파일 리포에서 * 매번). 200KB 파일 12개면 첫 호출에서 죽고, 60KB(버퍼 미만)는 멀쩡하다. diff --git a/apps/viewer/server/diff.ts b/apps/viewer/server/diff.ts index 7f5da4f..7a5511c 100644 --- a/apps/viewer/server/diff.ts +++ b/apps/viewer/server/diff.ts @@ -134,10 +134,11 @@ export interface DiffFile { // SharedArrayBuffer 기반 뷰를 받지 않으므로 넓은 ArrayBufferLike면 안 된다. // // `$`가 아니라 `Bun.spawn`이다. Bun 1.3.x의 `$`는 64KB를 넘는 stdout을 받는 -// 호출이 겹치면 자식이 이미 끝났는데도 promise가 영영 settle하지 않는다 -// (1.3.12·1.3.14 실측, 업스트림은 1.4.0에서 수정). 여기가 `getDiffFiles`의 -// 8-way 버스트라 큰 blob이 섞인 diff는 통째로 45초 flight 타임아웃 → 503이 -// 됐다. 같은 작업을 `Bun.spawn`으로는 수천 번 돌려도 걸리지 않았다. +// 호출에서 자식이 이미 끝났는데도 promise가 영영 settle하지 않을 수 있다 — +// 호출이 겹치면 거의 확정이고 완전 순차여도 결국 걸린다(1.3.12·1.3.14 실측, +// 업스트림은 1.4.0에서 수정). 여기가 `getDiffFiles`의 8-way 버스트라 큰 blob이 +// 섞인 diff는 통째로 45초 flight 타임아웃 → 503이 됐다. 같은 작업을 +// `Bun.spawn`으로는 수천 번 돌려도 걸리지 않았다. // 동작은 `$ … 2>/dev/null` + `.nothrow()`와 같다: 종료 코드를 보지 않고 // stdout만 읽고(없는 rev:path는 빈 바이트), 스폰 자체가 실패하면(cwd 삭제) // 둘 다 throw한다. 회귀망: `diff-large-blob.test.ts`. diff --git a/apps/viewer/server/singleFlight.ts b/apps/viewer/server/singleFlight.ts index 5bae96d..7b4f9a0 100644 --- a/apps/viewer/server/singleFlight.ts +++ b/apps/viewer/server/singleFlight.ts @@ -3,12 +3,14 @@ * 콜드 상태에서 프리워밍·첫 화면·watch 폴이 겹쳐도 diff 파이프라인(파일당 * git 서브프로세스)과 base 해석(gh pr view)이 중복 실행되지 않게 한다. * - * fn()이 settle하지 않는 경로가 실측됐다 — Bun 1.3.12의 `$`(ShellPromise)가, - * diff.ts의 BUILD_CONCURRENCY=8 git 서브프로세스 버스트가 외부 프로세스 생성 - * 경합과 겹치면 resolve도 reject도 없이 영구히 pending 상태가 된다(자식은 - * 시스템에서 사라지고 좀비도 없고 이벤트 루프도 정상인데 프라미스만 안 - * 끝난다). ShellPromise엔 `.timeout()`/`.kill()`이 없어 Promise.race가 유일한 - * 레버다. 타임아웃이 뜨면 이 슬롯을 reject해 `.finally()`가 키를 지우게 + * fn()이 settle하지 않는 경로가 실측됐다 — Bun 1.3.x의 `$`(ShellPromise)는 + * 64KB를 넘는 stdout을 받는 호출에서 resolve도 reject도 없이 영구히 pending + * 상태가 될 수 있다(겹치면 거의 확정이고 순차여도 결국 걸린다, 업스트림은 + * 1.4.0에서 수정). 자식은 시스템에서 사라지고 좀비도 없고 이벤트 루프도 + * 정상인데 프라미스만 안 끝난다. 그 버스트의 주인이던 diff.ts `showBytes`는 + * `Bun.spawn`으로 옮겼지만 출력이 클 수 있는 `$`가 남아 있어 이 타임아웃은 + * 그대로 필요하다. ShellPromise엔 `.timeout()`/`.kill()`이 없어 Promise.race가 + * 유일한 레버다. 타임아웃이 뜨면 이 슬롯을 reject해 `.finally()`가 키를 지우게 * 하고, 그래야 "다음" 호출이 죽은 프라미스에 합류하지 않고 새로 시작한다 — * 버려진 원래 fn()은 백그라운드에서 계속 pending인 채로 남지만 더는 아무도 * 기다리지 않는다. From 7c8a778f97cb4723fb165d574479e4cb1c45f9fa Mon Sep 17 00:00:00 2001 From: Penguin Date: Mon, 21 Sep 2026 13:32:34 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=E2=80=94=20=EC=9E=90=EA=B0=80=20=EC=B9=98=EC=9C=A0?= =?UTF-8?q?=EA=B0=80=20=EB=8B=BF=EB=8A=94=20=EB=B2=94=EC=9C=84=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=94=EB=A1=9C=EC=9E=A1=EA=B3=A0=20=EC=83=88=20git=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EA=B7=9C=EC=B9=99=EC=9D=84=20=EC=A0=81?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 직전 커밋이 "세 부품이 남은 `$`를 지킨다"며 summary.ts까지 목록에 넣었는데, summary.ts는 flight 밖이라 셋 다 닿지 않는다 — 같은 문단의 "덮지 않는 곳"과 모순이었다. 닿는 범위를 호출별로 가른다: diffFlight 안(①②③), refsFlight 안(①②, ③ 재시도는 /api/diff 전용), flight 밖(없음). - "새 git 호출의 출력이 64KB를 넘을 수 있으면 `$`가 아니라 Bun.spawn"이라는 규칙을 리포에 적는다. 지금까지 개인 메모에만 있어 다른 기여자는 showBytes를 되돌리지 말라는 것만 알 수 있었다. - "덮지 않는 곳"의 /api/blob 서술에 단서를 단다: getFileBytes의 blob 읽기는 이제 showBytes라 이미지 크기로는 걸리지 않고, 남은 `$`는 출력이 작은 merge-base뿐이다. 문서만 바뀐다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9d03d1d..e1350a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,7 +132,7 @@ cd scripts/parity && python3 -m http.server 8099 # http://127.0.0.1:8099/index.h - **CodeView 인스턴스 수명 — 재생성은 `!codeView`일 때만**: `renderPatch`가 CodeView를 새로 만드는 건 첫 렌더와 빈 상태 복귀뿐이다. unified↔split 전환은 살아 있는 인스턴스에 `setOptions(codeViewOptions())` → `setItems` → `render`로 태운다. 재생성하면 `diffMount.replaceChildren()`이 **스크롤 컨테이너 자신**(`#diff`가 곧 `diffMount`)을 비워 scrollHeight가 무너지고 브라우저가 scrollTop을 0으로 클램프해, 읽던 위치를 잃는다. 엔진은 `diffStyle`을 item-layout 옵션으로 취급하고(`hasItemLayoutOptionChanged`) `setOptions` 진입 즉시 `capturePendingLayoutAnchor()`로 앵커를 잡아 렌더 경로에서 `resolveAnchoredScrollTop()`으로 뷰포트를 붙든다(픽셀이 아니라 의미론적 앵커 — split은 scrollHeight가 대략 절반이라 픽셀 복원은 엉뚱한 파일에 착지한다). **호출 순서 자체가 계약이다**: `setOptions`가 `setItems`보다 먼저여야 앵커가 전환 전 레이아웃을 본다. `setOptions`가 건 인덱스 0 리셋이 뒤이은 `setItems`의 부분 리셋에 지워지지 않는 건 `markLayoutDirtyFromIndex()`가 기존 인덱스와 min을 취하기 때문이라, 순서를 뒤집으면 테스트가 못 잡는 채로 조용히 깨진다. `config.overscrollSize`를 생성 분기에서만 세팅해도 되는 이유도 여기 있다 — `setOptions`는 `config`를 건드리지 않아 이후 옵션 변경을 전부 살아남는다. 회귀망: `diffstyle-scroll.e2e.ts`(양방향 앵커 오프셋 + `data-diff-type`). - **평범한 데이터 갱신에도 픽셀 `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는 멀쩡하다. 그 버스트의 주인인 `showBytes`는 그래서 `$`가 아니라 **`Bun.spawn`**이다. **`$`로 되돌리지 말 것** — 회귀망 `diff-large-blob.test.ts`는 행업을 1.3.x에서만 잡는다(CI의 `setup-bun`은 버전 미지정이라 최신을 쓰고, 거기선 `$`도 통과한다). 아래 세 부품은 남은 `$`(출력이 64KB를 넘을 수 있는 단발 호출 — 예: 큰 diff의 `git diff --name-status`, `ls-files --others`, 지문의 `status -uall`, 원격 브랜치가 수천 개인 리포의 `refs.ts` `for-each-ref`, 그리고 flight 밖에서 도는 `summary.ts`의 `diff --name-only`·`ls-files --others`)를 위해 그대로 둔다. `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`은 클라이언트 재시도가 없어 이미지가 그냥 안 뜬다. 회귀망: `diff-server.test.ts`의 실제 HTTP 503 2종 + `self-heal.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는 멀쩡하다. 그 버스트의 주인인 `showBytes`는 그래서 `$`가 아니라 **`Bun.spawn`**이다. **`$`로 되돌리지 말 것** — 회귀망 `diff-large-blob.test.ts`는 행업을 1.3.x에서만 잡는다(CI의 `setup-bun`은 버전 미지정이라 최신을 쓰고, 거기선 `$`도 통과한다). 출력이 64KB를 넘을 수 있는 `$`는 아직 남아 있고, 그래서 아래 세 부품은 그대로 둔다 — 예: `diffFlight` 안의 큰 diff `git diff --name-status`·`ls-files --others`·지문의 `status -uall`(①②③ 전부), `refsFlight` 안의 `refs.ts` `for-each-ref`(원격 브랜치가 수천 개인 리포 — ①②만, ③ 재시도는 `/api/diff` 전용), 그리고 flight 밖의 `summary.ts` `diff --name-only`·`ls-files --others`(**셋 다 안 닿는다** — 아래 "덮지 않는 곳"). **새 git 호출의 출력이 64KB를 넘을 수 있으면 `$`가 아니라 `Bun.spawn`으로 쓴다.** `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`은 클라이언트 재시도가 없어 이미지가 그냥 안 뜬다(단 `getFileBytes`의 blob 읽기는 `showBytes`라 이미지 크기로는 더 걸리지 않는다 — 거기 남은 `$`는 출력이 작은 `merge-base`뿐이다). 회귀망: `diff-server.test.ts`의 실제 HTTP 503 2종 + `self-heal.e2e.ts`. - **`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` 분기는 한때 `
No changes.
`를 **동기로** 써 놓고, `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개가 그 상태였다). 새 분기를 넣을 때 커버리지 초록을 증거로 받지 말고, **일부러 그 분기로 들어가는 테스트**를 따로 둘 것.