Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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를 넘을 수 있는 `$`는 아직 남아 있고, 그래서 아래 세 부품은 그대로 둔다 — 예: `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` 분기는 한때 `<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
85 changes: 85 additions & 0 deletions apps/viewer/__tests__/diff-large-blob.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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하지 않을 수 있다 — 겹치면
* 거의 확정이고, 완전 순차여도 결국 걸린다(200KB × 32를 하나씩 읽어 14라운드째).
* `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 <T>(work: Promise<T>, ms: number): Promise<T> => {
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<never>((_, 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,
);
19 changes: 16 additions & 3 deletions apps/viewer/server/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,27 @@ export interface DiffFile {

// Uint8Array<ArrayBuffer>로 명시: 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<Uint8Array<ArrayBuffer>> => {
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);
};

Expand Down
14 changes: 8 additions & 6 deletions apps/viewer/server/singleFlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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인 채로 남지만 더는 아무도
* 기다리지 않는다.
Expand Down
Loading