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 from __future__ import annotations
import argparse import argparse
import json
import os import os
import subprocess import subprocess
import sys import sys
import tempfile
import time import time
from pathlib import Path from pathlib import Path
@@ -393,6 +395,87 @@ def run_gpu_checks() -> None:
# --- 빌드된 exe 점검 -------------------------------------------------------- # --- 빌드된 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: def run_exe_check(exe: Path) -> None:
print(f"\n=== 빌드된 exe 점검: {exe} ===") print(f"\n=== 빌드된 exe 점검: {exe} ===")
@@ -405,21 +488,31 @@ def run_exe_check(exe: Path) -> None:
raise RuntimeError(f"{mb:.0f}MB — 너무 큼. torch 가 섞여 들어갔는지 확인") raise RuntimeError(f"{mb:.0f}MB — 너무 큼. torch 가 섞여 들어갔는지 확인")
return f"{mb:.1f} MB" return f"{mb:.1f} MB"
@check("exe 실행 후 8초 생존") @check("exe 첫 실행 — 저장 위치 화면")
def _launch(): def _first_run():
# 창을 띄우고 잠깐 살아 있는지 본다. 바로 죽으면 의존성 누락이다. # APPDATA 를 빈 폴더로 돌려 "처음 켜는 사람" 을 그대로 재현한다.
proc = subprocess.Popen([str(exe)], stdout=subprocess.PIPE, stderr=subprocess.PIPE) with tempfile.TemporaryDirectory() as tmp:
time.sleep(8) title = _launch_and_wait_for_window(exe, appdata=Path(tmp))
alive = proc.poll() is None return f"창 제목 {title!r}"
if not alive:
err = (proc.stderr.read() or b"").decode("utf-8", "replace")[-800:] @check("exe 두 번째 실행 — 본 화면")
raise RuntimeError(f"즉시 종료 (코드 {proc.returncode}): {err}") def _second_run():
proc.terminate() # 위치를 이미 고른 사람. 묻지 않고 곧바로 본 화면이 떠야 한다.
try: with tempfile.TemporaryDirectory() as tmp:
proc.wait(timeout=10) data = Path(tmp) / "data"
except subprocess.TimeoutExpired: data.mkdir()
proc.kill() pointer = Path(tmp) / "LiveSub"
return "정상 기동" 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: def main() -> int:

View File

@@ -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__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())

View File

@@ -190,3 +190,24 @@ def test_ct2_티어는_gpu_조회_없이도_통과한다():
assert tier_availability(TIERS[key])[0] assert tier_availability(TIERS[key])[0]
finally: finally:
mgr.detect_gpu = original 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__")