diff --git a/packaging/windows_smoke.py b/packaging/windows_smoke.py index 55f457f..04d8a05 100644 --- a/packaging/windows_smoke.py +++ b/packaging/windows_smoke.py @@ -14,9 +14,11 @@ from __future__ import annotations import argparse +import json import os import subprocess import sys +import tempfile import time from pathlib import Path @@ -393,6 +395,87 @@ def run_gpu_checks() -> None: # --- 빌드된 exe 점검 -------------------------------------------------------- +#: PyInstaller 가 진입 스크립트에서 예외가 났을 때 띄우는 창. +_CRASH_MARKERS = ("unhandled exception", "failed to execute script") + + +def _process_tree(pid: int) -> set[int]: + """onefile exe 는 압축을 푼 뒤 자식 프로세스로 진짜 앱을 띄운다. + + 그래서 창의 주인은 우리가 Popen 한 pid 가 아니라 그 자식이다. 부모만 보면 + 창을 영영 못 찾는다. + """ + pids = {pid} + try: + import psutil + + parent = psutil.Process(pid) + pids.update(child.pid for child in parent.children(recursive=True)) + except Exception: # noqa: BLE001 - psutil 이 없어도 부모만으로 계속한다 + pass + return pids + + +def _visible_windows(pids: set[int]) -> list[str]: + """그 프로세스들이 띄운 보이는 최상위 창의 제목.""" + import ctypes + from ctypes import wintypes + + user32 = ctypes.WinDLL("user32", use_last_error=True) + titles: list[str] = [] + + @ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM) + def _collect(hwnd, _lparam): + owner = wintypes.DWORD() + user32.GetWindowThreadProcessId(hwnd, ctypes.byref(owner)) + if owner.value in pids and user32.IsWindowVisible(hwnd): + length = user32.GetWindowTextLengthW(hwnd) + buf = ctypes.create_unicode_buffer(length + 1) + user32.GetWindowTextW(hwnd, buf, length + 1) + if buf.value: + titles.append(buf.value) + return True + + user32.EnumWindows(_collect, 0) + return titles + + +def _launch_and_wait_for_window(exe: Path, appdata: Path, timeout_s: int = 90) -> str: + """exe 를 띄우고 **실제 창이 뜰 때까지** 기다린다. + + 예전에는 "8초 뒤에도 프로세스가 살아 있으면 통과" 였다. 그런데 진입 + 스크립트가 터지면 PyInstaller 가 오류창을 띄우고 그대로 서 있는다 — + 프로세스는 멀쩡히 살아 있으므로 통과로 보고됐다. 실제로 그 상태의 exe 가 + 릴리스까지 나갔다. 살아 있다는 것은 떴다는 뜻이 아니다. 창을 봐야 한다. + """ + env = dict(os.environ) + env["APPDATA"] = str(appdata) + proc = subprocess.Popen( + [str(exe)], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + try: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if proc.poll() is not None: + err = (proc.stderr.read() or b"").decode("utf-8", "replace")[-800:] + raise RuntimeError(f"창이 뜨기 전에 종료 (코드 {proc.returncode}): {err}") + + for title in _visible_windows(_process_tree(proc.pid)): + low = title.lower() + if any(marker in low for marker in _CRASH_MARKERS): + raise RuntimeError(f"오류창이 떴습니다: {title!r}") + if "livesub" in low: + return title + time.sleep(1) + raise RuntimeError(f"{timeout_s}초 안에 창이 뜨지 않았습니다") + finally: + proc.terminate() + try: + proc.wait(timeout=15) + except subprocess.TimeoutExpired: + proc.kill() + + def run_exe_check(exe: Path) -> None: print(f"\n=== 빌드된 exe 점검: {exe} ===") @@ -405,21 +488,31 @@ def run_exe_check(exe: Path) -> None: raise RuntimeError(f"{mb:.0f}MB — 너무 큼. torch 가 섞여 들어갔는지 확인") return f"{mb:.1f} MB" - @check("exe 실행 후 8초 생존") - def _launch(): - # 창을 띄우고 잠깐 살아 있는지 본다. 바로 죽으면 의존성 누락이다. - proc = subprocess.Popen([str(exe)], stdout=subprocess.PIPE, stderr=subprocess.PIPE) - time.sleep(8) - alive = proc.poll() is None - if not alive: - err = (proc.stderr.read() or b"").decode("utf-8", "replace")[-800:] - raise RuntimeError(f"즉시 종료 (코드 {proc.returncode}): {err}") - proc.terminate() - try: - proc.wait(timeout=10) - except subprocess.TimeoutExpired: - proc.kill() - return "정상 기동" + @check("exe 첫 실행 — 저장 위치 화면") + def _first_run(): + # APPDATA 를 빈 폴더로 돌려 "처음 켜는 사람" 을 그대로 재현한다. + with tempfile.TemporaryDirectory() as tmp: + title = _launch_and_wait_for_window(exe, appdata=Path(tmp)) + return f"창 제목 {title!r}" + + @check("exe 두 번째 실행 — 본 화면") + def _second_run(): + # 위치를 이미 고른 사람. 묻지 않고 곧바로 본 화면이 떠야 한다. + with tempfile.TemporaryDirectory() as tmp: + data = Path(tmp) / "data" + data.mkdir() + pointer = Path(tmp) / "LiveSub" + pointer.mkdir() + (pointer / "location.json").write_text( + json.dumps({"data_dir": str(data)}), encoding="utf-8" + ) + # 모델 미리 올리기를 꺼둔다. 켜두면 창을 확인하러 온 점검이 + # 수 GB 짜리 모델 내려받기를 시작했다가 중간에 죽인다. + (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)) + return f"창 제목 {title!r}" def main() -> int: diff --git a/src/livesub/__main__.py b/src/livesub/__main__.py index c58ddac..4ecba9b 100644 --- a/src/livesub/__main__.py +++ b/src/livesub/__main__.py @@ -1,4 +1,14 @@ -from .app import main +"""`python -m livesub` 와 포터블 exe 의 진입점. + +**여기서는 상대 임포트를 쓰면 안 된다.** PyInstaller 는 이 파일을 패키지 없이 +최상위 스크립트로 실행한다(`__package__` 가 비어 있다). 그래서 `from .app import` +는 그 자리에서 `attempted relative import with no known parent package` 로 죽는다. +exe 가 켜지자마자 오류창만 띄우던 원인이 이것이었다. + +`python -m livesub` 는 어느 형태든 동작하므로 둘 다 되는 절대 임포트로 둔다. +""" + +from livesub.app import main if __name__ == "__main__": raise SystemExit(main()) diff --git a/tests/test_portable.py b/tests/test_portable.py index 4bd48de..44f490c 100644 --- a/tests/test_portable.py +++ b/tests/test_portable.py @@ -190,3 +190,24 @@ def test_ct2_티어는_gpu_조회_없이도_통과한다(): assert tier_availability(TIERS[key])[0] finally: mgr.detect_gpu = original + + +def test_진입_스크립트는_패키지_없이도_임포트된다(): + """PyInstaller 는 진입 스크립트를 **패키지 없이** 최상위로 실행한다. + + 그래서 그 파일에 상대 임포트가 하나라도 있으면 exe 가 켜지자마자 + `attempted relative import with no known parent package` 로 죽는다. + 실제로 그 상태의 exe 가 릴리스까지 나갔고, "8초 뒤에도 살아 있으면 통과" + 였던 점검은 오류창이 떠 있는 것을 정상으로 봤다. + + `runpy.run_path` 가 바로 그 조건(패키지 문맥 없음)을 만들어준다. + run_name 을 `__main__` 이 아닌 것으로 주면 GUI 는 뜨지 않고 임포트만 돈다. + """ + import runpy + + root = SPEC.resolve().parents[1] + entry = root / "src" / "livesub" / "__main__.py" + # spec 이 실제로 이 파일을 진입점으로 쓰는지 확인한다. 아니면 엉뚱한 파일을 + # 지키고 있는 셈이 된다. + assert '"livesub" / "__main__.py"' in SPEC.read_text(encoding="utf-8") + runpy.run_path(str(entry), run_name="__livesub_frozen_probe__")