fix: exe 점검이 자식 프로세스를 안 죽여 임시폴더 정리에서 실패하던 것
Some checks failed
Windows / verify (push) Has been cancelled

onefile 의 부모는 압축을 푸는 부트로더일 뿐이고 창과 로그 파일을 쥐고 있는
것은 자식이다. 부모만 terminate 하니 자식이 livesub.log 를 계속 잡고 있어
임시 APPDATA 를 지울 때 PermissionError 가 났다. exe 의 문제가 아니라
점검이 뒷정리를 못 한 것인데 exe 기동 실패로 보고됐다.

- 프로세스 트리를 통째로 정리한다
- 임시폴더 삭제는 실패해도 점검을 실패시키지 않는다
- 두 번째 실행에서 저장 위치 화면이 또 뜨면 실패로 본다. "기억한다"는
  약속이 깨진 것이므로 본 화면이 아닌 창은 통과가 아니다
This commit is contained in:
EJClaw
2026-09-28 18:12:47 +09:00
parent 628280b227
commit 7f678fba7b

View File

@@ -14,8 +14,10 @@
from __future__ import annotations
import argparse
import contextlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
@@ -440,6 +442,37 @@ def _visible_windows(pids: set[int]) -> list[str]:
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, timeout_s: int = 90) -> str:
"""exe 를 띄우고 **실제 창이 뜰 때까지** 기다린다.
@@ -469,11 +502,22 @@ def _launch_and_wait_for_window(exe: Path, appdata: Path, timeout_s: int = 90) -
time.sleep(1)
raise RuntimeError(f"{timeout_s}초 안에 창이 뜨지 않았습니다")
finally:
proc.terminate()
_terminate_tree(proc)
@contextlib.contextmanager
def _scratch_appdata():
"""점검용 %APPDATA%.
치울 때 실패해도 점검을 실패시키지 않는다. Windows 는 방금 죽인 프로세스가
파일 핸들을 놓는 데 잠깐 시간이 걸려서, 임시 폴더 삭제가 PermissionError 로
튀는 일이 있다. 그건 exe 의 문제가 아니라 치우는 쪽 사정이다.
"""
tmp = Path(tempfile.mkdtemp(prefix="livesub-smoke-"))
try:
proc.wait(timeout=15)
except subprocess.TimeoutExpired:
proc.kill()
yield tmp
finally:
shutil.rmtree(tmp, ignore_errors=True)
def run_exe_check(exe: Path) -> None:
@@ -491,17 +535,17 @@ def run_exe_check(exe: Path) -> None:
@check("exe 첫 실행 — 저장 위치 화면")
def _first_run():
# APPDATA 를 빈 폴더로 돌려 "처음 켜는 사람" 을 그대로 재현한다.
with tempfile.TemporaryDirectory() as tmp:
title = _launch_and_wait_for_window(exe, appdata=Path(tmp))
with _scratch_appdata() as tmp:
title = _launch_and_wait_for_window(exe, appdata=tmp)
return f"창 제목 {title!r}"
@check("exe 두 번째 실행 — 본 화면")
def _second_run():
# 위치를 이미 고른 사람. 묻지 않고 곧바로 본 화면이 떠야 한다.
with tempfile.TemporaryDirectory() as tmp:
data = Path(tmp) / "data"
with _scratch_appdata() as tmp:
data = tmp / "data"
data.mkdir()
pointer = Path(tmp) / "LiveSub"
pointer = tmp / "LiveSub"
pointer.mkdir()
(pointer / "location.json").write_text(
json.dumps({"data_dir": str(data)}), encoding="utf-8"
@@ -511,7 +555,9 @@ def run_exe_check(exe: Path) -> None:
(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))
title = _launch_and_wait_for_window(exe, appdata=tmp)
if "저장 위치" in title:
raise RuntimeError(f"위치를 이미 골랐는데 또 물어봤습니다: {title!r}")
return f"창 제목 {title!r}"