Skip to content

fix: 큰 blob이 섞인 diff가 45초 뒤 503으로 떨어지던 것을 고친다 - #74

Merged
say8425 merged 3 commits into
mainfrom
fix/show-bytes-bun-spawn
Sep 21, 2026
Merged

say8425 merged 3 commits into
mainfrom
fix/show-bytes-bun-spawn

Conversation

@say8425

@say8425 say8425 commented Sep 21, 2026

Copy link
Copy Markdown
Owner

큰 파일이 섞인 diff를 열면 매번 45초 뒤 503이 나고, 클라이언트는 1회 재시도한 뒤 Failed to load diff.에서 멈췄다. 실측: raycast-extensions(91k 파일 리포)의 HEAD~50 대비 556파일 diff가 3/3 503 / 45.01s.

그런데 구성 요소는 전부 빨랐다 — merge-base 10ms, diff --name-status 230ms, git show 8ms/회. 같은 getDiffFiles를 서버 밖에서 한 번 돌리면 449ms에 끝났다. 느린 게 아니라 안 끝나고 있었다.

원인은 메모리·CLAUDE.md에 "재현이 어렵다"고 적혀 있던 그 Bun $ never-settle 행이고, 이번에 트리거가 확정됐다:

Bun 1.3.x의 $는 64KB 파이프 버퍼를 넘는 stdout을 받는 호출에서, 자식이 이미 끝났는데도 promise가 영영 settle하지 않을 수 있다. 호출이 겹치면 거의 확정이고, 완전 순차여도 결국 걸린다.

getDiffFiles의 8-way showBytes 버스트가 정확히 그 모양이다. 예전 진단(7/24)은 헤드리스 Chromium 기동 같은 외부 프로세스 경합이 필요하다고 봤는데 틀렸다 — 큰 blob만 있으면 서버 없이도, 첫 호출에서 죽는다. 작은 파일만 쓰는 픽스처로는 우연히만 밟혀서 재현에 42회 실행이 필요했던 것이다.

무엇을

showBytes 한 함수만 바꾼다: $Bun.spawn(argv, { stdout: "pipe", stderr: "ignore" }).

동작은 $ … 2>/dev/null + .nothrow()와 같다(둘 다 실측):

  • 없는 rev:path → 둘 다 빈 바이트(종료 코드 무시)
  • cwd 삭제로 스폰 자체가 실패 → 둘 다 throw (daemon-cwd.e2e.ts 통과)

getDiffFiles·buildFile·payloadCache·etag·지문은 하나도 안 바뀐다.

부수 효과: /api/blob(이미지 카드)의 old 쪽과 head 뷰의 new 쪽도 같은 showBytes를 탄다. 이미지는 흔히 64KB를 넘고 이 라우트는 클라이언트 재시도가 없으므로 같은 위험이 거기서도 사라진다. 다만 이미지 경로의 행업은 따로 재현하지 않았다.

실측 근거

트리거 격리 (Bun 1.3.12, 8-way, $):

픽스처 결과
1.5KB × 600 clean 15/15 × 3 (27,000 스폰, 행 0)
60KB × 14 + 소형 136 (버퍼 미만) clean 15/15 × 3
200KB × 12 HANG@round1
200KB × 32, 완전 순차(limit=1) HANG@round14 — 동시성은 확률만 올린다
200KB × 8 (한 파도로 동시 시작·종료) clean 20/20

배제한 가설: 부분 클론 지연 페치(blob이 따뜻해도 45초), fd 고갈(31개/한도 1,048,576, 누수 0), Bun.serve 핸들러(서버 없이도 행), 리포 크기. 행 중 자식 0·좀비 0.

버전 매트릭스 ($, 행업 픽스처):

Bun 결과
1.3.12 · 1.3.14 HANG@round1
1.4.0 · 1.4.2 clean 30/30

→ 1.3.x 전 계열 영향, 업스트림은 1.4.0에서 수정(해당 이슈는 업스트림에 없다). bunx는 사용자의 Bun을 쓰므로 1.3.x 사용자는 여전히 있다 — 이 머신도 1.3.12다.

Bun.spawn: 같은 행업 픽스처 전부에서 1.3.12 기준 clean 30/30 × 3 (수천 번 스폰, 행 0). 속도는 $와 같다(556파일 ~730ms).

실제 리포에서 (수정 빌드, 한 서버 프로세스에서 base를 바꿔 매번 콜드 버스트): 363819파일 10회 전부 200. 556파일은 503 / 45s200 / 0.59s. HEAD60(819파일)이 첫 회만 14초였는데, 부분 클론이 처음 보는 blob을 원격에서 당겨온 1회성 비용이다 — blob이 로컬에 있는 상태로 새 서버에서 다시 재면 1.5초(2회).

그대로 두는 것

자가 치유 3부품(singleFlight 타임아웃 race + 503/Retry-After + 1회 재시도, #54)은 남긴다. 64KB를 넘을 수 있는 단발 $가 아직 있다 — 큰 diff의 git diff --name-status -z, untracked를 켰을 때의 ls-files --others, 지문의 status --porcelain -z -uall(watch 폴마다). 완전 순차도 결국 죽으므로 확률은 낮아도 0이 아니다. 이 PR에선 손대지 않았다.

문서·주석

CLAUDE.md "Loading…" 자가 치유 항목의 원인 서술과 singleFlight.ts 머리 주석을 고쳤다(둘 다 기각된 원인인 외부 경합을 말하고 있었다). 그대로 두면 리포가 틀린 원인을 말하고, showBytesBun.spawn을 누군가 $로 "정리"해도 막을 근거가 없다. 남은 $는 예시로 적되 자가 치유가 어디까지 닿는지 호출별로 갈랐다(diffFlight 안 ①②③ / refsFlight 안 ①② / flight 밖 summary.ts는 없음). 새 git 호출의 출력이 64KB를 넘을 수 있으면 Bun.spawn으로 쓴다는 규칙도 CLAUDE.md에 적었다.

테스트 플랜

bun test              # 788 pass
bun run test:coverage # 100% 게이트 통과 (diff.ts 100/100)
bun run typecheck     # 통과
bun run lint          # 에러 0, 경고 21 = main과 동일 (신규 0)
bun run format:check  # 통과
bunx playwright test  # 111 passed (apps/viewer에서)

회귀망: diff-large-blob.test.ts — 200KB × 16파일을 getDiffFiles로 3회 연속 읽고, 매번 10초 안에 settle하는지와 old/new 내용이 바이트 단위로 같은지 단언한다.

뮤테이션 Bun 1.3.12 Bun 1.4.2
수정 전($) getDiffFiles가 10000ms 안에 settle하지 않았다 (3/3)
수정 후(Bun.spawn) ✅ (5/5)
첫 청크만 읽기 Expected 200009, Received 98304

⚠️ 행업 판별력은 1.3.x에서만 있다. CI의 oven-sh/setup-bun@v2bun-version이 없어 최신(1.4.x)을 쓰므로, showBytes$로 되돌려도 CI는 초록이다. 그 한계는 테스트 주석과 CLAUDE.md에 적어 두었다. 내용 단언(마지막 행)은 버전과 무관하게 잡는다.

보안: /api/blobpath와 URL의 base/head가 흘러드는 서브프로세스 인자 전달 방식이 바뀌므로 security review를 돌렸다 — 신규 취약점 0건. 인자는 여전히 ${rev}:${path} 한 덩어리이고 항상 verifyBaseRef를 통과한 rev로 시작하므로 옵션으로 읽히지 않으며, 셸을 아예 거치지 않아 같거나 더 안전하다. NUL 바이트는 두 방식 모두 인자가 git에 닿기 전에 throw한다.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK

say8425 and others added 3 commits September 21, 2026 11:52
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK
- 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK
- 직전 커밋이 "세 부품이 남은 `$`를 지킨다"며 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK
@say8425
say8425 merged commit 94800b3 into main Sep 21, 2026
5 checks passed
@say8425
say8425 deleted the fix/show-bytes-bun-spawn branch September 21, 2026 05:28
say8425 added a commit that referenced this pull request Sep 21, 2026
* fix: 출력이 64KB를 넘을 수 있는 나머지 git 호출도 Bun.spawn 헬퍼로 읽는다

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK

* fix: 리뷰 반영 — for-each-ref 회귀망을 16-way로, worktree list도 헬퍼로

- 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK

* test: [임시·되돌릴 것] 다섯 호출을 $로 되돌려 test-bun13이 Linux에서 빨개지는지 본다

* Revert "test: [임시·되돌릴 것] 다섯 호출을 $로 되돌려 test-bun13이 Linux에서 빨개지는지 본다"

Linux 러너에서 되돌린 코드로 test-bun13이 빨개지는 것을 확인했으므로 되돌린다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAe9zh6siLHSR4nPpqm8QK

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant