fix(B08): 거리이정표 자동 그림 캡처 = 자식 파이썬 프로세스(249-1)

서버(uvicorn · Windows Selector 루프) 스레드 안 Playwright 드라이버 기동 실패 해소 ·
입력 JSON stdin · 결과 JSON 한 줄 · 25초 넘김 · 잠금 그대로 · Selector 정책 시험 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
2026-10-10 23:51:19 +09:00
co-authored by Claude Opus 5.5
parent 9ac0e395af
commit b5cbe2c589
3 changed files with 152 additions and 10 deletions
+52 -10
View File
@@ -8,12 +8,17 @@
page.route 로 가로채 채움(카카오 JS 키 도메인 검사만 통과 · vite 안 떠도 됨).
경로 좌표는 그림 만든 뒤 버림(저장 = 그림 · km) · 동시 요청 = 하나씩(잠금) · 실패 = 까닭 한 줄 ·
키 값은 돌려주지도 적지도 않음.
캡처는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 = JSON stdin · 출력 = 결과 JSON 한 줄 ·
키는 환경 그대로) — 서버(uvicorn · Windows Selector 루프) 안 스레드에선 Playwright 가
드라이버 하위 프로세스를 못 만듦(249-1).
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import threading
import time
import urllib.parse
@@ -26,6 +31,9 @@ DEFAULT_ORIGIN = "http://localhost:5173"
PAGE = "/__route_capture"
WIDTH, HEIGHT = 1200, 800
TIMEOUT_S = 20
CHILD_TIMEOUT_S = 25 # 자식 프로세스 전체(파이썬 · 드라이버 기동 포함)
CHILD = __file__ # 자식으로 돌릴 스크립트
NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움"
CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠
LINE_COLOR = "#E60046" # 화면 지도(`…_UI_Milestone_KakaoMap`) 길 선과 같은 색
TAIL = " — 그림 올리기를 쓰세요."
@@ -90,7 +98,7 @@ def shoot(html: str, origin: str) -> bytes:
except PwError:
continue
if browser is None:
raise CaptureError("서버에서 브라우저(Edge · Chrome)를 못 띄움")
raise CaptureError(NO_BROWSER)
try:
page = browser.new_page(viewport={"width": WIDTH, "height": HEIGHT})
page.route(
@@ -114,7 +122,41 @@ def shoot(html: str, origin: str) -> bytes:
except PwTimeout as error:
raise CaptureError(f"지도 그림이 {TIMEOUT_S}초 안에 안 끝남") from error
except Exception as error: # 드라이버 못 뜸 등 — 예외 글은 내지 않음
raise CaptureError("서버에서 브라우저(Edge · Chrome)를 못 띄움") from error
raise CaptureError(NO_BROWSER) from error
def capture(job: dict[str, Any]) -> dict[str, Any]:
"""자식 프로세스 몸통 — 그림 찍어 `<out>/<route_id>.png` 저장 → {saved, reason}."""
key = os.environ.get(KAKAO_JS, "").strip()
try:
png = shoot(page_html(key, job["lines"], job["marks"]), job["origin"])
except CaptureError as error:
return {"saved": False, "reason": str(error)}
out_dir = Path(job["out"])
out_dir.mkdir(parents=True, exist_ok=True)
new = out_dir / f".{job['route_id']}.png.new"
new.write_bytes(png)
new.replace(out_dir / f"{job['route_id']}.png")
return {"saved": True, "reason": None}
def spawn(job: dict[str, Any]) -> dict[str, Any]:
"""자식 파이썬 프로세스로 capture — 서버 이벤트 루프 정책과 무관(기본 Proactor)."""
try:
done = subprocess.run(
[sys.executable, CHILD],
input=json.dumps(job).encode(),
capture_output=True,
timeout=CHILD_TIMEOUT_S,
)
except subprocess.TimeoutExpired:
return {"saved": False, "reason": f"지도 그림이 {TIMEOUT_S}초 안에 안 끝남"}
except OSError:
return {"saved": False, "reason": NO_BROWSER}
try:
return json.loads(done.stdout.decode().strip().splitlines()[-1])
except (ValueError, IndexError): # 자식이 죽음 — stderr(예외 글)는 내지 않음
return {"saved": False, "reason": NO_BROWSER}
def make_route_image(
@@ -130,13 +172,13 @@ def make_route_image(
if not any(lines) and not marks:
return {"saved": False, "reason": f"길 선 · 표지가 없어 지도 그림을 못 만듦{TAIL}"}
origin = (os.environ.get(ORIGIN_KEY, "").strip() or DEFAULT_ORIGIN).rstrip("/")
job = {"out": str(out_dir), "route_id": route_id, "lines": lines, "marks": marks}
with _lock:
try:
png = shoot(page_html(key, lines, marks), origin)
except CaptureError as error:
return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {error}{TAIL}"}
out_dir.mkdir(parents=True, exist_ok=True)
new = out_dir / f".{route_id}.png.new"
new.write_bytes(png)
new.replace(out_dir / f"{route_id}.png")
got = spawn({**job, "origin": origin})
if not got.get("saved"):
return {"saved": False, "reason": f"경로 그림 자동 캡처 실패 — {got.get('reason')}{TAIL}"}
return {"saved": True, "reason": None}
if __name__ == "__main__":
print(json.dumps(capture(json.loads(sys.stdin.buffer.read()))))
@@ -115,6 +115,9 @@ def fake_playwright(box):
def _setup(tmp_path, monkeypatch, box, origin=None):
monkeypatch.setitem(sys.modules, "playwright.sync_api", fake_playwright(box))
monkeypatch.setattr(
cap, "spawn", cap.capture
) # 가짜 브라우저 = 같은 프로세스(249-1 자식은 별도 시험)
monkeypatch.setenv(cap.KAKAO_JS, KEY)
if origin is None:
monkeypatch.delenv(cap.ORIGIN_KEY, raising=False)
@@ -0,0 +1,97 @@
"""249-1 B08 거리이정표 자동 그림 = 자식 프로세스 캡처(사용자 148번).
서버(uvicorn · Windows) = WindowsSelectorEventLoopPolicy → 스레드 안 sync_playwright 가
드라이버 하위 프로세스를 못 만듦. 같은 정책 · 스레드에서:
① 옛 길(같은 프로세스 shoot) = 「브라우저 못 띄움」 재현
② make_route_image(자식 파이썬 프로세스) = 실제 Edge 로 PNG 생김 · 키 값 응답 0
③ 자식 시간 넘김 · 자식 죽음 = 까닭 한 줄
카카오 SDK 대신 단색 페이지(이 파일을 자식 스크립트로 · page_html 만 바꿈) — 네트워크 없음.
"""
import asyncio
import json
import sys
import threading
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity import B08_Quantity_Milestone_Capture as cap # noqa: E402
HTML = (
'<!doctype html><body style="margin:0"><div id="m" style="width:1200px;height:800px;'
'background:#E60046"></div><script>window.__done = true</script></body>'
)
KEY = "fake-js-key-249"
LINES = [[[35.6866, 127.9095], [35.66, 127.85]]]
MARKS = [{"name": "거창군청", "lon": 127.9095, "lat": 35.6866}]
if __name__ == "__main__": # 자식 프로세스 — 카카오 페이지만 단색 페이지로
cap.page_html = lambda key, lines, marks: HTML
print(json.dumps(cap.capture(json.loads(sys.stdin.buffer.read()))))
sys.exit(0)
import pytest # noqa: E402
pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="Windows Selector 루프 재현")
def in_server_thread(fn):
"""uvicorn 처럼 Selector 정책을 건 채 스레드(asyncio.to_thread 자리)에서 fn()."""
got = {}
def run():
try:
got["value"] = fn()
except BaseException as error: # noqa: BLE001 — 시험에서 그대로 봄
got["error"] = error
before = asyncio.get_event_loop_policy()
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
try:
job = threading.Thread(target=run)
job.start()
job.join(60)
finally:
asyncio.set_event_loop_policy(before)
return got
def test_옛_길_같은_프로세스는_못_띄움():
pytest.importorskip("playwright.sync_api")
got = in_server_thread(lambda: cap.shoot(HTML, cap.DEFAULT_ORIGIN))
assert isinstance(got.get("error"), cap.CaptureError)
assert str(got["error"]) == cap.NO_BROWSER
def test_자식_프로세스로_PNG_생김(tmp_path, monkeypatch):
pytest.importorskip("playwright.sync_api")
monkeypatch.setattr(cap, "CHILD", __file__)
monkeypatch.setenv(cap.KAKAO_JS, KEY)
monkeypatch.delenv(cap.ORIGIN_KEY, raising=False)
out = tmp_path / "auto"
got = in_server_thread(lambda: cap.make_route_image(out, "r1", LINES, MARKS))
assert got.get("value") == {"saved": True, "reason": None}, got
png = (out / "r1.png").read_bytes()
assert png[:8] == b"\x89PNG\r\n\x1a\n" and len(png) > 1000
assert sorted(p.name for p in out.iterdir()) == ["r1.png"] # .new 남지 않음
def test_자식_시간_넘김_죽음_까닭(tmp_path, monkeypatch):
monkeypatch.setenv(cap.KAKAO_JS, KEY)
tail = " — 그림 올리기를 쓰세요."
head = "경로 그림 자동 캡처 실패 — "
hang = tmp_path / "hang.py"
hang.write_text("import time\ntime.sleep(30)\n", encoding="utf-8")
monkeypatch.setattr(cap, "CHILD", str(hang))
monkeypatch.setattr(cap, "CHILD_TIMEOUT_S", 1)
got = cap.make_route_image(tmp_path / "auto", "r1", LINES, MARKS)
assert got == {"saved": False, "reason": head + "지도 그림이 20초 안에 안 끝남" + tail}
dead = tmp_path / "dead.py"
dead.write_text(f"raise SystemExit('boom {KEY}')\n", encoding="utf-8")
monkeypatch.setattr(cap, "CHILD", str(dead))
got = cap.make_route_image(tmp_path / "auto", "r1", LINES, MARKS)
assert got == {"saved": False, "reason": head + cap.NO_BROWSER + tail}
assert not (tmp_path / "auto").exists()