fix: exe 가 켜지자마자 ImportError 로 죽던 것
Some checks failed
Windows / verify (push) Has been cancelled

포터블 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:
EJClaw
2026-09-28 18:06:09 +09:00
parent 88effe0e58
commit 628280b227
3 changed files with 140 additions and 16 deletions

View File

@@ -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: