포터블 exe 를 실행하면 창 대신 오류창만 떴다. File "__main__.py", line 1, in <module> ImportError: attempted relative import with no known parent package PyInstaller 는 진입 스크립트를 **패키지 없이** 최상위로 실행한다 (__package__ 가 비어 있다). 그래서 src/livesub/__main__.py 첫 줄의 `from .app import main` 이 그 자리에서 죽는다. 절대 임포트로 바꾼다. `python -m livesub` 는 둘 다 동작하므로 잃는 것이 없다. 왜 못 잡았나 — exe 점검이 "8초 뒤에도 프로세스가 살아 있으면 통과" 였다. PyInstaller 오류창은 프로세스를 그대로 띄워둔 채 서 있으므로 통과로 보고됐다. 살아 있는 것은 떴다는 뜻이 아니다. 고친다: - 프로세스 생존이 아니라 **실제 창**을 본다. onefile 은 자식 프로세스가 창을 띄우므로 프로세스 트리 전체를 훑는다. - "Unhandled exception" / "Failed to execute script" 오류창을 보면 그 자리에서 실패로 판정한다. - APPDATA 를 빈 폴더로 돌려 첫 실행(저장 위치 화면)과 두 번째 실행 (본 화면)을 각각 확인한다. 둘 다 실제 창 제목으로 확인한다. - 같은 회귀를 리눅스에서도 잡도록 runpy 로 패키지 없는 실행을 재현하는 테스트를 추가한다. 되돌려보면 실제로 실패하는 것을 확인했다.
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user