[diag] macOS PNG render 33분 hang 원인 검증 — 한글 폰트 부재 가설 #96
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| # 트리거: PR / main 푸시 — test 계열만 (wheel 빌드 + 배포는 publish.yml) | |
| # 문서·라이선스·gitignore 전용 변경은 changes job + job-level if 패턴으로 if-skip | |
| # (status: success). paths-ignore 안 씀 — required check "All tests passed" 가 | |
| # expected 로 stuck 되는 GitHub 함정 회피. | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| workflow_dispatch: {} | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| jobs: | |
| # * 코드 변경 감지 — docs / LICENSE / .gitignore 전용 변경이면 test 잡 if-skip | |
| # (status: success). 모든 변경 파일이 exclude 패턴에 매치돼야 code=false. | |
| # base SHA 부재 / zero SHA 면 안전 측면으로 code=true (= 전체 실행). | |
| changes: | |
| name: Detect code changes | |
| runs-on: ubuntu-latest | |
| outputs: | |
| code: ${{ steps.filter.outputs.code }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| - id: filter | |
| env: | |
| BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} | |
| run: | | |
| if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then | |
| echo "code=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # ^ three-dot diff = base..HEAD 공통 조상부터의 변경만 (PR diff 와 동일) | |
| if git diff --name-only "$BASE_SHA"...HEAD \ | |
| | grep -qvE '(^|/)([^/]+\.md|LICENSE[^/]*|\.gitignore)$|^docs/'; then | |
| echo "code=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "code=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| # * Linux abi3 wheel 1회 빌드 → 모든 Linux 잡(test×4 / slow / core-only)이 공유 | |
| # abi3-py310 이라 py3.10/3.11/3.12/3.13 가 동일 wheel 재사용 가능. | |
| # macOS/Windows 는 단일 잡이라 빌드/테스트 분리 이득이 없어 그대로 매번 빌드. | |
| build-linux-wheel: | |
| name: Build Linux abi3 wheel | |
| needs: changes | |
| if: needs.changes.outputs.code == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| submodules: recursive | |
| # ^ skia-safe 가 Linux 에서 freetype / fontconfig 를 동적 링크 — ubuntu runner 에 미내장. | |
| # macOS / Windows 는 frameworks / 시스템 라이브러리로 자체 해결되어 별도 단계 불필요. | |
| - name: Install skia-safe system dependencies (Linux) | |
| run: | | |
| sudo apt-get update -qq | |
| sudo apt-get install -y --no-install-recommends libfreetype-dev libfontconfig1-dev | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - uses: astral-sh/setup-uv@v8.1.0 | |
| with: | |
| python-version: "3.12" | |
| - run: uv sync --no-install-project --group all | |
| - run: uv run maturin build --release --out dist | |
| - uses: actions/upload-artifact@v7 | |
| with: | |
| name: rhwp-python-linux-wheel | |
| path: dist/*.whl | |
| retention-days: 1 | |
| # * 메인 테스트 + 린트 + 타입체크 (Linux × 전 Python 버전 — wheel 공유) | |
| test: | |
| name: Test (Linux / py${{ matrix.python }}) | |
| needs: [build-linux-wheel, changes] | |
| if: needs.changes.outputs.code == 'true' | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - { python: "3.10", lint: true } | |
| - { python: "3.11" } | |
| - { python: "3.12" } | |
| - { python: "3.13" } | |
| defaults: | |
| run: | |
| shell: bash | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| submodules: recursive | |
| - uses: astral-sh/setup-uv@v8.1.0 | |
| with: | |
| python-version: ${{ matrix.python }} | |
| - run: uv sync --no-install-project --group all | |
| - uses: actions/download-artifact@v8 | |
| with: | |
| name: rhwp-python-linux-wheel | |
| path: dist/ | |
| - run: uv pip install --reinstall dist/*.whl | |
| # ^ --no-sync: build-linux-wheel 가 만든 wheel 을 그대로 사용. uv 가 lock 기준으로 프로젝트 | |
| # 를 editable 빌드하려 하면 native-skia 시스템 의존성 (freetype / fontconfig dev) 필요해서 fail. | |
| - name: Run pytest (not slow) with coverage | |
| run: uv run --no-sync pytest tests/ -m "not slow" --cov=rhwp --cov-report=term-missing -v | |
| - name: Run pyright (normal) | |
| if: matrix.lint | |
| run: | | |
| uv run --no-sync pyright python/ \ | |
| tests/test_smoke.py tests/test_parse.py tests/test_text_extraction.py \ | |
| tests/test_errors.py tests/test_svg_rendering.py tests/test_pdf_rendering.py \ | |
| tests/test_langchain_loader.py tests/test_langchain_loader_ir.py \ | |
| tests/test_ir_schema.py tests/test_ir_roundtrip.py tests/test_ir_tables.py \ | |
| tests/test_ir_iter_blocks.py tests/test_ir_schema_export.py \ | |
| tests/test_ir_picture.py tests/test_ir_furniture.py \ | |
| tests/test_ir_formula.py tests/test_ir_footnote.py \ | |
| tests/test_ir_list.py tests/test_ir_caption.py \ | |
| tests/test_ir_toc.py tests/test_ir_field.py \ | |
| tests/test_ir_marker_char_offset.py \ | |
| tests/test_view_markdown.py tests/test_view_html.py \ | |
| tests/test_view_baseline.py \ | |
| tests/test_cli.py \ | |
| tests/test_mcp_server.py \ | |
| tests/conftest.py tests/type_check_samples.py | |
| - name: Run pyright (intentional errors — expect 4) | |
| if: matrix.lint | |
| run: | | |
| set +e | |
| uv run --no-sync pyright --outputjson tests/type_check_errors.py > pyright-errors.json | |
| count=$(uv run --no-sync python -c "import json; print(json.load(open('pyright-errors.json'))['summary']['errorCount'])") | |
| echo "intentional error count: $count" | |
| if [ "$count" != "4" ]; then | |
| echo "::error::expected 4 intentional errors, got $count" | |
| exit 1 | |
| fi | |
| # * macOS / Windows 스모크 — 단일 잡이라 wheel 분리 이득 없음 → 직접 maturin develop | |
| test-other-os: | |
| name: Test (${{ matrix.os }} / py3.12) | |
| needs: changes | |
| if: needs.changes.outputs.code == 'true' | |
| runs-on: ${{ matrix.os }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [macos-latest, windows-latest] | |
| defaults: | |
| run: | |
| shell: bash | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| submodules: recursive | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - uses: astral-sh/setup-uv@v8.1.0 | |
| with: | |
| python-version: "3.12" | |
| - run: uv sync --no-install-project --group all | |
| # * 실험 patch: macOS family lookup 에 시스템 폰트 사전 필터링 적용 — match_family_style 호출 전에 멤버십 체크해 missing family 를 CoreText 에 던지지 않음. external/rhwp 자체에는 commit 하지 않고 runtime 에만 apply. | |
| - name: Apply experimental CoreText fix (macOS only) | |
| if: runner.os == 'macOS' | |
| run: | | |
| cd external/rhwp | |
| git apply ../../ci/patches/macos-coretext-fix.patch | |
| echo "=== patch applied ===" | |
| git diff --stat | |
| - run: uv run maturin develop --release | |
| # * macOS PNG 렌더 hang — 3차 진단으로 CoreText downloadable-font IPC hang 확인 (TDownloadableFontManager::Download → FontRegistryUI). chain 첫 폰트 부재 시 macOS 가 다운로드 시도 → daemon 응답 없음 → 무한 IPC wait. | |
| # mitigation: 상류 fallback chain 의 모든 한글 폰트 사전 설치로 다운로드 lookup 자체를 막음. | |
| - name: Install Korean font chain (macOS mitigation) | |
| if: runner.os == 'macOS' | |
| run: | | |
| brew install --cask \ | |
| font-noto-sans-kr \ | |
| font-noto-serif-kr \ | |
| font-noto-sans-cjk \ | |
| font-nanum-gothic | |
| echo "=== fc-list 한글 폰트 ===" | |
| fc-list :lang=ko 2>&1 | head -20 || echo "(fc-list 미인식)" | |
| - name: Run pytest (non-macOS) | |
| if: runner.os != 'macOS' | |
| run: uv run pytest tests/ -m "not slow" -v | |
| - name: Run pytest (macOS) — sample native stack on hang | |
| if: runner.os == 'macOS' | |
| env: | |
| RUST_BACKTRACE: full | |
| run: | | |
| set +e | |
| mkdir -p /tmp/diag | |
| uv run pytest tests/test_render_png.py::TestRenderPng::test_returns_png_magic -v -s & | |
| PYTEST_PID=$! | |
| # ^ 정상이면 분 단위 안에 끝남. 90초 wait 후 살아있으면 hang 으로 판정. | |
| for i in $(seq 1 9); do | |
| sleep 10 | |
| if ! kill -0 "$PYTEST_PID" 2>/dev/null; then | |
| wait "$PYTEST_PID"; EXIT=$? | |
| echo "pytest 종료 in ~$((i*10))s exit=$EXIT" | |
| if [ "$EXIT" = "0" ]; then | |
| exec uv run pytest tests/ -m "not slow" -v | |
| fi | |
| exit $EXIT | |
| fi | |
| done | |
| echo "=== HANG DETECTED — sampling native stacks ===" | |
| # ^ 자식까지 포함 (uv → python pytest). sample 출력을 파일로 받아 artifact 업로드. | |
| PIDS="$PYTEST_PID $(pgrep -P "$PYTEST_PID" || true)" | |
| for PID in $PIDS; do | |
| kill -0 "$PID" 2>/dev/null || continue | |
| OUT="/tmp/diag/sample_${PID}.txt" | |
| echo "----- sampling PID=$PID → $OUT -----" | |
| sample "$PID" 15 -file "$OUT" 2>&1 | head -3 | |
| echo ">>> Call graph head (PID=$PID) <<<" | |
| awk '/^Call graph:/,/^Binary Images:/' "$OUT" 2>/dev/null | head -120 || echo "(파일 미생성)" | |
| echo | |
| done | |
| ls -la /tmp/diag/ | |
| kill -9 "$PYTEST_PID" 2>/dev/null | |
| exit 1 | |
| - name: Upload sample diagnostic artifacts | |
| if: always() && runner.os == 'macOS' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: macos-png-hang-samples | |
| path: /tmp/diag/ | |
| if-no-files-found: warn | |
| # * 상류 CLI 자체도 hang 하는지 확인 — rhwp export-svg 가 같은 FontMgr 진입점을 거침. external/rhwp 디렉토리에서 빌드하되 산출물은 workspace target 으로 떨어질 수 있어 두 path 다 시도. | |
| - name: Upstream rhwp CLI 재현 (macOS, export-svg) | |
| if: always() && runner.os == 'macOS' | |
| working-directory: external/rhwp | |
| run: | | |
| set +e | |
| cargo build --release --bin rhwp 2>&1 | tail -3 | |
| BIN=../../target/release/rhwp | |
| [ -x "$BIN" ] || BIN=./target/release/rhwp | |
| [ -x "$BIN" ] || { echo "(빌드 산출물 없음)"; find ../.. -name "rhwp" -type f -maxdepth 5 2>/dev/null | head -5; exit 0; } | |
| mkdir -p /tmp/svg_diag | |
| echo "=== export-svg page 0, 90s soft timeout · using $BIN ===" | |
| # macOS 는 GNU `timeout` 미포함 — background + sleep loop 로 구현 | |
| "$BIN" export-svg samples/aift.hwp -o /tmp/svg_diag -p 0 & | |
| CLI_PID=$! | |
| START=$(date +%s) | |
| while kill -0 "$CLI_PID" 2>/dev/null; do | |
| sleep 5 | |
| ELAPSED=$(( $(date +%s) - START )) | |
| if [ "$ELAPSED" -ge 90 ]; then | |
| echo "⚠️ TIMEOUT ${ELAPSED}s — 상류 CLI 도 동일하게 hang" | |
| kill -9 "$CLI_PID" 2>/dev/null | |
| wait "$CLI_PID" 2>/dev/null | |
| exit 0 | |
| fi | |
| done | |
| wait "$CLI_PID"; RC=$? | |
| ELAPSED=$(( $(date +%s) - START )) | |
| if [ "$RC" = "0" ]; then | |
| echo "✓ 상류 CLI export-svg ${ELAPSED}s 에 정상 종료 — PNG path 특이성" | |
| else | |
| echo "exit=$RC after ${ELAPSED}s (timeout 도 정상도 아님)" | |
| fi | |
| # * PDF 렌더링 — 느려서 별도 잡, Linux wheel 재사용 | |
| test-slow: | |
| name: Test slow (Linux / py3.12 — PDF) | |
| needs: [build-linux-wheel, changes] | |
| if: needs.changes.outputs.code == 'true' | |
| runs-on: ubuntu-latest | |
| defaults: | |
| run: | |
| shell: bash | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| submodules: recursive | |
| - uses: astral-sh/setup-uv@v8.1.0 | |
| with: | |
| python-version: "3.12" | |
| - run: uv sync --no-install-project --group testing | |
| - uses: actions/download-artifact@v8 | |
| with: | |
| name: rhwp-python-linux-wheel | |
| path: dist/ | |
| - run: uv pip install --reinstall dist/*.whl | |
| - run: uv run --no-sync pytest tests/ -m slow -v | |
| # * Rust unit tests — src/ir.rs 의 #[cfg(test)] 모듈 실행 | |
| # Cargo.toml 의 default features 에서 extension-module 이 빠져 있어 libpython 링크 시도 안 함. | |
| # utf16_to_cp / simple_eq_text_alt / field_type_to_str / caption_direction_to_str / | |
| # assert_position_invariant (#[should_panic]) 검증 — Python 측에서 우회 불가능한 Rust 도메인 로직. | |
| cargo-test: | |
| name: Cargo test (Rust unit tests) | |
| needs: changes | |
| if: needs.changes.outputs.code == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| submodules: recursive | |
| # ^ skia-safe 가 Linux 에서 freetype / fontconfig 를 동적 링크 — build-linux-wheel 와 동일. | |
| - name: Install skia-safe system dependencies (Linux) | |
| run: | | |
| sudo apt-get update -qq | |
| sudo apt-get install -y --no-install-recommends libfreetype-dev libfontconfig1-dev | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - name: Run cargo test (default features — extension-module disabled) | |
| run: cargo test --lib | |
| # * extras 미설치 시 langchain 테스트가 importorskip 로 auto-skip 되는지 검증 | |
| test-core-only: | |
| name: Test without extras (importorskip auto-skip) | |
| needs: [build-linux-wheel, changes] | |
| if: needs.changes.outputs.code == 'true' | |
| runs-on: ubuntu-latest | |
| defaults: | |
| run: | |
| shell: bash | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| submodules: recursive | |
| - uses: astral-sh/setup-uv@v8.1.0 | |
| with: | |
| python-version: "3.12" | |
| - name: Install pytest only (no langchain extras — intentional) | |
| run: | | |
| uv venv | |
| uv pip install pytest | |
| - uses: actions/download-artifact@v8 | |
| with: | |
| name: rhwp-python-linux-wheel | |
| path: dist/ | |
| - run: uv pip install dist/*.whl | |
| - name: Run pytest — extras-gated tests must auto-skip via importorskip | |
| # ^ 파일-레벨 importorskip 은 해당 파일 전체를 skip 1개로 카운트. | |
| # v0.6.0 기준 gated 파일: test_langchain_loader.py + test_langchain_loader_ir.py | |
| # (langchain-core), test_ir_schema_export.py (jsonschema), test_cli.py (typer), | |
| # test_mcp_server.py (fastmcp), test_render_png.py (Pillow) → 총 6 파일. | |
| # test_async.py 는 v0.3.0 부터 stdlib 만 사용 (aiofiles 의존성 제거). | |
| run: | | |
| uv run --no-sync pytest tests/ -m "not slow" -v | tee pytest-output.txt | |
| if ! grep -qE '(^|[^0-9])6 skipped([^0-9]|$)' pytest-output.txt; then | |
| echo "::error::expected 6 extras-gated files to auto-skip via importorskip (langchain×2, jsonschema, typer, fastmcp, Pillow)" | |
| exit 1 | |
| fi | |
| all-tests-passed: | |
| name: All tests passed | |
| if: always() | |
| runs-on: ubuntu-latest | |
| needs: [changes, build-linux-wheel, test, test-other-os, test-slow, test-core-only, cargo-test] | |
| steps: | |
| - uses: re-actors/alls-green@release/v1 | |
| with: | |
| jobs: ${{ toJSON(needs) }} | |
| allowed-skips: build-linux-wheel, test, test-other-os, test-slow, test-core-only, cargo-test |