From 7f678fba7b06b2ef912772d5e367b0bd82735972 Mon Sep 17 00:00:00 2001 From: EJClaw Date: Mon, 28 Sep 2026 18:12:47 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20exe=20=EC=A0=90=EA=B2=80=EC=9D=B4=20?= =?UTF-8?q?=EC=9E=90=EC=8B=9D=20=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4?= =?UTF-8?q?=EB=A5=BC=20=EC=95=88=20=EC=A3=BD=EC=97=AC=20=EC=9E=84=EC=8B=9C?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20=EC=A0=95=EB=A6=AC=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=ED=95=98=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onefile 의 부모는 압축을 푸는 부트로더일 뿐이고 창과 로그 파일을 쥐고 있는 것은 자식이다. 부모만 terminate 하니 자식이 livesub.log 를 계속 잡고 있어 임시 APPDATA 를 지울 때 PermissionError 가 났다. exe 의 문제가 아니라 점검이 뒷정리를 못 한 것인데 exe 기동 실패로 보고됐다. - 프로세스 트리를 통째로 정리한다 - 임시폴더 삭제는 실패해도 점검을 실패시키지 않는다 - 두 번째 실행에서 저장 위치 화면이 또 뜨면 실패로 본다. "기억한다"는 약속이 깨진 것이므로 본 화면이 아닌 창은 통과가 아니다 --- packaging/windows_smoke.py | 68 ++++++++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/packaging/windows_smoke.py b/packaging/windows_smoke.py index 04d8a05..567caba 100644 --- a/packaging/windows_smoke.py +++ b/packaging/windows_smoke.py @@ -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() - try: - proc.wait(timeout=15) - except subprocess.TimeoutExpired: - proc.kill() + _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: @@ -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}"