From b5cbe2c5898dc1403fc5af06a86f25cc9ad77f52 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 23:51:19 +0900 Subject: [PATCH] =?UTF-8?q?fix(B08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EC=9E=90=EB=8F=99=20=EA=B7=B8=EB=A6=BC=20?= =?UTF-8?q?=EC=BA=A1=EC=B2=98=20=3D=20=EC=9E=90=EC=8B=9D=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=EC=8D=AC=20=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4(249-1?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버(uvicorn · Windows Selector 루프) 스레드 안 Playwright 드라이버 기동 실패 해소 · 입력 JSON stdin · 결과 JSON 한 줄 · 25초 넘김 · 잠금 그대로 · Selector 정책 시험 추가 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../B08_Quantity_Milestone_Capture.py | 62 ++++++++++-- .../test_244_11_b08_milestone_auto_capture.py | 3 + .../test_249_1_b08_milestone_capture_child.py | 97 +++++++++++++++++++ 3 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 resources/tester/test_249_1_b08_milestone_capture_child.py diff --git a/B08_Quantity/B08_Quantity_Milestone_Capture.py b/B08_Quantity/B08_Quantity_Milestone_Capture.py index 635bf9e88..ca6a9cff1 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Capture.py +++ b/B08_Quantity/B08_Quantity_Milestone_Capture.py @@ -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]: + """자식 프로세스 몸통 — 그림 찍어 `/.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())))) diff --git a/resources/tester/test_244_11_b08_milestone_auto_capture.py b/resources/tester/test_244_11_b08_milestone_auto_capture.py index 33e0396fa..25a591302 100644 --- a/resources/tester/test_244_11_b08_milestone_auto_capture.py +++ b/resources/tester/test_244_11_b08_milestone_auto_capture.py @@ -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) diff --git a/resources/tester/test_249_1_b08_milestone_capture_child.py b/resources/tester/test_249_1_b08_milestone_capture_child.py new file mode 100644 index 000000000..d567527cc --- /dev/null +++ b/resources/tester/test_249_1_b08_milestone_capture_child.py @@ -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 = ( + '
' +) +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()