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
5 changes: 4 additions & 1 deletion .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ jobs:
# 이 잡이 회귀와 무관하게 빨개진다 — 그때 쉬운 오답은 고정 판을 올리는 것이고,
# 그러면 이 잡의 존재 이유가 사라진다.
- name: Bun 1.3.x `$` never-settle regression
run: bun test apps/viewer/__tests__/diff-large-blob.test.ts
run: >-
bun test
apps/viewer/__tests__/diff-large-blob.test.ts
apps/viewer/__tests__/git-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.

2 changes: 1 addition & 1 deletion apps/viewer/__tests__/diff-large-blob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { getDiffFiles } from "../server/diff.ts";
*
* **판별력은 Bun 버전에 달렸다**: 업스트림이 1.4.0에서 고쳐, 1.4 이상에서는
* `showBytes`를 `$`로 되돌려도 이 테스트가 통과한다. 1.3.x에서는 되돌리면
* 타임아웃으로 죽는다 — 그래서 CI의 `test-bun13` 잡이 이 파일만 Bun 1.3.14로
* 타임아웃으로 죽는다 — 그래서 CI의 `test-bun13` 잡이 이 파일을 Bun 1.3.14로
* 고정해 돌린다. 내용 단언은 버전과 무관하게 큰 blob을 끝까지 읽는지를 지킨다.
*/

Expand Down
95 changes: 95 additions & 0 deletions apps/viewer/__tests__/git-output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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 { 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로 — 으로 헬퍼 자체를
* 찌른다.
*
* 판별력은 `diff-large-blob.test.ts`와 같다: 행업 단언은 1.3.x에서만 갈리므로
* CI의 `test-bun13` 잡이 이 파일도 Bun 1.3.14로 돌린다. 내용 단언은 버전 무관.
*/

const FILES = 12;
const LINES = 2000; // 100바이트 × 2000줄 = 200KB — 64KB 버퍼의 세 배
const ROUNDS = 3;
const SETTLE_MS = 10_000;

const content = (name: string): string =>
`${name}\n${`${"y".repeat(99)}\n`.repeat(LINES)}`;

let repo: string;

beforeEach(async () => {
repo = mkdtempSync(join(tmpdir(), "cc-git-output-"));
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), content(name));
}
writeFileSync(join(repo, "한글.txt"), "안녕 — 세계\n");
await $`git -C ${repo} add -A`;
await $`git -C ${repo} commit -qm base`;
});

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(`gitText 버스트가 ${ms}ms 안에 settle하지 않았다`)),
ms,
);
});
try {
return await Promise.race([work, deadline]);
} finally {
clearTimeout(timer);
}
};

test(
"reads many >64KB outputs to the end, concurrently and repeatedly, without hanging",
async () => {
const names = Array.from({ length: FILES }, (_, i) => `big${i}.txt`);
for (let round = 0; round < ROUNDS; round++) {
const texts = await settleWithin(
mapWithLimit(names, 8, (name) =>
gitText(["-C", repo, "show", `HEAD:${name}`]),
),
SETTLE_MS,
);
for (const [i, text] of texts.entries()) {
const name = `big${i}.txt`;
expect(text.length).toBe(name.length + 1 + 100 * LINES);
expect(text).toBe(content(name));
}
}
},
ROUNDS * SETTLE_MS + 5_000,
);

test("decodes stdout as UTF-8", async () => {
expect(await gitText(["-C", repo, "show", "HEAD:한글.txt"])).toBe(
"안녕 — 세계\n",
);
});

test("ignores the exit code: a missing rev:path yields an empty string", async () => {
expect(await gitText(["-C", repo, "show", "HEAD:nope.txt"])).toBe("");
});
26 changes: 7 additions & 19 deletions apps/viewer/server/diff.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFileSync } from "node:fs";
import { isAbsolute, join, resolve } from "node:path";
import { $ } from "bun";
import { gitBytes } from "./gitOutput.ts";
import { mapWithLimit } from "./mapLimit.ts";

// buildFile 병렬 실행 상한 — 파일당 git 서브프로세스가 뜨므로 무제한이면
Expand Down Expand Up @@ -133,28 +134,15 @@ 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 (
// `$`가 아니라 `gitBytes`(Bun.spawn)다 — 여기가 `getDiffFiles`의 8-way 버스트라
// Bun 1.3.x `$`의 never-settle을 가장 확실하게 밟던 자리다(근거와 동작 계약은
// gitOutput.ts). 회귀망: `diff-large-blob.test.ts`.
const showBytes = (
repo: string,
rev: string,
path: string,
): Promise<Uint8Array<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);
};
): Promise<Uint8Array<ArrayBuffer>> =>
gitBytes(["-C", repo, "show", `${rev}:${path}`]);

const readWorkingBytes = (
repo: string,
Expand Down
35 changes: 35 additions & 0 deletions apps/viewer/server/gitOutput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* git을 `$`가 아니라 `Bun.spawn`으로 부르고 stdout을 끝까지 읽는다. 출력이
* 64KB를 넘을 수 있는 **새** git 호출은 여기를 탄다 — 아직 `$`로 남은 호출은
* CLAUDE.md "Loading…" 항목에 있다.
*
* Bun 1.3.x의 `$`는 64KB를 넘는 stdout을 받는 호출에서 자식이 이미 끝났는데도
* promise가 영영 settle하지 않을 수 있다 — 호출이 겹치면 거의 확정이고 완전
* 순차여도 결국 걸린다(1.3.12·1.3.14 실측, macOS·Linux 모두; 업스트림은 1.4.0에서
* 수정). `getDiffFiles`의 8-way `showBytes` 버스트가 그 모양이라 큰 blob이 섞인
* diff가 통째로 45초 flight 타임아웃 → 503이 됐고, 같은 작업을 `Bun.spawn`으로는
* 수천 번 돌려도 걸리지 않았다.
*
* 동작은 `$ … 2>/dev/null` + `.nothrow()`와 같다: 종료 코드를 보지 않고 stdout만
* 읽고(없는 rev:path는 빈 출력), 스폰 자체가 실패하면(cwd 삭제) 둘 다 throw한다.
* stdout을 먼저 비우고 `exited`를 기다린다 — 지금 Bun은 파이프를 선제
* 버퍼링해 반대 순서도 교착하지 않지만(1.3.12, 50MB까지 실측) 그 구현 세부에
* 기대지 않는다.
* 인자는 셸을 거치지 않고 argv로 그대로 간다(옵션 꼴 참조를 막는 건 여전히
* 호출자 몫이다 — `verifyBaseRef`). 회귀망: `git-output.test.ts`,
* `diff-large-blob.test.ts`.
*/
export const gitBytes = async (
args: readonly string[],
): Promise<Uint8Array<ArrayBuffer>> => {
const proc = Bun.spawn(["git", ...args], {
stdout: "pipe",
stderr: "ignore",
});
const buf = await new Response(proc.stdout).arrayBuffer();
await proc.exited;
return new Uint8Array(buf);
};

export const gitText = async (args: readonly string[]): Promise<string> =>
new TextDecoder().decode(await gitBytes(args));
58 changes: 33 additions & 25 deletions apps/viewer/server/summary.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { $ } from "bun";
import { resolveDiffBaseRev } from "./diff.ts";
import { gitText } from "./gitOutput.ts";

/**
* 빈 diff 화면의 정보형 빈 상태 전용 경량 요약. diff가 0건일 때만 lazy하게
Expand Down Expand Up @@ -40,31 +40,28 @@ export const getRepoSummary = async (
// 워크트리의 브랜치를 말하면 보고 있지도 않은 곳을 가리킨다.
const branch = opts.head
? opts.head
: (
await $`git -C ${repo} branch --show-current 2>/dev/null`
.nothrow()
.text()
).trim();
: (await gitText(["-C", repo, "branch", "--show-current"])).trim();
const head = (
await $`git -C ${repo} rev-parse --short ${opts.head ?? "HEAD"} 2>/dev/null`
.nothrow()
.text()
await gitText(["-C", repo, "rev-parse", "--short", opts.head ?? "HEAD"])
).trim();
// 커밋된 리비전에는 미커밋 변경도 untracked도 없다. 0이 아니라 null인
// 이유는 위 필드 주석에 있다 — 재지 않은 것을 0으로 적으면 주장이 된다.
const workingFiles = opts.head
? null
: countZ(
await $`git -C ${repo} diff --name-only -z HEAD -- 2>/dev/null`
.nothrow()
.text(),
await gitText(["-C", repo, "diff", "--name-only", "-z", "HEAD", "--"]),
);
const untrackedFiles = opts.head
? null
: countZ(
await $`git -C ${repo} ls-files --others --exclude-standard -z 2>/dev/null`
.nothrow()
.text(),
await gitText([
"-C",
repo,
"ls-files",
"--others",
"--exclude-standard",
"-z",
]),
);
let baseFiles: number | null = null;
let aheadCommits: number | null = null;
Expand All @@ -79,18 +76,29 @@ export const getRepoSummary = async (
// 끝의 `--`는 diff.ts의 같은 이유다 — 참조 이름이 경로와 겹치면
// `ambiguous argument`가 나고 nothrow가 그것을 0으로 삼킨다.
baseFiles = countZ(
opts.head
? await $`git -C ${repo} diff --name-only -z ${mergeBase} ${opts.head} -- 2>/dev/null`
.nothrow()
.text()
: await $`git -C ${repo} diff --name-only -z ${mergeBase} -- 2>/dev/null`
.nothrow()
.text(),
await gitText(
opts.head
? [
"-C",
repo,
"diff",
"--name-only",
"-z",
mergeBase,
opts.head,
"--",
]
: ["-C", repo, "diff", "--name-only", "-z", mergeBase, "--"],
),
);
const ahead = (
await $`git -C ${repo} rev-list --count ${`${mergeBase}..${opts.head ?? "HEAD"}`} 2>/dev/null`
.nothrow()
.text()
await gitText([
"-C",
repo,
"rev-list",
"--count",
`${mergeBase}..${opts.head ?? "HEAD"}`,
])
).trim();
aheadCommits = /^\d+$/.test(ahead) ? Number(ahead) : null;
}
Expand Down
Loading