From c8997d77b51622e233ee102a3d72a017eb9448b3 Mon Sep 17 00:00:00 2001 From: Penguin Date: Mon, 21 Sep 2026 16:09:23 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EC=B6=9C=EB=A0=A5=EC=9D=B4=2064KB?= =?UTF-8?q?=EB=A5=BC=20=EB=84=98=EC=9D=84=20=EC=88=98=20=EC=9E=88=EB=8A=94?= =?UTF-8?q?=20=EB=82=98=EB=A8=B8=EC=A7=80=20git=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=EB=8F=84=20Bun.spawn=20=ED=97=AC=ED=8D=BC=EB=A1=9C=20=EC=9D=BD?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bun 1.3.x의 `$`는 64KB를 넘는 stdout을 받는 호출에서 promise가 영영 settle하지 않을 수 있다(업스트림은 1.4.0에서 수정). #74·#77이 `showBytes`와 `summary.ts`를 `gitOutput.ts` 헬퍼로 옮긴 뒤에도 큰 출력을 받는 `$`가 넷 남아 있었다: - 지문의 `status --porcelain -z -uall` — watch가 2초마다 부른다 - `getDiffFiles`의 `diff --name-status` (큰 diff) - `getDiffFiles`의 `ls-files --others` (untracked 켜짐) - `refs.ts`의 `for-each-ref` (원격 브랜치가 많은 리포) 넷 다 `gitText`로 옮긴다. 이로써 서버에 남은 `$`는 `rev-parse`· `merge-base`·`gh pr view`·`worktree list`처럼 출력이 작은 호출뿐이다. 자가 치유 3부품(flight 타임아웃·503·재시도)은 원인을 가리지 않는 안전망이라 그대로 둔다. 회귀망 `git-large-output.test.ts`는 호출처마다 그 함수를 8번 동시에 불러 출력을 겹치게 만든다(동시 호출은 실제로 일어난다 — 선택이 다른 요청, prewarm, watch 폴). Bun 1.3.12에서 수정 전 코드는 4/4 실패, 수정 후 4/4 통과. 호출을 하나씩 되돌리는 뮤테이션으로 판별력을 쟀다: 지문· name-status·ls-files는 각 3/3 잡히지만 for-each-ref는 좁은 구간에서만 확률적으로 멈춰 20라운드로도 5번 중 3번만 잡힌다 — 테스트와 CLAUDE.md에 그대로 적었다. 행업 단언은 1.3.x에서만 갈리므로 CI `test-bun13` 잡에 이 파일을 더한다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK --- .github/workflows/pr-check.yml | 1 + CLAUDE.md | 4 +- .../viewer/__tests__/git-large-output.test.ts | 172 ++++++++++++++++++ apps/viewer/__tests__/git-output.test.ts | 13 +- apps/viewer/server/diff.ts | 33 ++-- apps/viewer/server/fingerprint.ts | 7 +- apps/viewer/server/gitOutput.ts | 7 +- apps/viewer/server/refs.ts | 15 +- apps/viewer/server/singleFlight.ts | 8 +- 9 files changed, 226 insertions(+), 34 deletions(-) create mode 100644 apps/viewer/__tests__/git-large-output.test.ts diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 99059d4..94da22c 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -66,6 +66,7 @@ jobs: bun test apps/viewer/__tests__/diff-large-blob.test.ts apps/viewer/__tests__/git-output.test.ts + apps/viewer/__tests__/git-large-output.test.ts coverage: runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index 4cf08d5..15d6e8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ bun run format ### CI / 릴리스 -- **`.github/workflows/pr-check.yml`** — PR마다 **여섯 잡**: lint(oxlint + format:check(oxfmt)가 같은 잡의 스텝)·typecheck·test·coverage(100% 게이트)·**e2e**·**test-bun13**. **Bun 버전을 고정하는 건 `test-bun13` 하나뿐이다.** 나머지는 setup-bun 기본값인데, 그 기본값은 `bun-version-file`이나 루트 `package.json`의 `packageManager`·`engines.bun`이 있으면 거기를 따르고 **없어서 지금은 최신**이다 — `engines.bun`을 넣는 순간 다섯 잡의 Bun이 조용히 바뀐다. 고정하는 이유는 최신 Bun에서 `$` never-settle 회귀가 원리적으로 안 보이기 때문이고(아래 "Loading…" 항목), 그래서 그 잡엔 버전에 따라 판별력이 갈리는 회귀망(`diff-large-blob.test.ts`·`git-output.test.ts`)만 싣는다. **그 잡에 `bun install`이 없는 건 의도다**: 그 회귀망은 내장·로컬 파일만 쓰고(설치 없이 통과·되돌리면 실패 — 실측), 설치를 넣으면 최신 Bun이 lockfile 포맷을 바꾸는 날 1.3.14가 못 읽어 회귀와 무관하게 빨개진다 — 그때의 쉬운 오답(고정 판 올리기)이 잡의 존재 이유를 지운다. 지원 하한이 1.4 이상이 되면(`engines.bun` 도입 등) 이 잡은 지운다. 나머지 잡은 `bun install --frozen-lockfile`로 설치한다. e2e 잡은 러너에 Node를 고정(Playwright는 spec·fixture·globalSetup을 항상 Node로 실행)하고 `playwright install --with-deps chrome`으로 **실제 Google Chrome**을 깐다(`playwright.config.ts`가 `channel:"chrome"`이라 번들 Chromium으로는 안 된다). 실패 시 `apps/viewer/test-results/`를 아티팩트로 올린다 — 없으면 CI 전용 실패를 로컬에서 재현할 단서가 없다. **e2e를 게이트에 둔 이유**: happy-dom에는 레이아웃이 없어 "창 크기가 상태 전환에도 변하지 않는가"·"버튼이 실제로 페인트되는가"·"클릭해도 포커스가 남는가" 같은 계약은 유닛이 원리적으로 못 잡는다. 일부만 고르지 않고 전부 돌리는 이유는 비용이 싸서가 아니라(실측상 e2e 잡이 다른 잡보다 수십 배 무겁다) **어느 계약이 깨질지 미리 알 수 없어서**다 — 골라 돌리면 고르는 사람이 이미 답을 안다고 전제하게 된다. `timeout-minutes: 15` 안에서 감당된다. 개수·소요시간을 문서에 박아두지 않는다(반드시 드리프트한다); 개수는 `bunx playwright test --list`가, 소요시간은 `gh run list --workflow=pr-check.yml --status=success` → `gh run view --json jobs`가 답한다. +- **`.github/workflows/pr-check.yml`** — PR마다 **여섯 잡**: lint(oxlint + format:check(oxfmt)가 같은 잡의 스텝)·typecheck·test·coverage(100% 게이트)·**e2e**·**test-bun13**. **Bun 버전을 고정하는 건 `test-bun13` 하나뿐이다.** 나머지는 setup-bun 기본값인데, 그 기본값은 `bun-version-file`이나 루트 `package.json`의 `packageManager`·`engines.bun`이 있으면 거기를 따르고 **없어서 지금은 최신**이다 — `engines.bun`을 넣는 순간 다섯 잡의 Bun이 조용히 바뀐다. 고정하는 이유는 최신 Bun에서 `$` never-settle 회귀가 원리적으로 안 보이기 때문이고(아래 "Loading…" 항목), 그래서 그 잡엔 버전에 따라 판별력이 갈리는 회귀망(`diff-large-blob.test.ts`·`git-output.test.ts`·`git-large-output.test.ts`)만 싣는다. **그 잡에 `bun install`이 없는 건 의도다**: 그 회귀망은 내장·로컬 파일만 쓰고(설치 없이 통과·되돌리면 실패 — 실측), 설치를 넣으면 최신 Bun이 lockfile 포맷을 바꾸는 날 1.3.14가 못 읽어 회귀와 무관하게 빨개진다 — 그때의 쉬운 오답(고정 판 올리기)이 잡의 존재 이유를 지운다. 지원 하한이 1.4 이상이 되면(`engines.bun` 도입 등) 이 잡은 지운다. 나머지 잡은 `bun install --frozen-lockfile`로 설치한다. e2e 잡은 러너에 Node를 고정(Playwright는 spec·fixture·globalSetup을 항상 Node로 실행)하고 `playwright install --with-deps chrome`으로 **실제 Google Chrome**을 깐다(`playwright.config.ts`가 `channel:"chrome"`이라 번들 Chromium으로는 안 된다). 실패 시 `apps/viewer/test-results/`를 아티팩트로 올린다 — 없으면 CI 전용 실패를 로컬에서 재현할 단서가 없다. **e2e를 게이트에 둔 이유**: happy-dom에는 레이아웃이 없어 "창 크기가 상태 전환에도 변하지 않는가"·"버튼이 실제로 페인트되는가"·"클릭해도 포커스가 남는가" 같은 계약은 유닛이 원리적으로 못 잡는다. 일부만 고르지 않고 전부 돌리는 이유는 비용이 싸서가 아니라(실측상 e2e 잡이 다른 잡보다 수십 배 무겁다) **어느 계약이 깨질지 미리 알 수 없어서**다 — 골라 돌리면 고르는 사람이 이미 답을 안다고 전제하게 된다. `timeout-minutes: 15` 안에서 감당된다. 개수·소요시간을 문서에 박아두지 않는다(반드시 드리프트한다); 개수는 `bunx playwright test --list`가, 소요시간은 `gh run list --workflow=pr-check.yml --status=success` → `gh run view --json jobs`가 답한다. - **lint 스코프**: 스크립트는 `oxlint apps/`/`oxfmt apps/`로 **owned 코드만** 대상(vendored `packages/*`는 Pierre 원본 스타일이라 lint/format 게이트 제외). `.oxlintrc.json`의 `typeAware:true` 때문에 `oxlint-tsgolint`(devDep)가 있어야 lint가 돈다. 테스트/e2e override(`**/__tests__/**`·`**/e2e/**`)에서 unbound-method·no-empty-pattern·no-unassigned-import 등 완화. - **워커 하이라이트 경로 활성화됨**: `build.ts`가 `packages/diffs/src/worker/worker.ts`를 `dist/viewer/worker.js`로 번들하고, `main.ts`가 `getOrCreateWorkerPoolSingleton`(poolSize 2)으로 만든 풀을 CodeView에 주입한다. 렌더 옵션 5필드(theme·useTokenTransformer·tokenizeMaxLineLength·lineDiffType·maxLineDiffLength)는 풀이 자기 옵션을 init 메시지로 워커에 밀어넣어 자기일관적이다 — 진짜 계약은 반대 방향: 이 5필드를 CodeView 레벨에서 오버라이드해도 워커 경로는 무시하므로, 바꾸려면 반드시 `getOrCreateWorkerPoolSingleton`의 `highlighterOptions`에 넣어야 한다. 회귀망: `worker-highlight.e2e.ts`(첫 진입 무스파이크 + plain→색 전이 + 폴백). - **`.github/workflows/release.yml` + `release-please-config.json` + `.release-please-manifest.json`** — release-please(모노레포: 배포 패키지 `apps/viewer`, `package-name @say8425/diffdeck`)가 conventional commits로 **릴리스 PR을 생성**하고, **사람이 그 PR을 머지**하면 release-please가 릴리스·태그를 커팅 → `releases_created == 'true'` 일 때 publish 잡이 `apps/viewer`에서 `bun run build` + `npm publish --provenance --access public`. @@ -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`와 `summary.ts`의 git 호출은 `$`가 아니라 **`gitOutput.ts`의 `gitBytes`/`gitText`(`Bun.spawn`)**를 탄다. **`$`로 되돌리지 말 것** — 회귀망 `diff-large-blob.test.ts`·`git-output.test.ts`는 행업을 1.3.x에서만 잡으므로(최신 Bun에선 `$`도 통과한다) CI의 `test-bun13` 잡이 그 둘을 Bun 1.3.14로 고정해 돌린다. 다만 `summary.ts`의 행업은 결정론적으로 재현되지 않아(순차 호출이라, 437KB 출력에서 한 번은 18번째 호출에 걸리고 다른 한 번은 150회 무사했다 — 실측) **그 파일이 `$`로 돌아가는 것은 테스트가 못 잡는다** — 헬퍼 자체의 행업만 `git-output.test.ts`가 잡는다. 출력이 64KB를 넘을 수 있는 `$`는 아직 남아 있고, 그래서 아래 세 부품은 그대로 둔다 — 예: `diffFlight` 안의 큰 diff `git diff --name-status`·`ls-files --others`·지문의 `status -uall`(①②③ 전부), `refsFlight` 안의 `refs.ts` `for-each-ref`(원격 브랜치가 수천 개인 리포 — ①②만, ③ 재시도는 `/api/diff` 전용). **새 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`. +- **"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`. 남은 `$`는 `rev-parse`·`merge-base`·`gh pr view`·`worktree list`처럼 출력이 작은 호출뿐이다. **`$`로 되돌리지 말 것** — 회귀망 `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 — 400개 이하나 2000개에선 30라운드 동안 한 번도 안 멈췄다)에서만 확률적으로 멈춰 20라운드를 돌려도 5번 중 3번만 잡혔다. `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`. - **`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__/git-large-output.test.ts b/apps/viewer/__tests__/git-large-output.test.ts new file mode 100644 index 0000000..113e6fa --- /dev/null +++ b/apps/viewer/__tests__/git-large-output.test.ts @@ -0,0 +1,172 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getDiffFiles } from "../server/diff.ts"; +import { repoFingerprint } from "../server/fingerprint.ts"; +import { getRefs } from "../server/refs.ts"; + +/** + * 출력이 64KB를 넘는 git 호출이 서버 함수를 **동시에** 여러 번 불러도 settle하는가. + * Bun 1.3.x의 `$`는 그런 호출에서 자식이 끝났는데도 promise가 영영 settle하지 않을 + * 수 있고, 호출이 겹치면 거의 확정이다(업스트림은 1.4.0에서 수정). 동시 호출은 + * 실제로 일어난다 — 선택이 다른 `/api/diff` 요청들, prewarm, watch 폴이 서로 다른 + * flight 키로 겹친다. + * + * 한 케이스가 한 호출처를 지킨다: 지문의 `status -uall`, `getDiffFiles`의 + * `diff --name-status`와 `ls-files --others`, `getRefs`의 `for-each-ref`. 앞의 + * 셋은 `$`로 돌아가면 첫 라운드에 확정적으로 죽는다(1.3.12, 각 3/3 실측 — + * `ls-files` 케이스는 `name-status`도 거치므로 그걸 되돌려도 함께 죽는다). + * + * **`for-each-ref` 케이스만 확률적이다.** 이 호출의 멈춤은 좁은 구간에서만 + * 나고(8-way에서 참조 600~800개 ≈ 180~240KB — 400개 이하나 2000개에서는 30라운드 + * 동안 한 번도 안 멈췄다), 그 구간에서도 라운드마다 확률이다. 그래서 800개로 + * 라운드를 늘려 돌린다 — 그래도 되돌린 코드를 5번 중 3번만 잡았으니(1.3.12) + * 이 케이스의 초록을 증거로 받지 말 것. + * + * 판별력은 `diff-large-blob.test.ts`와 같다: 행업 단언은 1.3.x에서만 갈리므로 CI의 + * `test-bun13` 잡이 이 파일도 Bun 1.3.14로 돌린다. 개수 단언은 버전 무관. 그 잡은 + * 설치 없이 돌므로 픽스처도 `$` 대신 `Bun.spawnSync`로 만든다 — 1.3.x에서 셋업이 + * 먼저 멈추면 무엇을 재는지 흐려진다. + */ + +const STAGED = 1000; // 이름 150자 × 1000 → name-status ~160KB +const LOOSE = 1000; // → ls-files --others ~157KB, status -uall은 둘을 합쳐 ~320KB +const BRANCHES = 800; // → for-each-ref ~256KB +const CALLS = 8; +const ROUNDS = 2; +const REF_ROUNDS = 20; // getRefs만 — 위 docblock 참고 +const SETTLE_MS = 15_000; +const TIMEOUT = ROUNDS * SETTLE_MS + 5_000; + +const longName = (prefix: string, i: number): string => + `${prefix}-${String(i).padStart(4, "0")}-${"x".repeat(140)}`; + +const git = (repo: string, args: string[], stdin?: string): void => { + const r = Bun.spawnSync(["git", "-C", repo, ...args], { + stdin: stdin === undefined ? "ignore" : Buffer.from(stdin), + stdout: "ignore", + stderr: "pipe", + }); + if (r.exitCode !== 0) { + throw new Error(`git ${args.join(" ")}: ${r.stderr.toString()}`); + } +}; + +let repo: string; + +beforeAll(() => { + repo = mkdtempSync(join(tmpdir(), "cc-git-large-output-")); + git(repo, ["init", "-q", "-b", "main"]); + git(repo, ["config", "user.email", "t@t.co"]); + git(repo, ["config", "user.name", "test"]); + writeFileSync(join(repo, "seed.txt"), "seed\n"); + git(repo, ["add", "seed.txt"]); + git(repo, ["commit", "-qm", "base"]); + + mkdirSync(join(repo, "staged")); + mkdirSync(join(repo, "loose")); + for (let i = 0; i < STAGED; i++) { + writeFileSync(join(repo, "staged", longName("s", i)), `s${i}\n`); + } + for (let i = 0; i < LOOSE; i++) { + writeFileSync(join(repo, "loose", longName("l", i)), `l${i}\n`); + } + git(repo, ["add", "staged"]); + + const lines = Array.from( + { length: BRANCHES }, + (_, i) => `create refs/heads/${longName("b", i)} HEAD\n`, + ).join(""); + git(repo, ["update-ref", "--stdin"], lines); +}); + +afterAll(() => { + rmSync(repo, { recursive: true, force: true }); +}); + +const settleWithin = async (work: Promise, what: string): Promise => { + let timer: ReturnType | undefined; + const deadline = new Promise((_, reject) => { + timer = setTimeout( + () => + reject(new Error(`${what}가 ${SETTLE_MS}ms 안에 settle하지 않았다`)), + SETTLE_MS, + ); + }); + try { + return await Promise.race([work, deadline]); + } finally { + clearTimeout(timer); + } +}; + +/** `fn`을 `CALLS`번 동시에 부르는 라운드를 `rounds`번 돌려 모든 결과를 모은다. */ +const concurrently = async ( + what: string, + fn: () => Promise, + rounds = ROUNDS, +): Promise => { + const all: T[] = []; + for (let round = 0; round < rounds; round++) { + const batch = Array.from({ length: CALLS }, fn); + all.push(...(await settleWithin(Promise.all(batch), what))); + } + return all; +}; + +test( + "repoFingerprint settles under concurrent calls when `status -uall` exceeds 64KB", + async () => { + const prints = await concurrently("repoFingerprint", () => + repoFingerprint(repo, { untracked: true }), + ); + expect(prints).toHaveLength(CALLS * ROUNDS); + expect(new Set(prints).size).toBe(1); + }, + TIMEOUT, +); + +test( + "getDiffFiles settles under concurrent calls when `diff --name-status` exceeds 64KB", + async () => { + const results = await concurrently("getDiffFiles(name-status)", () => + getDiffFiles(repo), + ); + for (const files of results) { + expect(files).toHaveLength(STAGED); + expect(files.every((f) => f.status === "added")).toBe(true); + } + }, + TIMEOUT, +); + +test( + "getDiffFiles settles under concurrent calls when `ls-files --others` exceeds 64KB", + async () => { + const results = await concurrently("getDiffFiles(ls-files)", () => + getDiffFiles(repo, { untracked: true }), + ); + for (const files of results) { + expect(files).toHaveLength(STAGED + LOOSE); + expect(files.filter((f) => f.status === "untracked")).toHaveLength(LOOSE); + } + }, + TIMEOUT, +); + +test( + "getRefs settles under concurrent calls when `for-each-ref` exceeds 64KB", + async () => { + const results = await concurrently( + "getRefs", + () => getRefs(repo), + REF_ROUNDS, + ); + for (const { refs } of results) { + // 만든 브랜치 800개 + main + expect(refs.filter((r) => r.kind === "local")).toHaveLength(BRANCHES + 1); + } + }, + TIMEOUT, +); diff --git a/apps/viewer/__tests__/git-output.test.ts b/apps/viewer/__tests__/git-output.test.ts index e2147b5..6c290ea 100644 --- a/apps/viewer/__tests__/git-output.test.ts +++ b/apps/viewer/__tests__/git-output.test.ts @@ -7,13 +7,12 @@ import { gitText } from "../server/gitOutput.ts"; import { mapWithLimit } from "../server/mapLimit.ts"; /** - * `gitText`(와 그 아래 `gitBytes`)의 회귀망. 출력이 64KB를 넘을 수 있는 새 git - * 호출은 이 헬퍼를 탄다(아직 `$`로 남은 호출은 CLAUDE.md "Loading…" 항목) — Bun - * 1.3.x의 `$`는 그런 호출에서 자식이 끝났는데도 promise가 영영 settle하지 않을 - * 수 있기 때문이다(업스트림은 1.4.0에서 수정). 실제 호출처(예: `summary.ts`의 - * `diff --name-only`)는 걸리는 빈도가 낮아 결정론적으로 재현되지 않으므로, - * 확실히 걸리는 모양 — 200KB `git show` 12개를 8-way로 — 으로 헬퍼 자체를 - * 찌른다. + * `gitText`(와 그 아래 `gitBytes`)의 회귀망. 출력이 64KB를 넘을 수 있는 서버의 + * git 호출은 전부 이 헬퍼를 탄다 — Bun 1.3.x의 `$`는 그런 호출에서 자식이 + * 끝났는데도 promise가 영영 settle하지 않을 수 있기 때문이다(업스트림은 1.4.0에서 + * 수정). 호출처별 회귀망은 `git-large-output.test.ts`인데 거기서도 결정론적으로 + * 재현되지 않는 호출처가 있어(`summary.ts`의 순차 호출, `for-each-ref`), 확실히 + * 걸리는 모양 — 200KB `git show` 12개를 8-way로 — 으로 헬퍼 자체를 찌른다. * * 판별력은 `diff-large-blob.test.ts`와 같다: 행업 단언은 1.3.x에서만 갈리므로 * CI의 `test-bun13` 잡이 이 파일도 Bun 1.3.14로 돌린다. 내용 단언은 버전 무관. diff --git a/apps/viewer/server/diff.ts b/apps/viewer/server/diff.ts index fdabf5b..b2dace1 100644 --- a/apps/viewer/server/diff.ts +++ b/apps/viewer/server/diff.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { $ } from "bun"; -import { gitBytes } from "./gitOutput.ts"; +import { gitBytes, gitText } from "./gitOutput.ts"; import { mapWithLimit } from "./mapLimit.ts"; // buildFile 병렬 실행 상한 — 파일당 git 서브프로세스가 뜨므로 무제한이면 @@ -304,13 +304,18 @@ export const getDiffFiles = async ( // 위치 인자로 처음 통과시키면서 열린 노출이다 — 예전엔 언제나 // `HEAD` 아니면 merge-base OID였다. `merge-base`·`rev-list`· // `rev-parse`·`show :`는 rev만 받아 영향이 없다(실측). - const nameStatus = opts.head - ? await $`git -C ${repo} diff --name-status -z ${base} ${opts.head} -- 2>/dev/null` - .nothrow() - .text() - : await $`git -C ${repo} diff --name-status -z ${base} -- 2>/dev/null` - .nothrow() - .text(); + // + // `$`가 아니라 `gitText`다 — 큰 diff에서 출력이 64KB를 넘는다. + const nameStatus = await gitText([ + "-C", + repo, + "diff", + "--name-status", + "-z", + base, + ...(opts.head ? [opts.head] : []), + "--", + ]); // 파일별 git show/워킹트리 읽기는 서로 독립이라 병렬화하되, 대형 diff에서 // git 서브프로세스가 무제한으로 뜨지 않도록 동시성을 제한한다 (순서 유지). const specs = parseNameStatusZ(nameStatus); @@ -324,10 +329,14 @@ export const getDiffFiles = async ( // 없으므로 건너뛴다(디스크를 훑어 봐야 그건 워킹트리의 사실이지 이 뷰의 // 사실이 아니다). if (opts.untracked && !opts.head) { - const listed = - await $`git -C ${repo} ls-files --others --exclude-standard -z 2>/dev/null` - .nothrow() - .text(); + const listed = await gitText([ + "-C", + repo, + "ls-files", + "--others", + "--exclude-standard", + "-z", + ]); const paths = listed.split("\0").filter((s) => s !== ""); files.push( ...(await mapWithLimit(paths, BUILD_CONCURRENCY, (path) => diff --git a/apps/viewer/server/fingerprint.ts b/apps/viewer/server/fingerprint.ts index 5260874..a9e122c 100644 --- a/apps/viewer/server/fingerprint.ts +++ b/apps/viewer/server/fingerprint.ts @@ -14,6 +14,7 @@ import { statSync } from "node:fs"; import { join } from "node:path"; import { $ } from "bun"; +import { gitText } from "./gitOutput.ts"; export const repoFingerprint = async ( repo: string, @@ -27,9 +28,9 @@ export const repoFingerprint = async ( ): Promise => { const untrackedFlag = opts.untracked ? "-uall" : "-uno"; const [status, head, baseRev, headRev] = await Promise.all([ - $`git -C ${repo} status --porcelain -z ${untrackedFlag} 2>/dev/null` - .nothrow() - .text(), + // `$`가 아니라 `gitText` — untracked를 켜면 출력이 64KB를 쉽게 넘고, watch가 + // 2초마다 부르는 자리라 Bun 1.3.x `$`의 never-settle에 가장 오래 노출된다. + gitText(["-C", repo, "status", "--porcelain", "-z", untrackedFlag]), $`git -C ${repo} rev-parse HEAD 2>/dev/null`.nothrow().text(), opts.mode === "base" && opts.ref ? $`git -C ${repo} rev-parse ${opts.ref} 2>/dev/null`.nothrow().text() diff --git a/apps/viewer/server/gitOutput.ts b/apps/viewer/server/gitOutput.ts index ccdac6e..445ce28 100644 --- a/apps/viewer/server/gitOutput.ts +++ b/apps/viewer/server/gitOutput.ts @@ -1,7 +1,8 @@ /** * git을 `$`가 아니라 `Bun.spawn`으로 부르고 stdout을 끝까지 읽는다. 출력이 - * 64KB를 넘을 수 있는 **새** git 호출은 여기를 탄다 — 아직 `$`로 남은 호출은 - * CLAUDE.md "Loading…" 항목에 있다. + * 64KB를 넘을 수 있는 서버의 git 호출은 전부 여기를 탄다 — 남은 `$`는 + * `rev-parse`·`merge-base`·`gh pr view`·`worktree list`처럼 출력이 작은 호출뿐이고, + * 새 호출도 출력이 클 수 있으면 여기를 탄다. * * Bun 1.3.x의 `$`는 64KB를 넘는 stdout을 받는 호출에서 자식이 이미 끝났는데도 * promise가 영영 settle하지 않을 수 있다 — 호출이 겹치면 거의 확정이고 완전 @@ -17,7 +18,7 @@ * 기대지 않는다. * 인자는 셸을 거치지 않고 argv로 그대로 간다(옵션 꼴 참조를 막는 건 여전히 * 호출자 몫이다 — `verifyBaseRef`). 회귀망: `git-output.test.ts`, - * `diff-large-blob.test.ts`. + * `diff-large-blob.test.ts`, `git-large-output.test.ts`(호출처별). */ export const gitBytes = async ( args: readonly string[], diff --git a/apps/viewer/server/refs.ts b/apps/viewer/server/refs.ts index e362852..76c9ce6 100644 --- a/apps/viewer/server/refs.ts +++ b/apps/viewer/server/refs.ts @@ -6,6 +6,7 @@ * 그것을 물고 있는 워크트리를 알려준다. */ import { $ } from "bun"; +import { gitText } from "./gitOutput.ts"; export interface WorktreeRecord { path: string; @@ -159,10 +160,16 @@ export const getRefs = async (repo: string): Promise => { // 찾는 것이고, 기본(refname) 순서면 충분하다. const [wtRaw, refRaw] = await Promise.all([ $`git -C ${repo} worktree list --porcelain -z`.nothrow().quiet().text(), - $`git -C ${repo} for-each-ref ${REF_FORMAT} refs/heads refs/remotes` - .nothrow() - .quiet() - .text(), + // `$`가 아니라 `gitText` — 원격 브랜치가 수천 개인 리포면 출력이 64KB를 + // 넘는다. 작은 `worktree list`는 `$`로 둔다. + gitText([ + "-C", + repo, + "for-each-ref", + REF_FORMAT, + "refs/heads", + "refs/remotes", + ]), ]); const worktrees = parseWorktreeList(wtRaw); const live = new Set(worktrees.map((w) => w.path)); diff --git a/apps/viewer/server/singleFlight.ts b/apps/viewer/server/singleFlight.ts index 7b4f9a0..4dd9665 100644 --- a/apps/viewer/server/singleFlight.ts +++ b/apps/viewer/server/singleFlight.ts @@ -7,9 +7,11 @@ * 64KB를 넘는 stdout을 받는 호출에서 resolve도 reject도 없이 영구히 pending * 상태가 될 수 있다(겹치면 거의 확정이고 순차여도 결국 걸린다, 업스트림은 * 1.4.0에서 수정). 자식은 시스템에서 사라지고 좀비도 없고 이벤트 루프도 - * 정상인데 프라미스만 안 끝난다. 그 버스트의 주인이던 diff.ts `showBytes`는 - * `Bun.spawn`으로 옮겼지만 출력이 클 수 있는 `$`가 남아 있어 이 타임아웃은 - * 그대로 필요하다. ShellPromise엔 `.timeout()`/`.kill()`이 없어 Promise.race가 + * 정상인데 프라미스만 안 끝난다. 출력이 클 수 있는 git 호출은 이제 전부 + * `Bun.spawn`(`gitOutput.ts`)을 타지만 이 타임아웃은 남겨 둔다 — 원인을 가리지 + * 않는 안전망이라(예: `baseFlight` 안의 `gh pr view`는 네트워크를 기다린다), + * 그리고 남은 작은 `$`가 원리적으로 안전하다는 증명은 없다(60KB는 멀쩡했다는 + * 관측뿐이다). ShellPromise엔 `.timeout()`/`.kill()`이 없어 Promise.race가 * 유일한 레버다. 타임아웃이 뜨면 이 슬롯을 reject해 `.finally()`가 키를 지우게 * 하고, 그래야 "다음" 호출이 죽은 프라미스에 합류하지 않고 새로 시작한다 — * 버려진 원래 fn()은 백그라운드에서 계속 pending인 채로 남지만 더는 아무도 From 8c61faa2042255c29d8db8f153f209510cfb7c45 Mon Sep 17 00:00:00 2001 From: Penguin Date: Mon, 21 Sep 2026 16:36:50 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=E2=80=94=20for-each-ref=20=ED=9A=8C=EA=B7=80?= =?UTF-8?q?=EB=A7=9D=EC=9D=84=2016-way=EB=A1=9C,=20worktree=20list?= =?UTF-8?q?=EB=8F=84=20=ED=97=AC=ED=8D=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - for-each-ref 케이스는 8-way에선 되돌린 코드를 20라운드로도 5번 중 3번만 잡았다. 그 케이스만 16-way로 올리자 첫 라운드에 11번 중 11번 잡는다 (1.3.12, macOS — 리뷰어 6/6 + 재현 5/5). 라운드는 20 → 5. - beforeAll에 60초 상한을 준다. 픽스처가 평소 1.2~1.8초인데 부하가 걸린 뮤테이션 실행에서 훅 기본 상한 5초를 넘어 무관한 이유로 실패한 것을 실측했다 — CI 러너에서도 날 수 있는 결함이다. - 테스트 상한을 라운드 수에 맞춰 케이스별로 계산한다. - refs.ts의 worktree list도 gitText로 옮긴다. 출력이 등록된 워크트리 수에 비례하고, 디렉토리가 지워져도 prunable로 등록이 남아 약 300개면 64KB를 넘는다. 다만 죽은 워크트리 400개(110KB)로 `$`를 8·16-way 10라운드씩 8번 돌려도 한 번도 안 멈춰 판별할 모양을 못 찾았으므로 회귀망은 두지 않고 그 사실을 적는다. 크기는 필요조건일 뿐이라는 것도 적는다. - 남은 `$`는 rev-parse·merge-base·gh pr view — 출력 크기가 리포 규모와 무관한 호출뿐이다. - CI 잡 주석의 "10초"(새 파일은 15초), git-output.test.ts docblock의 오해 소지 있는 문장을 고친다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK --- .github/workflows/pr-check.yml | 2 +- CLAUDE.md | 2 +- .../viewer/__tests__/git-large-output.test.ts | 45 ++++++++++--------- apps/viewer/__tests__/git-output.test.ts | 7 +-- apps/viewer/server/gitOutput.ts | 7 +-- apps/viewer/server/refs.ts | 8 ++-- 6 files changed, 39 insertions(+), 32 deletions(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 94da22c..deab28a 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -48,7 +48,7 @@ jobs: # 지원 하한이 1.4 이상이 되면(`engines.bun` 도입 등) 이 잡은 지운다. test-bun13: runs-on: ubuntu-latest - # 회귀하면 테스트가 스스로 10초 타임아웃으로 끝나지만, Bun 다운로드나 러너가 + # 회귀하면 테스트가 스스로 타임아웃(케이스당 10~15초)으로 끝나지만, Bun 다운로드나 러너가 # 매달리는 경우까지 GHA 기본값(6시간)을 태우지 않게 상한을 둔다. timeout-minutes: 10 steps: diff --git a/CLAUDE.md b/CLAUDE.md index 15d6e8f..bc9772e 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는 멀쩡하다. 그래서 출력이 64KB를 넘을 수 있는 서버의 git 호출은 전부 `$`가 아니라 **`gitOutput.ts`의 `gitBytes`/`gitText`(`Bun.spawn`)**를 탄다 — `showBytes`(파일별 버스트), `summary.ts`, `getDiffFiles`의 `diff --name-status`·`ls-files --others`, 지문의 `status`, `refs.ts`의 `for-each-ref`. 남은 `$`는 `rev-parse`·`merge-base`·`gh pr view`·`worktree list`처럼 출력이 작은 호출뿐이다. **`$`로 되돌리지 말 것** — 회귀망 `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 — 400개 이하나 2000개에선 30라운드 동안 한 번도 안 멈췄다)에서만 확률적으로 멈춰 20라운드를 돌려도 5번 중 3번만 잡혔다. `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`. +- **"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`. - **`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__/git-large-output.test.ts b/apps/viewer/__tests__/git-large-output.test.ts index 113e6fa..81d06a7 100644 --- a/apps/viewer/__tests__/git-large-output.test.ts +++ b/apps/viewer/__tests__/git-large-output.test.ts @@ -18,11 +18,15 @@ import { getRefs } from "../server/refs.ts"; * 셋은 `$`로 돌아가면 첫 라운드에 확정적으로 죽는다(1.3.12, 각 3/3 실측 — * `ls-files` 케이스는 `name-status`도 거치므로 그걸 되돌려도 함께 죽는다). * - * **`for-each-ref` 케이스만 확률적이다.** 이 호출의 멈춤은 좁은 구간에서만 - * 나고(8-way에서 참조 600~800개 ≈ 180~240KB — 400개 이하나 2000개에서는 30라운드 - * 동안 한 번도 안 멈췄다), 그 구간에서도 라운드마다 확률이다. 그래서 800개로 - * 라운드를 늘려 돌린다 — 그래도 되돌린 코드를 5번 중 3번만 잡았으니(1.3.12) - * 이 케이스의 초록을 증거로 받지 말 것. + * **`for-each-ref` 케이스만 16-way다.** 이 호출의 멈춤은 좁은 구간에서만 난다 + * (8-way에서 참조 600~800개 ≈ 180~240KB — 400개 이하나 2000개에서는 30라운드 동안 + * 한 번도 안 멈췄다). 8-way로는 20라운드를 돌려도 되돌린 코드를 5번 중 3번만 + * 잡았고, 16-way로 겹침을 늘리자 첫 라운드에 11번 중 11번 잡혔다(1.3.12, macOS). + * + * 같은 `getRefs`의 `worktree list`도 `gitText`로 옮겼지만 **여기서 지키지 않는다**: + * 죽은 워크트리 등록 400개로 110KB를 내게 해도 `$`가 8·16-way로 10라운드씩 + * 8번 동안 한 번도 안 멈춰(1.3.12) 판별할 모양을 못 찾았다. 멈춤은 출력 크기만으로 + * 정해지지 않는다. * * 판별력은 `diff-large-blob.test.ts`와 같다: 행업 단언은 1.3.x에서만 갈리므로 CI의 * `test-bun13` 잡이 이 파일도 Bun 1.3.14로 돌린다. 개수 단언은 버전 무관. 그 잡은 @@ -35,9 +39,11 @@ const LOOSE = 1000; // → ls-files --others ~157KB, status -uall은 둘을 합 const BRANCHES = 800; // → for-each-ref ~256KB const CALLS = 8; const ROUNDS = 2; -const REF_ROUNDS = 20; // getRefs만 — 위 docblock 참고 +const REF_CALLS = 16; // getRefs만 — 위 docblock 참고 +const REF_ROUNDS = 5; const SETTLE_MS = 15_000; -const TIMEOUT = ROUNDS * SETTLE_MS + 5_000; +/** 라운드마다 `settleWithin`이 먼저 터지도록 테스트 상한을 라운드 수에 맞춘다. */ +const timeoutFor = (rounds: number): number => rounds * SETTLE_MS + 5_000; const longName = (prefix: string, i: number): string => `${prefix}-${String(i).padStart(4, "0")}-${"x".repeat(140)}`; @@ -79,7 +85,7 @@ beforeAll(() => { (_, i) => `create refs/heads/${longName("b", i)} HEAD\n`, ).join(""); git(repo, ["update-ref", "--stdin"], lines); -}); +}, 60_000); // 훅의 기본 상한 5초는 부하가 걸리면 넘는다(평소 1.2~1.8초, 한 번 넘는 것을 실측) afterAll(() => { rmSync(repo, { recursive: true, force: true }); @@ -101,15 +107,15 @@ const settleWithin = async (work: Promise, what: string): Promise => { } }; -/** `fn`을 `CALLS`번 동시에 부르는 라운드를 `rounds`번 돌려 모든 결과를 모은다. */ +/** `fn`을 `calls`번 동시에 부르는 라운드를 `rounds`번 돌려 모든 결과를 모은다. */ const concurrently = async ( what: string, fn: () => Promise, - rounds = ROUNDS, + { calls = CALLS, rounds = ROUNDS } = {}, ): Promise => { const all: T[] = []; for (let round = 0; round < rounds; round++) { - const batch = Array.from({ length: CALLS }, fn); + const batch = Array.from({ length: calls }, fn); all.push(...(await settleWithin(Promise.all(batch), what))); } return all; @@ -124,7 +130,7 @@ test( expect(prints).toHaveLength(CALLS * ROUNDS); expect(new Set(prints).size).toBe(1); }, - TIMEOUT, + timeoutFor(ROUNDS), ); test( @@ -138,7 +144,7 @@ test( expect(files.every((f) => f.status === "added")).toBe(true); } }, - TIMEOUT, + timeoutFor(ROUNDS), ); test( @@ -152,21 +158,20 @@ test( expect(files.filter((f) => f.status === "untracked")).toHaveLength(LOOSE); } }, - TIMEOUT, + timeoutFor(ROUNDS), ); test( "getRefs settles under concurrent calls when `for-each-ref` exceeds 64KB", async () => { - const results = await concurrently( - "getRefs", - () => getRefs(repo), - REF_ROUNDS, - ); + const results = await concurrently("getRefs", () => getRefs(repo), { + calls: REF_CALLS, + rounds: REF_ROUNDS, + }); for (const { refs } of results) { // 만든 브랜치 800개 + main expect(refs.filter((r) => r.kind === "local")).toHaveLength(BRANCHES + 1); } }, - TIMEOUT, + timeoutFor(REF_ROUNDS), ); diff --git a/apps/viewer/__tests__/git-output.test.ts b/apps/viewer/__tests__/git-output.test.ts index 6c290ea..f4c783f 100644 --- a/apps/viewer/__tests__/git-output.test.ts +++ b/apps/viewer/__tests__/git-output.test.ts @@ -10,9 +10,10 @@ import { mapWithLimit } from "../server/mapLimit.ts"; * `gitText`(와 그 아래 `gitBytes`)의 회귀망. 출력이 64KB를 넘을 수 있는 서버의 * git 호출은 전부 이 헬퍼를 탄다 — Bun 1.3.x의 `$`는 그런 호출에서 자식이 * 끝났는데도 promise가 영영 settle하지 않을 수 있기 때문이다(업스트림은 1.4.0에서 - * 수정). 호출처별 회귀망은 `git-large-output.test.ts`인데 거기서도 결정론적으로 - * 재현되지 않는 호출처가 있어(`summary.ts`의 순차 호출, `for-each-ref`), 확실히 - * 걸리는 모양 — 200KB `git show` 12개를 8-way로 — 으로 헬퍼 자체를 찌른다. + * 수정). 호출처별 회귀망은 `git-large-output.test.ts`인데 거기서 지키지 못하는 + * 호출처가 있어(`summary.ts`의 순차 호출과 `worktree list`는 멈춤을 재현하지 + * 못했다), 확실히 걸리는 모양 — 200KB `git show` 12개를 8-way로 — 으로 헬퍼 자체를 + * 찌른다. * * 판별력은 `diff-large-blob.test.ts`와 같다: 행업 단언은 1.3.x에서만 갈리므로 * CI의 `test-bun13` 잡이 이 파일도 Bun 1.3.14로 돌린다. 내용 단언은 버전 무관. diff --git a/apps/viewer/server/gitOutput.ts b/apps/viewer/server/gitOutput.ts index 445ce28..500a918 100644 --- a/apps/viewer/server/gitOutput.ts +++ b/apps/viewer/server/gitOutput.ts @@ -1,13 +1,14 @@ /** * git을 `$`가 아니라 `Bun.spawn`으로 부르고 stdout을 끝까지 읽는다. 출력이 * 64KB를 넘을 수 있는 서버의 git 호출은 전부 여기를 탄다 — 남은 `$`는 - * `rev-parse`·`merge-base`·`gh pr view`·`worktree list`처럼 출력이 작은 호출뿐이고, - * 새 호출도 출력이 클 수 있으면 여기를 탄다. + * `rev-parse`·`merge-base`·`gh pr view`처럼 출력 크기가 리포 규모와 무관한 + * 호출뿐이고, 새 호출도 출력이 클 수 있으면 여기를 탄다. * * Bun 1.3.x의 `$`는 64KB를 넘는 stdout을 받는 호출에서 자식이 이미 끝났는데도 * promise가 영영 settle하지 않을 수 있다 — 호출이 겹치면 거의 확정이고 완전 * 순차여도 결국 걸린다(1.3.12·1.3.14 실측, macOS·Linux 모두; 업스트림은 1.4.0에서 - * 수정). `getDiffFiles`의 8-way `showBytes` 버스트가 그 모양이라 큰 blob이 섞인 + * 수정). 크기는 필요조건일 뿐이다 — 같은 크기라도 호출에 따라 안 멈추기도 한다 + * (`worktree list` 110KB는 한 번도 안 멈췄다). `getDiffFiles`의 8-way `showBytes` 버스트가 그 모양이라 큰 blob이 섞인 * diff가 통째로 45초 flight 타임아웃 → 503이 됐고, 같은 작업을 `Bun.spawn`으로는 * 수천 번 돌려도 걸리지 않았다. * diff --git a/apps/viewer/server/refs.ts b/apps/viewer/server/refs.ts index 76c9ce6..7bf1ca6 100644 --- a/apps/viewer/server/refs.ts +++ b/apps/viewer/server/refs.ts @@ -5,7 +5,6 @@ * 개념이 아니라 git이 이미 갖고 있는 관계다: `%(worktreepath)`가 브랜치마다 * 그것을 물고 있는 워크트리를 알려준다. */ -import { $ } from "bun"; import { gitText } from "./gitOutput.ts"; export interface WorktreeRecord { @@ -159,9 +158,10 @@ export const getRefs = async (repo: string): Promise => { // 읽게 만들어 브랜치가 많은 리포에서 비용을 지배한다 — 목록은 검색으로 // 찾는 것이고, 기본(refname) 순서면 충분하다. const [wtRaw, refRaw] = await Promise.all([ - $`git -C ${repo} worktree list --porcelain -z`.nothrow().quiet().text(), - // `$`가 아니라 `gitText` — 원격 브랜치가 수천 개인 리포면 출력이 64KB를 - // 넘는다. 작은 `worktree list`는 `$`로 둔다. + // 둘 다 `$`가 아니라 `gitText` — 출력이 참조 수·등록된 워크트리 수에 + // 비례해 64KB를 넘을 수 있다(워크트리는 디렉토리가 지워져도 prunable로 + // 등록이 남으므로 약 300개면 넘는다). + gitText(["-C", repo, "worktree", "list", "--porcelain", "-z"]), gitText([ "-C", repo, From e37d2e86799f09718277a78585512c050af55104 Mon Sep 17 00:00:00 2001 From: Penguin Date: Mon, 21 Sep 2026 16:36:53 +0900 Subject: [PATCH 3/4] =?UTF-8?q?test:=20[=EC=9E=84=EC=8B=9C=C2=B7=EB=90=98?= =?UTF-8?q?=EB=8F=8C=EB=A6=B4=20=EA=B2=83]=20=EB=8B=A4=EC=84=AF=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EC=9D=84=20$=EB=A1=9C=20=EB=90=98=EB=8F=8C?= =?UTF-8?q?=EB=A0=A4=20test-bun13=EC=9D=B4=20Linux=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B9=A8=EA=B0=9C=EC=A7=80=EB=8A=94=EC=A7=80=20=EB=B3=B8?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/viewer/server/diff.ts | 29 +++++++++++------------------ apps/viewer/server/fingerprint.ts | 4 +++- apps/viewer/server/refs.ts | 16 ++++++---------- 3 files changed, 20 insertions(+), 29 deletions(-) diff --git a/apps/viewer/server/diff.ts b/apps/viewer/server/diff.ts index b2dace1..33abf52 100644 --- a/apps/viewer/server/diff.ts +++ b/apps/viewer/server/diff.ts @@ -306,16 +306,13 @@ export const getDiffFiles = async ( // `rev-parse`·`show :`는 rev만 받아 영향이 없다(실측). // // `$`가 아니라 `gitText`다 — 큰 diff에서 출력이 64KB를 넘는다. - const nameStatus = await gitText([ - "-C", - repo, - "diff", - "--name-status", - "-z", - base, - ...(opts.head ? [opts.head] : []), - "--", - ]); + const nameStatus = opts.head + ? await $`git -C ${repo} diff --name-status -z ${base} ${opts.head} -- 2>/dev/null` + .nothrow() + .text() + : await $`git -C ${repo} diff --name-status -z ${base} -- 2>/dev/null` + .nothrow() + .text(); // 파일별 git show/워킹트리 읽기는 서로 독립이라 병렬화하되, 대형 diff에서 // git 서브프로세스가 무제한으로 뜨지 않도록 동시성을 제한한다 (순서 유지). const specs = parseNameStatusZ(nameStatus); @@ -329,14 +326,10 @@ export const getDiffFiles = async ( // 없으므로 건너뛴다(디스크를 훑어 봐야 그건 워킹트리의 사실이지 이 뷰의 // 사실이 아니다). if (opts.untracked && !opts.head) { - const listed = await gitText([ - "-C", - repo, - "ls-files", - "--others", - "--exclude-standard", - "-z", - ]); + const listed = + await $`git -C ${repo} ls-files --others --exclude-standard -z 2>/dev/null` + .nothrow() + .text(); const paths = listed.split("\0").filter((s) => s !== ""); files.push( ...(await mapWithLimit(paths, BUILD_CONCURRENCY, (path) => diff --git a/apps/viewer/server/fingerprint.ts b/apps/viewer/server/fingerprint.ts index a9e122c..1499c7c 100644 --- a/apps/viewer/server/fingerprint.ts +++ b/apps/viewer/server/fingerprint.ts @@ -30,7 +30,9 @@ export const repoFingerprint = async ( const [status, head, baseRev, headRev] = await Promise.all([ // `$`가 아니라 `gitText` — untracked를 켜면 출력이 64KB를 쉽게 넘고, watch가 // 2초마다 부르는 자리라 Bun 1.3.x `$`의 never-settle에 가장 오래 노출된다. - gitText(["-C", repo, "status", "--porcelain", "-z", untrackedFlag]), + $`git -C ${repo} status --porcelain -z ${untrackedFlag} 2>/dev/null` + .nothrow() + .text(), $`git -C ${repo} rev-parse HEAD 2>/dev/null`.nothrow().text(), opts.mode === "base" && opts.ref ? $`git -C ${repo} rev-parse ${opts.ref} 2>/dev/null`.nothrow().text() diff --git a/apps/viewer/server/refs.ts b/apps/viewer/server/refs.ts index 7bf1ca6..44bb239 100644 --- a/apps/viewer/server/refs.ts +++ b/apps/viewer/server/refs.ts @@ -5,7 +5,7 @@ * 개념이 아니라 git이 이미 갖고 있는 관계다: `%(worktreepath)`가 브랜치마다 * 그것을 물고 있는 워크트리를 알려준다. */ -import { gitText } from "./gitOutput.ts"; +import { $ } from "bun"; export interface WorktreeRecord { path: string; @@ -161,15 +161,11 @@ export const getRefs = async (repo: string): Promise => { // 둘 다 `$`가 아니라 `gitText` — 출력이 참조 수·등록된 워크트리 수에 // 비례해 64KB를 넘을 수 있다(워크트리는 디렉토리가 지워져도 prunable로 // 등록이 남으므로 약 300개면 넘는다). - gitText(["-C", repo, "worktree", "list", "--porcelain", "-z"]), - gitText([ - "-C", - repo, - "for-each-ref", - REF_FORMAT, - "refs/heads", - "refs/remotes", - ]), + $`git -C ${repo} worktree list --porcelain -z`.nothrow().quiet().text(), + $`git -C ${repo} for-each-ref ${REF_FORMAT} refs/heads refs/remotes` + .nothrow() + .quiet() + .text(), ]); const worktrees = parseWorktreeList(wtRaw); const live = new Set(worktrees.map((w) => w.path)); From 9016a3edd912ee86ef133638328abc95c1a3bf17 Mon Sep 17 00:00:00 2001 From: Penguin Date: Mon, 21 Sep 2026 16:37:11 +0900 Subject: [PATCH 4/4] =?UTF-8?q?Revert=20"test:=20[=EC=9E=84=EC=8B=9C=C2=B7?= =?UTF-8?q?=EB=90=98=EB=8F=8C=EB=A6=B4=20=EA=B2=83]=20=EB=8B=A4=EC=84=AF?= =?UTF-8?q?=20=ED=98=B8=EC=B6=9C=EC=9D=84=20$=EB=A1=9C=20=EB=90=98?= =?UTF-8?q?=EB=8F=8C=EB=A0=A4=20test-bun13=EC=9D=B4=20Linux=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B9=A8=EA=B0=9C=EC=A7=80=EB=8A=94=EC=A7=80=20?= =?UTF-8?q?=EB=B3=B8=EB=8B=A4"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux 러너에서 되돌린 코드로 test-bun13이 빨개지는 것을 확인했으므로 되돌린다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK --- apps/viewer/server/diff.ts | 29 ++++++++++++++++++----------- apps/viewer/server/fingerprint.ts | 4 +--- apps/viewer/server/refs.ts | 16 ++++++++++------ 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/apps/viewer/server/diff.ts b/apps/viewer/server/diff.ts index 33abf52..b2dace1 100644 --- a/apps/viewer/server/diff.ts +++ b/apps/viewer/server/diff.ts @@ -306,13 +306,16 @@ export const getDiffFiles = async ( // `rev-parse`·`show :`는 rev만 받아 영향이 없다(실측). // // `$`가 아니라 `gitText`다 — 큰 diff에서 출력이 64KB를 넘는다. - const nameStatus = opts.head - ? await $`git -C ${repo} diff --name-status -z ${base} ${opts.head} -- 2>/dev/null` - .nothrow() - .text() - : await $`git -C ${repo} diff --name-status -z ${base} -- 2>/dev/null` - .nothrow() - .text(); + const nameStatus = await gitText([ + "-C", + repo, + "diff", + "--name-status", + "-z", + base, + ...(opts.head ? [opts.head] : []), + "--", + ]); // 파일별 git show/워킹트리 읽기는 서로 독립이라 병렬화하되, 대형 diff에서 // git 서브프로세스가 무제한으로 뜨지 않도록 동시성을 제한한다 (순서 유지). const specs = parseNameStatusZ(nameStatus); @@ -326,10 +329,14 @@ export const getDiffFiles = async ( // 없으므로 건너뛴다(디스크를 훑어 봐야 그건 워킹트리의 사실이지 이 뷰의 // 사실이 아니다). if (opts.untracked && !opts.head) { - const listed = - await $`git -C ${repo} ls-files --others --exclude-standard -z 2>/dev/null` - .nothrow() - .text(); + const listed = await gitText([ + "-C", + repo, + "ls-files", + "--others", + "--exclude-standard", + "-z", + ]); const paths = listed.split("\0").filter((s) => s !== ""); files.push( ...(await mapWithLimit(paths, BUILD_CONCURRENCY, (path) => diff --git a/apps/viewer/server/fingerprint.ts b/apps/viewer/server/fingerprint.ts index 1499c7c..a9e122c 100644 --- a/apps/viewer/server/fingerprint.ts +++ b/apps/viewer/server/fingerprint.ts @@ -30,9 +30,7 @@ export const repoFingerprint = async ( const [status, head, baseRev, headRev] = await Promise.all([ // `$`가 아니라 `gitText` — untracked를 켜면 출력이 64KB를 쉽게 넘고, watch가 // 2초마다 부르는 자리라 Bun 1.3.x `$`의 never-settle에 가장 오래 노출된다. - $`git -C ${repo} status --porcelain -z ${untrackedFlag} 2>/dev/null` - .nothrow() - .text(), + gitText(["-C", repo, "status", "--porcelain", "-z", untrackedFlag]), $`git -C ${repo} rev-parse HEAD 2>/dev/null`.nothrow().text(), opts.mode === "base" && opts.ref ? $`git -C ${repo} rev-parse ${opts.ref} 2>/dev/null`.nothrow().text() diff --git a/apps/viewer/server/refs.ts b/apps/viewer/server/refs.ts index 44bb239..7bf1ca6 100644 --- a/apps/viewer/server/refs.ts +++ b/apps/viewer/server/refs.ts @@ -5,7 +5,7 @@ * 개념이 아니라 git이 이미 갖고 있는 관계다: `%(worktreepath)`가 브랜치마다 * 그것을 물고 있는 워크트리를 알려준다. */ -import { $ } from "bun"; +import { gitText } from "./gitOutput.ts"; export interface WorktreeRecord { path: string; @@ -161,11 +161,15 @@ export const getRefs = async (repo: string): Promise => { // 둘 다 `$`가 아니라 `gitText` — 출력이 참조 수·등록된 워크트리 수에 // 비례해 64KB를 넘을 수 있다(워크트리는 디렉토리가 지워져도 prunable로 // 등록이 남으므로 약 300개면 넘는다). - $`git -C ${repo} worktree list --porcelain -z`.nothrow().quiet().text(), - $`git -C ${repo} for-each-ref ${REF_FORMAT} refs/heads refs/remotes` - .nothrow() - .quiet() - .text(), + gitText(["-C", repo, "worktree", "list", "--porcelain", "-z"]), + gitText([ + "-C", + repo, + "for-each-ref", + REF_FORMAT, + "refs/heads", + "refs/remotes", + ]), ]); const worktrees = parseWorktreeList(wtRaw); const live = new Set(worktrees.map((w) => w.path));