3 Commits

Author SHA1 Message Date
EJClaw
bec5e285bd fix: exe 점검이 본 화면 대신 자막 오버레이를 보고 통과하던 것
Some checks failed
Windows / verify (push) Has been cancelled
"아무 창이나 뜨면 통과" 로 둔 탓에, 자막 오버레이 창(제목이 앱 이름뿐이다)이
잡혀서 본 화면이 안 떠도 통과할 수 있었다. 지난 두 번의 오보가 전부 같은
형태였다 — 느슨한 통과 조건. 어떤 창을 기다리는지 호출하는 쪽이 지정하게
바꾼다. 첫 실행은 저장 위치 화면, 두 번째 실행은 본 화면 제목에만 있는
표어를 기다린다. 찾은 창 목록을 전부 기록해 나중에 되짚을 수 있게 한다.
2026-09-28 18:17:46 +09:00
EJClaw
7f678fba7b fix: exe 점검이 자식 프로세스를 안 죽여 임시폴더 정리에서 실패하던 것
Some checks failed
Windows / verify (push) Has been cancelled
onefile 의 부모는 압축을 푸는 부트로더일 뿐이고 창과 로그 파일을 쥐고 있는
것은 자식이다. 부모만 terminate 하니 자식이 livesub.log 를 계속 잡고 있어
임시 APPDATA 를 지울 때 PermissionError 가 났다. exe 의 문제가 아니라
점검이 뒷정리를 못 한 것인데 exe 기동 실패로 보고됐다.

- 프로세스 트리를 통째로 정리한다
- 임시폴더 삭제는 실패해도 점검을 실패시키지 않는다
- 두 번째 실행에서 저장 위치 화면이 또 뜨면 실패로 본다. "기억한다"는
  약속이 깨진 것이므로 본 화면이 아닌 창은 통과가 아니다
2026-09-28 18:12:47 +09:00
EJClaw
628280b227 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 로 패키지 없는 실행을 재현하는
  테스트를 추가한다. 되돌려보면 실제로 실패하는 것을 확인했다.
2026-09-28 18:06:09 +09:00
3 changed files with 196 additions and 16 deletions

View File

@@ -14,9 +14,13 @@
from __future__ import annotations
import argparse
import contextlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
@@ -393,6 +397,135 @@ 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 _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, require: str, timeout_s: int = 90
) -> str:
"""exe 를 띄우고 **기다리던 창이 뜰 때까지** 기다린다.
예전에는 "8초 뒤에도 프로세스가 살아 있으면 통과" 였다. 그런데 진입
스크립트가 터지면 PyInstaller 가 오류창을 띄우고 그대로 서 있는다 —
프로세스는 멀쩡히 살아 있으므로 통과로 보고됐다. 실제로 그 상태의 exe 가
릴리스까지 나갔다. 살아 있다는 것은 떴다는 뜻이 아니다.
"아무 창이나 뜨면 통과" 도 부족하다. 자막 오버레이는 제목이 앱 이름
그대로라, 본 화면이 안 떠도 그게 잡혀 통과해 버린다. 그래서 어떤 창을
기다리는지(`require`)를 호출하는 쪽이 지정한다.
"""
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}")
titles = _visible_windows(_process_tree(proc.pid))
for title in titles:
if any(marker in title.lower() for marker in _CRASH_MARKERS):
raise RuntimeError(f"오류창이 떴습니다: {title!r}")
if any(require in title for title in titles):
return " / ".join(titles)
time.sleep(1)
raise RuntimeError(f"{timeout_s}초 안에 {require!r} 창이 뜨지 않았습니다")
finally:
_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:
print(f"\n=== 빌드된 exe 점검: {exe} ===")
@@ -405,21 +538,37 @@ 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 _scratch_appdata() as tmp:
titles = _launch_and_wait_for_window(exe, appdata=tmp, require="저장 위치")
return f"창 {titles!r}"
@check("exe 두 번째 실행 — 본 화면")
def _second_run():
# 위치를 이미 고른 사람. 묻지 않고 곧바로 본 화면이 떠야 한다.
with _scratch_appdata() as tmp:
data = tmp / "data"
data.mkdir()
pointer = 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"
)
# 자막 오버레이는 제목이 앱 이름뿐이라 그것만으로는 본 화면이 떴다는
# 증거가 못 된다. 본 화면 제목에만 있는 표어를 기다린다.
from livesub.constants import APP_SLOGAN
titles = _launch_and_wait_for_window(exe, appdata=tmp, require=APP_SLOGAN)
if any("저장 위치" in t for t in titles.split(" / ")):
raise RuntimeError(f"위치를 이미 골랐는데 또 물어봤습니다: {titles!r}")
return f"창 {titles!r}"
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__":
raise SystemExit(main())

View File

@@ -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__")