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 from __future__ import annotations
import argparse import argparse
import contextlib
import json import json
import os import os
import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -440,6 +442,37 @@ def _visible_windows(pids: set[int]) -> list[str]:
return titles 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: def _launch_and_wait_for_window(exe: Path, appdata: Path, timeout_s: int = 90) -> str:
"""exe 를 띄우고 **실제 창이 뜰 때까지** 기다린다. """exe 를 띄우고 **실제 창이 뜰 때까지** 기다린다.
@@ -469,11 +502,22 @@ def _launch_and_wait_for_window(exe: Path, appdata: Path, timeout_s: int = 90) -
time.sleep(1) time.sleep(1)
raise RuntimeError(f"{timeout_s}초 안에 창이 뜨지 않았습니다") raise RuntimeError(f"{timeout_s}초 안에 창이 뜨지 않았습니다")
finally: finally:
proc.terminate() _terminate_tree(proc)
try:
proc.wait(timeout=15)
except subprocess.TimeoutExpired: @contextlib.contextmanager
proc.kill() 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: def run_exe_check(exe: Path) -> None:
@@ -491,17 +535,17 @@ def run_exe_check(exe: Path) -> None:
@check("exe 첫 실행 — 저장 위치 화면") @check("exe 첫 실행 — 저장 위치 화면")
def _first_run(): def _first_run():
# APPDATA 를 빈 폴더로 돌려 "처음 켜는 사람" 을 그대로 재현한다. # APPDATA 를 빈 폴더로 돌려 "처음 켜는 사람" 을 그대로 재현한다.
with tempfile.TemporaryDirectory() as tmp: with _scratch_appdata() as tmp:
title = _launch_and_wait_for_window(exe, appdata=Path(tmp)) title = _launch_and_wait_for_window(exe, appdata=tmp)
return f"창 제목 {title!r}" return f"창 제목 {title!r}"
@check("exe 두 번째 실행 — 본 화면") @check("exe 두 번째 실행 — 본 화면")
def _second_run(): def _second_run():
# 위치를 이미 고른 사람. 묻지 않고 곧바로 본 화면이 떠야 한다. # 위치를 이미 고른 사람. 묻지 않고 곧바로 본 화면이 떠야 한다.
with tempfile.TemporaryDirectory() as tmp: with _scratch_appdata() as tmp:
data = Path(tmp) / "data" data = tmp / "data"
data.mkdir() data.mkdir()
pointer = Path(tmp) / "LiveSub" pointer = tmp / "LiveSub"
pointer.mkdir() pointer.mkdir()
(pointer / "location.json").write_text( (pointer / "location.json").write_text(
json.dumps({"data_dir": str(data)}), encoding="utf-8" 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( (data / "config.json").write_text(
json.dumps({"models": {"preload_on_start": False}}), encoding="utf-8" 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}" return f"창 제목 {title!r}"