Skip to content

Commit 2eb7499

Browse files
committed
docs(plan): separate the Windows CI red into three failure modes
"Windows CI is flaky" was covering three failures with different causes, and reading them as one random flake is why the blame kept moving to an arbitrary test each run. Mode A is Bun crashing (no test fails at all). Mode B is our own tests overrunning the 5s default budget (no crash at all). The two never co-occur -- checked across ten failing runs. Mode C is the job ceiling and concurrency cancels, already handled. Two hypotheses died here. The unterminated-worker theory is refuted: 1a46299 shipped the worker-exit await, is an ancestor of every panicking commit, and run 30606401943 crashed with workers_spawned(9)/terminated(9) -- the counts matched. The process-lifetime accumulation theory is refuted too: the crash lands at file 30 of 456, and files run alphabetically, so the position belongs to the suite rather than to how long the process has lived. That also demotes sharding as a Mode A remedy. Corrects two errors in 120_wp_ci_windows_timeout: --isolate is same-process per-file realm isolation, not one process per file (--parallel is the process one, which we do not use), and --isolate is not CI-only -- scripts/test.ts passes it as well. Investigation only, as asked. No source, test, or workflow file is touched, and Mode A has no upgrade path since 1.3.14 is current.
1 parent 993e39a commit 2eb7499

1 file changed

Lines changed: 303 additions & 0 deletions

File tree

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
# 000 — Windows Bun 안정성 조사 (조사 전용)
2+
3+
`dev``windows-latest` CI가 하루에 여러 번 빨갛게 뜬다. 사용자 요청은 **조사와 문서화
4+
**이며 이 단위는 어떤 패치도 적용하지 않는다. 목적은 "무엇이 깨지는가"를 실패 모드별로
5+
분리하고, 각 모드에 대해 근거 있는 개선 후보를 남기는 것이다.
6+
7+
수집일 2026-07-31. CI 런 ID와 커밋 SHA는 모두 실제 조회 결과다.
8+
9+
## 요약 — 하나의 증상이 아니라 세 가지다
10+
11+
"Windows CI가 불안정하다"는 한 문장이 서로 원인이 다른 세 가지를 덮고 있었다. 이걸 섞어
12+
보면 "매번 다른 테스트가 실패하는 무작위 플레이크"로 보이지만, 로그를 분리하면 패턴이
13+
분명하다.
14+
15+
| 모드 | 지문 | 원인 위치 | 고칠 수 있는 주체 |
16+
|------|------|-----------|-------------------|
17+
| A. Bun 런타임 크래시 | `oh no: Bun has crashed`, `(fail)`**0개** | Bun 1.3.14 Windows | 업스트림 (우리는 회피/트리거 축소) |
18+
| B. 테스트 실패 (주로 5초 예산 초과) | `(fail)`**1개 이상**, 크래시 **0개** | 우리 테스트 | 우리 |
19+
| C. 잡 타임아웃 / 동시성 취소 | `cancelled`, 잡 20분 한도 | 워크플로 설정 | 우리 (이미 조치됨) |
20+
21+
**지문 주의:** 크래시 판별에 `panic(thread`를 쓰면 안 된다. 스레드 표기가 런마다 다르다 —
22+
30613324981은 `panic(thread 2852)`, 30606401943은 `panic(main thread)`다. 안정적인 지문은
23+
`oh no: Bun has crashed` 또는 `Internal assertion failure`다. 마찬가지로 모드 B의
24+
`timed out after 5000ms``(fail)` 줄이 아니라 그 아래 별도 줄(`^ this test timed out...`)에
25+
찍히므로, `(fail)` 줄만 grep하면 안 걸린다.
26+
27+
A와 B는 **상호 배타적**이다. 추정이 아니라 로그 카운트다.
28+
29+
```
30+
run 30613324981 (모드 A): "(fail)" 0개, 크래시 1개
31+
run 30615086938 (모드 B): 크래시 0개, "(fail)" 2개
32+
```
33+
34+
감사에서 실패 런 8건(30612358421, 30611549111, 30610504499, 30609285180, 30600828044,
35+
30597652077, 30595616725, 30594516343)으로 표본을 넓혔는데, 전부 `(fail)` > 0이고 크래시는
36+
0이었다. 배타성은 2건이 아니라 10건에서 성립한다.
37+
38+
패닉 런에는 실패한 테스트가 단 하나도 없고, 테스트 실패 런에는 크래시가 없다. "매번 범인이
39+
바뀌는 무작위 실패"라는 인상은 두 개의 다른 고장을 한 바구니에 담아서 생긴 착시다.
40+
41+
## 모드 A — Bun 패닉 (외부 결함)
42+
43+
### 지문
44+
45+
```
46+
Features: ... workers_spawned(9) workers_terminated(8)
47+
panic(thread 2852): Internal assertion failure
48+
oh no: Bun has crashed. This indicates a bug in Bun, not your code.
49+
https://bun.report/1.3.14/wt10d9b296izBukooC+7xB+inBk9/...
50+
```
51+
52+
### 크래시 지점 (표본 2건)
53+
54+
패닉 런 두 건(30613324981, 30606401943)의 마지막 테스트 그룹이 **둘 다 동일**하다. 표본이
55+
2건뿐이므로 "항상"이 아니라 "지금까지 2/2"로 읽어야 한다.
56+
57+
```
58+
##[group]tests\api-storage-cleanup.test.ts:
59+
##[group]tests\api-storage-policy.test.ts: ← 여기가 마지막
60+
```
61+
62+
`api-storage-policy``src/storage/policy-job.ts:238``new Worker()` 경로를 태운다. 다만
63+
**워커를 띄우는 유일한 스위트는 아니다**`tests/storage-worker-lifecycle.test.ts`,
64+
`storage-policy-job-responsive`, `storage-restore-job-*`, `storage-mutation-race`도 워커
65+
경로를 건드린다(`src/storage/restore-job.ts:130`에도 `new Worker()`가 있다). 따라서 "유일한
66+
워커 스위트라서 여기서 죽는다"는 설명은 성립하지 않는다.
67+
68+
**파일 순서를 감안해야 한다.** Bun은 파일을 알파벳 순으로 도는데, 통과 런(30616856523)의
69+
456개 그룹에서 `api-storage-policy`는 30번째 근처(`api-debug``api-storage-cleanup`
70+
`api-storage-policy``api-storage``api-usage`)다. 즉 크래시는 전체의 7% 지점에서
71+
일어난다. 이건 "오래 돌다가 상태가 누적돼서"라는 설명과 맞지 않는다.
72+
73+
`bun.report` URL은 두 런에서 앞부분(`wt10d9b296izBukooC+7xB+inBk9`)이 동일하다. 다만 이
74+
공통 구간은 플랫폼/버전/빌드 메타데이터일 수 있고 뒷부분은 서로 다르며, 스레드 표기도
75+
`main thread` vs `thread 2852`로 갈린다. **같은 어서션이라고 단정할 근거는 아니다** — 같은
76+
계열일 가능성을 시사하는 정황으로만 취급한다.
77+
78+
### 이미 방어를 넣었고, 그런데도 재발했다
79+
80+
`1a46299b5 fix(storage): wait for storage workers to exit before the test file boundary`
81+
`src/storage/worker-lifecycle.ts`를 추가해 워커 종료를 `close` 이벤트로 await 한다.
82+
83+
문제는 **그 커밋 이후 런에서도 패닉이 났다**는 것이다.
84+
85+
| run | 커밋 | worker 방어 포함 | 결과 |
86+
|-----|------|------------------|------|
87+
| 30606401943 | 12eae74ec | 포함 | panic |
88+
| 30608166216 | bf9bc1ac8 | 포함 | 타임아웃 |
89+
| 30613324981 | ebd4cdf02 | 포함 | panic |
90+
91+
30606401943의 크래시 헤더는 `workers_spawned(9) workers_terminated(9)`**숫자가
92+
맞는데도** 죽었다. 이건 중요한 반증이다. 원래 가설(미종료 워커 1개가 어서션을 건드린다)은
93+
`8 vs 9` 불일치를 근거로 삼았는데, 9/9인 런도 같은 지점에서 같은 어서션으로 죽었다.
94+
95+
즉 워커 카운트 불일치는 **원인이 아니라 동반 증상**일 가능성이 높다. 방어 자체는
96+
무해하고 유지할 가치가 있지만, 이걸로 모드 A가 닫혔다고 볼 수 없다.
97+
98+
### `--isolate`의 실제 의미 (오해 정정 2건)
99+
100+
기존 devlog(`120_wp_ci_windows_timeout.md:56-57`)는 `--isolate`에 대해 두 가지를 적었는데
101+
**둘 다 사실과 다르다.**
102+
103+
**정정 1 — 프로세스 격리가 아니다.** 그 문서는 `--isolate`를 "테스트별 프로세스 격리"로
104+
적고, Windows 프로세스 생성 비용이 2.5배 격차의 원인일 것으로 추정했다.
105+
106+
Bun v1.3.13 릴리스 노트 기준으로 `--isolate`**같은 프로세스 안에서 파일마다 새 realm
107+
(fresh global)을 만드는** 기능이다. 프로세스를 파일당 하나씩 띄우는 것이 아니다. 프로세스를
108+
실제로 나누는 건 `--parallel=N` 쪽이고, 우리는 그 플래그를 쓰지 않는다.
109+
110+
따라서 "프로세스 생성 비용" 가설은 근거가 약하다. 한 프로세스가 realm만 갈아끼우며 456파일을
111+
끝까지 들고 간다는 구조 자체는 사실이지만, 위에서 본 대로 크래시가 30번째 파일에서 나므로
112+
**"장시간 누적" 설명도 성립하지 않는다.** 현재 증거가 가리키는 건 프로세스 수명이 아니라
113+
`api-storage-policy` 스위트가 하는 일(워커 스레드 생성/종료)과 Bun isolate의 realm 정리가
114+
Windows에서 만나는 지점이다. 다만 워커를 띄우는 다른 스위트에서는 죽지 않으므로, 이 스위트만의
115+
무엇이 남았는지는 아직 규명되지 않았다.
116+
117+
**정정 2 — 로컬에도 `--isolate`가 있다.** 그 문서는 "`--isolate`는 CI에만 있고
118+
`package.json`의 로컬 `test` 스크립트에는 없다"고 적었다. 실제로는 `package.json`
119+
`"test": "bun scripts/test.ts"``scripts/test.ts:122`에서
120+
`[process.execPath, "test", "--isolate", ...]`로 자식을 띄운다. 즉 로컬 `bun run test`
121+
CI와 동일하게 `--isolate`로 돈다.
122+
123+
이 정정은 실무적으로 중요하다. "로컬은 격리 없이 도니까 CI만의 문제"라는 추론이 성립하지
124+
않는다는 뜻이고, 반대로 로컬 macOS에서 같은 플래그로 패닉이 재현되지 않는다는 사실이
125+
**Windows 고유성**을 더 강하게 뒷받침한다.
126+
127+
### Bun 업그레이드로는 못 피한다
128+
129+
조사 시점(2026-07-31) 기준 **1.3.14가 최신 안정 버전**으로 확인됐다(2026-05-12 릴리스 공지).
130+
더 올릴 곳이 없다. 다만 검색 중 "Bun 1.4 regression"을 제목에 단 이슈가 보였으므로, 이
131+
문장을 근거로 삼기 전에 릴리스 페이지를 직접 열어 재확인하는 것이 안전하다.
132+
133+
1.3.14가 고친 것들은 우리 증상과 인접하지만 일치하지 않는다: macOS isolate GC 경쟁,
134+
isolate/parallel 모드의 NAPI finalizer 크래시, Windows 자식 프로세스 teardown과 일부
135+
libuv/경로 패닉. 즉 **isolate 불안정성 자체가 1.3.14에서 끝나지 않았다** — 1.3.14에서
136+
보고된 isolate 세그폴트(oven-sh/bun#35301)와 isolate 워커 데드락(#36235)이 존재한다.
137+
138+
공개 이슈 중 우리 증상 4가지(Windows + 1.3.14 + `--isolate` + Internal assertion)를
139+
**동시에** 만족하는 건 찾지 못했다. 이건 없다는 증명이 아니라 미발견이다.
140+
141+
인용한 isolate 관련 이슈 번호(oven-sh/bun#35301, #36235)는 검색 단계에서 수집한 것으로
142+
원문을 직접 열어 확인하지 않았다. 업스트림 리포트를 쓸 때는 먼저 열어볼 것.
143+
144+
### 러너 이미지
145+
146+
실패 런의 이미지는 `windows-2025-vs2026`, 릴리스 태그 `win25-vs2026/20260728.188`이다.
147+
`windows-latest`는 2026-06 중 Windows Server 2025 / VS 2026으로 이미 이관됐다. 저장소는
148+
public이라 GitHub-hosted 러너의 표준 사양을 쓴다.
149+
150+
## 모드 B — 5초 예산 초과 (우리 쪽)
151+
152+
### 지문
153+
154+
크래시 없이 `(fail)` 줄이 나온다. 대부분 5초 예산 초과이지만 **전부는 아니다.** Bun `test`
155+
기본 타임아웃이 5000ms이므로, 예산을 명시하지 않은 테스트는 전부 5초 안에 끝나야 한다.
156+
157+
예외가 실제로 있다. run 30610504499은 이 모드로 분류했지만 실패 2건의 성격이 다르다.
158+
159+
```
160+
(fail) ... missing or non-string detail never authorizes a pool retry [5353.12ms] ← 5초 초과
161+
(fail) ... wrong model id in exact sentence never authorizes a pool retry [412.09ms] ← 어서션 불일치
162+
```
163+
164+
412ms짜리는 예산 문제가 아니라 `expect(...).toEqual(...)` 불일치다. 같은 파일의 앞선
165+
테스트가 타임아웃으로 하네스를 남긴 뒤 오염이 전파된 것으로 보이지만 **확인하지 못했다.**
166+
모드 B를 "5초 초과만"으로 정의하면 이 케이스를 놓친다.
167+
168+
### 걸린 테스트들의 공통점
169+
170+
| run | 실패 테스트 | 성격 |
171+
|-----|-------------|------|
172+
| 30615086938 | Windows tray packaging — 소켓 미상속 증명 | PowerShell + Bun 자식 실제 spawn |
173+
| 30612358421 | executeArchivedCleanup — spawn edge 정리 | DB/정리 작업 |
174+
| 30610504499 | server local API auth — pool retry | 실제 HTTP 서버 (+ 어서션 실패 1건 동반) |
175+
| 30609285180 | GET /api/github/star | 외부 바이너리 spawn |
176+
| 30611549111 | CLI status JSON + tray | CLI 자식 프로세스 |
177+
178+
타임아웃으로 걸린 것들은 전부 **프로세스를 띄우거나 실제 서버를 세우는** 테스트다. 순수 함수
179+
테스트가 타임아웃으로 실패한 사례는 없었다. Windows에서 프로세스 spawn과 파일시스템이 느린
180+
것은 알려진 특성이고, 5초는 그 위에서 너무 얇다.
181+
182+
### 이미 상당 부분 조치됐다 (그리고 그게 최근 실패를 설명한다)
183+
184+
커밋 타임스탬프는 작성자 타임존이 섞여 있으므로 오프셋까지 적는다.
185+
186+
```
187+
3e95ba33f 2026-07-31 10:11:02 +0200 test(storage): raise cleanup suite timeout for Windows CI load
188+
217db0426 2026-07-31 10:15:17 +0200 fix(test): raise Windows timeout for CLI spawn smoke tests
189+
81f3f689a 2026-07-31 17:22:34 +0900 test(tray): budget the socket-inheritance proof …
190+
cd9ad7396 2026-07-31 17:05:37 +0900 Merge pull request #797 (오늘 머지)
191+
```
192+
193+
`+0200` 두 건은 KST로 환산하면 17:11 / 17:15다. 즉 세 수정은 모두 오늘 오후에 몰려 있고,
194+
tray 예산 수정(17:22 KST)은 우리 머지 `cd9ad7396`(17:05 KST)보다 **17분 늦다**. 그래서
195+
`cd9ad7396`의 tray 타임아웃 실패는 수정이 들어오기 전 트리에서 난 것이고, 지금 `dev`에는
196+
이미 고쳐져 있다.
197+
198+
`81f3f689a`의 주석이 좋은 판단 기준을 남겼다: 예산을 올리는 것이 일반해가 아니고, **기다림이
199+
주장의 본질인지**를 봐야 한다. tray 테스트는 실제로 프로세스를 띄우는 것이 증명 자체이므로
200+
예산을 올리는 게 맞고, 같은 라운드에서 sidebar 라우트 테스트는 `gh` spawn이 주장과 무관해서
201+
spawn을 **삭제**하는 쪽으로 고쳤다.
202+
203+
### 현재 상태
204+
205+
최신 런 30616856523(`81f3f689a`)에서 **windows-latest는 통과**했다. 실패한 건 macOS의
206+
`claude outbound SSE > idle keepalive pings flow during upstream silence` 한 건으로, Windows와
207+
무관한 별개 건이다.
208+
209+
## 모드 C — 잡 타임아웃과 동시성 취소
210+
211+
`120_wp_ci_windows_timeout.md`가 이미 다뤘다. 12분 한도에 여유가 12초밖에 없어서 러너 변동이
212+
결과를 좌우했고, 20분으로 올렸다. 최근 런은 9~11분대라 한도 문제는 당장은 아니다.
213+
214+
`cancelled`는 실패가 아니라 `cancel-in-progress: true` 동시성 그룹의 정상 동작이다. 연속
215+
머지 시 앞선 런이 취소된다. 다만 `gh pr checks`에서는 이것도 빨갛게 보여서 체감 실패율을
216+
부풀린다.
217+
218+
## 실패율 (2026-07-30 ~ 07-31, `dev`)
219+
220+
`Cross-platform CI` 워크플로, `dev` 브랜치, 최근 40개 런 (조회 시점 2026-07-31 오후):
221+
failure 19, cancelled 13, success 7, 진행중 1.
222+
223+
숫자를 읽을 때 주의할 점 세 가지.
224+
225+
- **워크플로를 반드시 필터해야 한다.** `--workflow "Cross-platform CI"` 없이 `gh run list`
226+
돌리면 Service lifecycle, Issue quality 등이 섞여 전혀 다른 값이 나온다.
227+
- **failure에 모드 A/B/C가 섞여 있다.** "19번 깨졌다"가 아니다.
228+
- **cancelled는 대부분 정상 동작이다.** 연속 머지 시 `cancel-in-progress`가 앞선 런을 자른다.
229+
230+
슬라이딩 윈도우라 재조회하면 값이 달라진다. 이 집계는 스냅샷이며, 인용할 때는 위 명령과
231+
시점을 함께 적어야 한다.
232+
233+
## 열린 질문 (닫힌 것 포함)
234+
235+
조사 중 확인해서 닫은 것:
236+
237+
- `test.concurrent` / `--concurrent` 사용 여부 → **미사용**. `bunfig.toml`에는 `root`
238+
`preload`만 있고 동시성 설정이 없다.
239+
- 저장소 공개 여부 → **public**.
240+
- 패닉과 타임아웃이 같은 런에 공존하는가 → **아니오**, 상호 배타적.
241+
- 러너 이미지 → `windows-2025-vs2026` / `win25-vs2026/20260728.188`.
242+
- Bun 상위 버전 존재 → **없음**, 1.3.14가 최신 안정.
243+
244+
아직 열린 것:
245+
246+
- 크래시가 `api-storage-policy`에서만 나는 이유. 워커 카운트가 맞는 런도 죽었으므로 워커
247+
미종료 단독 설명은 반증됐고, 워커를 띄우는 다른 스위트는 멀쩡하며, 위치가 30/456이라
248+
누적 설명도 막혔다. **현재 유력 가설이 없다.**
249+
- `--isolate` 없이 돌리면 크래시가 사라지는가 — 미측정.
250+
- 실패 시작 시점이 Windows Server 2025 이미지 이관과 상관있는가 — 미측정.
251+
- run 30610504499의 412ms 어서션 실패가 앞선 타임아웃의 오염 전파인가 — 미확인.
252+
- Bun 테스트 러너가 `--shard`를 지원하는가 — 미확인.
253+
- 인용한 oven-sh/bun 이슈 원문(#35301, #36235) — 미열람.
254+
255+
## 개선 후보 (제안일 뿐, 적용하지 않음)
256+
257+
### 모드 A용
258+
259+
1. **`--isolate` A/B 측정.** Windows 잡만 `--isolate` 없이 여러 번 돌려 패닉이 사라지는지
260+
본다. 사라지면 Bun isolate 생명주기가 원인이라는 강한 증거다. 대가는 파일 간 전역 상태
261+
격리가 약해지는 것 — 우리 스위트가 realm 격리에 실제로 의존하는지부터 확인해야 한다.
262+
2. **`api-storage-policy` 경계 최소 재현 (우선순위 높음).** 그 스위트 + 인접 파일만
263+
Windows에서 반복 실행해 크래시를 좁힌다. macOS에서는 이 경계가 깨끗하다 — 로컬에서
264+
`bun test --isolate tests/api-storage-cleanup tests/api-storage-policy tests/api-storage`
265+
돌려 20 pass / 0 fail을 확인했다. 따라서 재현에는 Windows 러너가 필요하다. 재현되면
266+
oven-sh/bun에 올릴 최소 케이스가 나온다. 지금은 `bun.report` URL만 있고 최소 재현이 없어
267+
업스트림 리포트가 어렵다.
268+
3. **러너 이미지 고정 비교.** `windows-2022` vs 명시적 2025로 나눠 이미지 이관 영향을 본다.
269+
4. **분할(sharding)은 근거가 약해졌다.** 원래는 "한 프로세스가 456파일을 끝까지 들고 가는
270+
것"을 완화한다는 논리였는데, 크래시가 30번째 파일에서 나므로 그 논리가 무너진다. 어느
271+
샤드에 들어가든 `api-storage-policy`는 여전히 돈다. 러닝타임 단축 효과는 남지만 모드 A
272+
대책으로는 부적절하다. 참고로 Bun 테스트 러너의 `--shard` 지원 여부 자체를 확인하지 않았다.
273+
274+
### 모드 B용
275+
276+
5. **프로세스 spawn 테스트의 예산 정책화.** 이미 3건을 개별로 올렸는데, 지금은 케이스마다
277+
임기응변이다. "실제 프로세스를 띄우는 테스트는 Windows에서 최소 N초"를 헬퍼로 만들면
278+
다음 테스트가 같은 함정을 반복하지 않는다. 단 `81f3f689a`의 기준 — 기다림이 주장의
279+
본질일 때만 — 을 깨지 않아야 한다.
280+
6. **spawn이 본질이 아닌 테스트는 spawn 제거.** sidebar 라우트 테스트에서 이미 한 방식이
281+
맞는 방향이다.
282+
283+
### 하지 말아야 할 것
284+
285+
- **전역 `--timeout` 상향.** 모드 B는 가리고 모드 A는 못 고친다. 진짜 느려진 테스트도 같이
286+
숨는다.
287+
- **재시도(retry)로 덮기.** 모드 A는 프로세스가 죽는 것이라 재시도가 통계만 바꾼다.
288+
- **`--max-concurrency` 조정.** 파일 내 동시 테스트 수 제어라 우리 상황과 무관하다.
289+
- **Defender 예외 추가.** 근거가 오래된 정황뿐이고 CI 보안 표면을 건드린다.
290+
291+
## 판정
292+
293+
- 모드 B와 C는 우리 쪽 문제이고 대부분 조치됐다. 최신 `dev`에서 windows-latest가 통과한 것이
294+
그 증거다.
295+
- 모드 A는 **Bun 1.3.14 Windows의 외부 결함**이다. 조사 시점 기준 상위 안정 버전이 없어
296+
업그레이드로 피할 수 없다(이 문장이 "업그레이드 경로 없음"의 유일한 근거이므로, 인용 전
297+
릴리스 페이지를 다시 열어 확인할 것). 우리가 할 수 있는 건 트리거를 좁히는 것과 업스트림
298+
리포트용 최소 재현이다.
299+
- 모드 A의 **원인은 아직 규명되지 않았다.** 초기 가설(미종료 워커)은 반증됐고, 대체 가설
300+
(프로세스 수명 누적)도 크래시 위치가 30/456이라는 사실에 막혔다. 현재는 "이 스위트에서만
301+
재현되는 Bun isolate + Windows 상호작용"까지가 확인된 범위다.
302+
- 오늘 머지한 #808/#797은 어느 모드와도 무관하다. 레지스트리 상수와 순수 함수만 건드렸고,
303+
패닉 지점과 타임아웃 대상 테스트 어디에도 닿지 않는다.

0 commit comments

Comments
 (0)