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
3 changes: 2 additions & 1 deletion .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

177 changes: 177 additions & 0 deletions apps/viewer/__tests__/git-large-output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
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` 케이스만 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로 돌린다. 개수 단언은 버전 무관. 그 잡은
* 설치 없이 돌므로 픽스처도 `$` 대신 `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_CALLS = 16; // getRefs만 — 위 docblock 참고
const REF_ROUNDS = 5;
const SETTLE_MS = 15_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)}`;

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);
}, 60_000); // 훅의 기본 상한 5초는 부하가 걸리면 넘는다(평소 1.2~1.8초, 한 번 넘는 것을 실측)

afterAll(() => {
rmSync(repo, { recursive: true, force: true });
});

const settleWithin = async <T>(work: Promise<T>, what: string): Promise<T> => {
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<never>((_, 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 <T>(
what: string,
fn: () => Promise<T>,
{ calls = CALLS, rounds = ROUNDS } = {},
): Promise<T[]> => {
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);
},
timeoutFor(ROUNDS),
);

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);
}
},
timeoutFor(ROUNDS),
);

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);
}
},
timeoutFor(ROUNDS),
);

test(
"getRefs settles under concurrent calls when `for-each-ref` exceeds 64KB",
async () => {
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);
}
},
timeoutFor(REF_ROUNDS),
);
12 changes: 6 additions & 6 deletions apps/viewer/__tests__/git-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +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`의 순차 호출과 `worktree list`는 멈춤을 재현하지
* 못했다), 확실히 걸리는 모양 — 200KB `git show` 12개를 8-way로 — 으로 헬퍼 자체를
* 찌른다.
*
* 판별력은 `diff-large-blob.test.ts`와 같다: 행업 단언은 1.3.x에서만 갈리므로
Expand Down
33 changes: 21 additions & 12 deletions apps/viewer/server/diff.ts
Original file line number Diff line number Diff line change
@@ -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 서브프로세스가 뜨므로 무제한이면
Expand Down Expand Up @@ -304,13 +304,18 @@ export const getDiffFiles = async (
// 위치 인자로 처음 통과시키면서 열린 노출이다 — 예전엔 언제나
// `HEAD` 아니면 merge-base OID였다. `merge-base`·`rev-list`·
// `rev-parse`·`show <rev>:<path>`는 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);
Expand All @@ -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) =>
Expand Down
7 changes: 4 additions & 3 deletions apps/viewer/server/fingerprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -27,9 +28,9 @@ export const repoFingerprint = async (
): Promise<string> => {
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()
Expand Down
10 changes: 6 additions & 4 deletions apps/viewer/server/gitOutput.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
/**
* git을 `$`가 아니라 `Bun.spawn`으로 부르고 stdout을 끝까지 읽는다. 출력이
* 64KB를 넘을 수 있는 **새** git 호출은 여기를 탄다 — 아직 `$`로 남은 호출은
* CLAUDE.md "Loading…" 항목에 있다.
* 64KB를 넘을 수 있는 서버의 git 호출은 전부 여기를 탄다 — 남은 `$`는
* `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`으로는
* 수천 번 돌려도 걸리지 않았다.
*
Expand All @@ -17,7 +19,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[],
Expand Down
19 changes: 13 additions & 6 deletions apps/viewer/server/refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 개념이 아니라 git이 이미 갖고 있는 관계다: `%(worktreepath)`가 브랜치마다
* 그것을 물고 있는 워크트리를 알려준다.
*/
import { $ } from "bun";
import { gitText } from "./gitOutput.ts";

export interface WorktreeRecord {
path: string;
Expand Down Expand Up @@ -158,11 +158,18 @@ export const getRefs = async (repo: string): Promise<RefsResult> => {
// 읽게 만들어 브랜치가 많은 리포에서 비용을 지배한다 — 목록은 검색으로
// 찾는 것이고, 기본(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를 넘을 수 있다(워크트리는 디렉토리가 지워져도 prunable로
// 등록이 남으므로 약 300개면 넘는다).
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));
Expand Down
8 changes: 5 additions & 3 deletions apps/viewer/server/singleFlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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인 채로 남지만 더는 아무도
Expand Down
Loading