Skip to content

fix(landing): stop vinext prerender from silently skipping pages - #790

Closed
owjs3901 wants to merge 2 commits into
mainfrom
fix/vinext-prerender-transport
Closed

owjs3901 wants to merge 2 commits into
mainfrom
fix/vinext-prerender-transport

Conversation

@owjs3901

@owjs3901 owjs3901 commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Refs #683 (landing 빌드 신뢰성: Vinext 사전 렌더링이 페이지를 조용히 건너뛰는 문제). main 기반 단독 PR이며 먼저 병합되어야 할 PR은 없습니다(#788의 changepack 수정과 #768의 의존성 범위는 이미 main에 있습니다).

요약

landing 앱을 vinext build로 빌드할 때 사전 렌더링 단계가 가끔 페이지를 건너뛰고도 종료 코드 0으로 끝났습니다. 원인은 Vinext의 빌드용 HTTP 클라이언트가, Vinext 자신이 띄운 로컬 사전 렌더링 서버가 keep-alive 시간 초과로 이미 닫고 있는 유휴 연결을 다시 쓰는 것입니다. 요청은 ECONNRESET으로 실패하고, Vinext는 그 페이지를 오류가 아니라 "dynamic이라 건너뜀"으로 기록합니다.

이 PR은 다음을 합니다.

  1. Bun 패치(patches/vinext@1.2.1.patch, 루트 patchedDependencies)로 Vinext 1.2.1의 빌드용 사전 렌더링 서버만 연결당 요청 1개(maxRequestsPerSocket = 1)로 바꿉니다. 유휴 연결이 재사용되지 않으므로 경쟁 조건이 사라집니다.
  2. 같은 패치로 정적 export 중 응답을 받지 못한 사전 렌더링 요청을 빌드 오류로 바꿉니다. 경로를 밝히고 원래 오류를 그대로 보존합니다.
  3. landing 빌드 뒤에 산출물 검사(apps/landing/check-prerender.ts)를 실행해, 기대한 70개 경로의 HTML과 Flight(.txt), 그리고 404.html 중 하나라도 없거나 비어 있으면 빌드가 실패합니다.
  4. 실제 배포된 Vinext 사전 렌더링 코드를 자체 로컬 서버 픽스처에 대고 돌리는 회귀 테스트를 추가합니다. 패치가 없으면 RED, 있으면 GREEN입니다.

원인

배포된 Vinext 1.2.1 코드, Node 24.19.0(내장 undici 7.29.0)에서 확인했습니다.

  • prerenderApp은 같은 프로세스에서 띄운 Vinext 서버(startProdServer({ purpose: "prerender" }))에 전역 fetch()로 요청합니다. undici는 keep-alive 연결을 풀에 두고 다음 요청에 재사용합니다.
  • 렌더링이 이벤트 루프를 서버의 keepAliveTimeout(5000ms) + keepAliveTimeoutBuffer(1000ms)보다 오래 막으면, 루프가 풀린 직후 같은 타이머 단계에서 undici의 유휴 소켓 검증 타이머가 먼저 실행되어 요청을 그 소켓에 쓰고, 이어서 서버의 socketOnTimeout이 같은 소켓을 파괴합니다. 서버는 요청을 읽기 전이므로 처리하지 않고, 클라이언트는 read ECONNRESET을 받습니다.
  • prerender.js의 renderUrl은 분류가 unknown인(추측 렌더링) 경로에서 던져진 오류를 모두 skipped/dynamic으로 바꿉니다. 404 센티널 요청 실패도 삼킵니다. export 검사(run-prerender.js)는 error 상태만 보므로 빌드는 종료 코드 0으로 끝나고 HTML과 Flight 파일이 빠집니다.

재현 근거(모두 실제 배포 코드, 6 GiB 상한):

시도 연결 결과
기존 재사용 (3회: W71b 2회, W71c 1회) 같은 소켓에 요청 기록 → 서버 socketOnTimeout 파괴 → read ECONNRESET, 서버 핸들러 미도달 /victim skipped/dynamic, HTML·Flight 없음
새 연결 (warm 요청만 Connection: close) 새 튜플로 연결 정확한 HTML·Flight 기록
서버 정책 (maxRequestsPerSocket = 1) warm 응답이 Connection: close, 풀에 유휴 연결 없음, 새 튜플 정확한 HTML·Flight 기록

같은 조건에서 패치 없이 돌린 landing 실제 빌드 1회(W71b, 패치 전)는 70개 경로 모두 정상이었습니다. 이 경쟁은 이벤트 루프가 keep-alive 시간보다 오래 막힐 때만 생기므로 평소 빌드에서는 드물게 나타나고, 그래서 가끔 페이지가 빠지는 증상이 됩니다.

변경

Vinext 패치 (patches/vinext@1.2.1.patch)

dist/server/prod-server.js

  • applyPrerenderConnectionPolicy(server, purpose): purpose === "prerender"일 때만 server.maxRequestsPerSocket = 1. App Router와 Pages Router 서버 생성 직후에 적용합니다. Vinext가 빌드 때 띄우는 모든 서버(App, Pages, App+Pages 공유, 병렬 렌더 워커, 경로 탐색)가 이 purpose를 씁니다.
  • vinext start, standalone 같은 사용자 서버는 purpose가 없으므로 Node 기본값 그대로입니다. 클라이언트 쪽 fetch, 연결 풀, 시간 초과, 동시성은 바꾸지 않았고 재시도나 대기도 넣지 않았습니다.

dist/build/prerender.js

  • fetchPrerenderServer(): 사전 렌더링 서버로 가는 fetch()가 응답 없이 거부되면 그 오류 객체를 표시합니다(WeakSet, 오류 객체는 바꾸지 않음).
  • createExportRequestFailures(mode): export 모드에서만, 표시된 오류를 모든 catch 지점(App 렌더, App 404, generateStaticParams, Pages 렌더, Pages 404, getStaticPaths)에서 경로와 함께 기록하고, 진행 중인 렌더가 모두 끝난 뒤(병렬 워커 상태 검사와 manifest 쓰기 전) 한 번에 실패시킵니다.
  • 응답이 온 경우(no-store, 비정상 상태 코드, 스트림 도중 중단)는 지금처럼 분류합니다. 즉 실제 dynamic 경로는 그대로 skipped/dynamic입니다. export가 아닌 모드도 그대로입니다.

landing 산출물 검사

  • apps/landing/verify-prerender.ts: e2e/exported-routes.ts의 70개 경로마다 Vinext의 getOutputPath/getRscOutputPath로 HTML과 Flight 경로를 계산해 일반 파일이고 비어 있지 않은지 확인하고, 404.html도 확인합니다.
  • apps/landing/check-prerender.ts를 build 스크립트 끝(vinext build && bun ./check-prerender.ts)에서 실행합니다.

새로 생기는 오류

  1. 정적 export 중 사전 렌더링 요청이 응답 없이 실패하면 (AggregateError, cause는 첫 원래 오류, errors는 모든 원래 오류, 경로는 정렬):
[vinext] Static export failed: 2 prerender requests failed before a response was received.
  /dynamic: fetch failed (ECONNRESET)
  /victim: fetch failed (ECONNRESET)

동적 경로는 /blog/:slug (/blog/a)처럼 패턴과 실제 경로를 함께 씁니다. 404는 /404로 표시합니다.

  1. landing 빌드 산출물이 빠지면 (PrerenderArtifactError, route/path/reason 필드):
Prerender artifact missing: route /docs/devup/breakpoints, artifact <dist/client>/docs/devup/breakpoints.txt

reason은 missing, empty, not-file 중 하나입니다.

버전 업데이트

이 브랜치는 처음에 세 워크스페이스(apps/landing, apps/vinext-rsc-css-repro, benchmark/vinext-devup-ui)의 Vinext를 최신 1.2.1로, @vitejs/plugin-rsc를 0.5.38로, React 계열 peer를 19.3.1로 올렸습니다. 같은 업데이트가 #768로 main에 먼저 들어갔으므로, main 병합(689e34e) 때 이 범위들은 main의 줄을 그대로 따랐습니다. 그래서 이 PR이 바꾸는 의존성 범위는 없고, 루트 patchedDependencies가 main이 쓰는 정확한 버전 vinext@1.2.1에 패치를 연결합니다. bun.lock은 bun install로 다시 만들었고 main 대비 차이는 patchedDependencies 세 줄뿐입니다. 패치 전 1.2.1 업데이트 상태에서 apps/vinext-rsc-css-repro의 RSC CSS 단언은 바꾸지 않았습니다. 배포되는 패키지 내용은 바뀌지 않으므로 changepack은 없습니다.

검증

회귀 테스트 packages/vite-plugin/src/__tests__/vinext-prerender-transport.test.ts는 landing 앱이 쓰는 Vinext 파일을 찾아 Node로 vinext-prerender-transport.helper.mjs를 실행합니다(vinext build가 Node에서 돌기 때문입니다). 헬퍼는 실제 배포 코드인 prerenderApp을 Vinext 자신의 startProdServer가 띄운 서버에 대고 실행합니다.

  • shipped: Vinext가 사전 렌더링용으로 띄운 서버 그대로. keep-alive warm 요청 뒤 이벤트 루프를 keepAliveTimeout + keepAliveTimeoutBuffer(6000ms)만큼 막습니다. 기대: warm 응답 connection: close, maxRequestsPerSocket 1, /victim rendered와 정확한 HTML·Flight, no-store인 /dynamic은 그대로 skipped/dynamic.
  • reuse: 같은 서버에서 maxRequestsPerSocket을 Node 기본값 0으로 되돌린 대조군. 기대: 페이지가 조용히 빠지지 않음(렌더되거나 /victim을 밝힌 export 오류).
  • reset: 요청을 받으면 TCP 리셋하는 node:net 서버. 기대: 위 "새로 생기는 오류" 1번 메시지 그대로, cause 체인 TypeError: fetch failed ← ECONNRESET, 파일 없음.
  • purpose 없는 startProdServer는 maxRequestsPerSocket 0 그대로.
Node (undici) 패치 적용 패치 없음(같은 1.2.1 배포본)
24.19.0 (7.29.0) 5 pass 3 fail: shipped는 skipped + keep-alive, reuse는 skipped, reset은 두 경로 모두 조용히 skipped
26.12.0 (8.11.2) 5 pass 3 fail: shipped와 reuse의 정책 단언, reset은 두 경로 모두 조용히 skipped

Node 24.19에서 패치를 적용한 헬퍼의 실제 출력(요약):

shipped: max=1 warm=close routes=[/victim:rendered,/dynamic:skipped:dynamic,/404:rendered] html=True flight=True
reuse:   error=AggregateError, causes=TypeError: fetch failed <- Error: read ECONNRESET (ECONNRESET)
         [vinext] Static export failed: 1 prerender request failed before a response was received.
           /victim: fetch failed (ECONNRESET)
reset:   error=AggregateError errors=2, 위 1번 메시지와 같음

그 밖의 검증:

  • 패치를 적용한 실제 landing 빌드 1회(6 GiB 상한, 시작 시 여유 메모리 10.1 GB, 최대 2.38 GB): Prerendered 71 routes (0 skipped), check-prerender.ts 통과, HTML 71개(70개 경로 + 404.html)와 Flight 70개.
  • 패치 전 같은 1.2.1로 한 landing 실제 빌드 1회(W71b): 70개 경로 모두 정상. 위 재현과 함께 보면, 경쟁은 이벤트 루프가 keep-alive 시간보다 오래 막힐 때만 생겨 가끔만 나타납니다.
  • landing 산출물 검사 테스트 landing-prerender.test.ts 17개 통과(누락, 빈 파일, 디렉터리, 파일 시스템 오류 전파).
  • 커밋 훅: 187bebd bun test 6363 pass / 0 fail, main 병합 689e34e bun test 6596 pass / 0 fail, 둘 다 함수·라인 100%. bun lint 통과. 로컬 Windows tarpaulin은 97.87%이고 Linux CI가 100%를 강제합니다.
  • 새 파일 eslint, strict 타입 검사(bun, node 타입) 통과.
  • CI: 실행 39268061103 성공(head 689e34e). publish: Linux tarpaulin 100.00%(14752/14752 라인), bun test 6596 pass / 0 fail(함수·라인 100%), Node v26.12.0에서 전송 회귀 테스트 5/5 통과, landing 빌드 두 번(파일별 CSS, singleCss) 모두 \Prerendered 71 routes (0 skipped)\와 산출물 검사 통과. benchmark, landing-next-e2e, react-18, vinext-rsc-css-e2e, webpack-extends-theme, wasm-size도 모두 성공.

남는 한계

  • 패치는 Vinext 1.2.1에 고정됩니다(patchedDependencies의 키가 정확한 버전). Vinext를 올리면 Bun이 패치 적용 실패로 설치를 멈추므로, 그때 업스트림 수정 여부를 보고 패치를 옮기거나 지웁니다. 업스트림 이슈와 수정 PR은 따로 준비합니다.
  • Node 26.12.0(내장 undici 8.11.2, CI의 latest)에서는 같은 재현 순서로 페이지가 빠지지 않았습니다. 그래서 회귀 테스트는 모든 Node에서 서버 정책과 결정적인 연결 리셋 경로를 확인하고, 실제 경쟁은 재현되는 Node(24)에서만 확인합니다. Node 26에서 다른 타이밍으로 경쟁이 남아 있는지는 증명하지 않았습니다.
  • 응답 헤더를 받은 뒤 본문 도중 연결이 끊긴 경우는 기존 분류를 따릅니다. 추측 렌더링이 dynamic 사용을 만나면 서버가 스트림을 중단할 수 있어서, 이를 export 실패로 바꾸면 실제 dynamic 경로까지 실패하기 때문입니다.
  • 사전 렌더링 요청마다 새 TCP 연결을 엽니다. 루프백이라 비용은 작지만, 경로가 매우 많은 앱에서는 연결 수가 그만큼 늘어납니다.

owjs3901 and others added 2 commits October 19, 2026 18:34
Refs #683

Also carries the identical changepack fix from #788 so the changepack guard passes on this base.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Takes main's dependency ranges from #768 (vinext 1.2.1, @vitejs/plugin-rsc 0.5.38) and regenerates bun.lock with bun install.

Refs #683

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
@codecov

codecov Bot commented Oct 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Files with missing lines Coverage Δ
apps/landing/verify-prerender.ts 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@owjs3901

Copy link
Copy Markdown
Contributor Author

메인테이너 결정으로 닫습니다. 저장소에서 의존성(vinext)을 패치하지 않습니다.

이 PR에서 확인한 원인과 재현 근거는 upstream(cloudflare/vinext)에 이슈와 수정 PR로 제출합니다. upstream에 수정이 반영되어 배포되면, 그 버전으로 vinext를 올리는 것으로 해결합니다.

@owjs3901 owjs3901 closed this Oct 20, 2026
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