diff --git a/packaging/windows_smoke.py b/packaging/windows_smoke.py index 04d8a05..567caba 100644 --- a/packaging/windows_smoke.py +++ b/packaging/windows_smoke.py @@ -14,8 +14,10 @@ from __future__ import annotations import argparse +import contextlib import json import os +import shutil import subprocess import sys import tempfile @@ -440,6 +442,37 @@ def _visible_windows(pids: set[int]) -> list[str]: return titles +def _terminate_tree(proc: subprocess.Popen) -> None: + """부모만 죽이면 안 된다. + + onefile 의 부모는 압축을 푸는 부트로더일 뿐이고, 창과 로그 파일을 쥐고 있는 + 것은 자식이다. 부모만 terminate 하면 자식이 살아남아 로그 파일을 계속 + 잡고 있고, 임시 폴더를 지울 수 없어 점검이 엉뚱한 곳에서 실패한다. + """ + children = [] + try: + import psutil + + children = psutil.Process(proc.pid).children(recursive=True) + except Exception: # noqa: BLE001 - psutil 이 없으면 부모만이라도 정리한다 + pass + + for child in children: + with contextlib.suppress(Exception): + child.terminate() + proc.terminate() + try: + proc.wait(timeout=15) + except subprocess.TimeoutExpired: + proc.kill() + for child in children: + with contextlib.suppress(Exception): + child.wait(timeout=10) + with contextlib.suppress(Exception): + if child.is_running(): + child.kill() + + def _launch_and_wait_for_window(exe: Path, appdata: Path, timeout_s: int = 90) -> str: """exe 를 띄우고 **실제 창이 뜰 때까지** 기다린다. @@ -469,11 +502,22 @@ def _launch_and_wait_for_window(exe: Path, appdata: Path, timeout_s: int = 90) - time.sleep(1) raise RuntimeError(f"{timeout_s}초 안에 창이 뜨지 않았습니다") finally: - proc.terminate() - try: - proc.wait(timeout=15) - except subprocess.TimeoutExpired: - proc.kill() + _terminate_tree(proc) + + +@contextlib.contextmanager +def _scratch_appdata(): + """점검용 %APPDATA%. + + 치울 때 실패해도 점검을 실패시키지 않는다. Windows 는 방금 죽인 프로세스가 + 파일 핸들을 놓는 데 잠깐 시간이 걸려서, 임시 폴더 삭제가 PermissionError 로 + 튀는 일이 있다. 그건 exe 의 문제가 아니라 치우는 쪽 사정이다. + """ + tmp = Path(tempfile.mkdtemp(prefix="livesub-smoke-")) + try: + yield tmp + finally: + shutil.rmtree(tmp, ignore_errors=True) def run_exe_check(exe: Path) -> None: @@ -491,17 +535,17 @@ def run_exe_check(exe: Path) -> None: @check("exe 첫 실행 — 저장 위치 화면") def _first_run(): # APPDATA 를 빈 폴더로 돌려 "처음 켜는 사람" 을 그대로 재현한다. - with tempfile.TemporaryDirectory() as tmp: - title = _launch_and_wait_for_window(exe, appdata=Path(tmp)) + with _scratch_appdata() as tmp: + title = _launch_and_wait_for_window(exe, appdata=tmp) return f"창 제목 {title!r}" @check("exe 두 번째 실행 — 본 화면") def _second_run(): # 위치를 이미 고른 사람. 묻지 않고 곧바로 본 화면이 떠야 한다. - with tempfile.TemporaryDirectory() as tmp: - data = Path(tmp) / "data" + with _scratch_appdata() as tmp: + data = tmp / "data" data.mkdir() - pointer = Path(tmp) / "LiveSub" + pointer = tmp / "LiveSub" pointer.mkdir() (pointer / "location.json").write_text( json.dumps({"data_dir": str(data)}), encoding="utf-8" @@ -511,7 +555,9 @@ def run_exe_check(exe: Path) -> None: (data / "config.json").write_text( json.dumps({"models": {"preload_on_start": False}}), encoding="utf-8" ) - title = _launch_and_wait_for_window(exe, appdata=Path(tmp)) + title = _launch_and_wait_for_window(exe, appdata=tmp) + if "저장 위치" in title: + raise RuntimeError(f"위치를 이미 골랐는데 또 물어봤습니다: {title!r}") return f"창 제목 {title!r}"