Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_1

This commit is contained in:
2026-09-12 19:54:24 +09:00
+87 -5
View File
@@ -97,6 +97,13 @@ from config.config_system import (
logging.basicConfig(level=getattr(logging, LOG_LEVEL))
logger = logging.getLogger(__name__)
# ⚠ **리로드가 멈출 때 어디까지 갔는지 알려 주는 표식**(2026-09-12).
# uvicorn 리로더는 옛 워커를 내리고 **새 워커 프로세스를 다시 띄운다.** 그 워커는 이
# 파일을 처음부터 다시 읽으므로, 이 줄이 로그에 찍히면 **새 워커가 뜨기는 한 것**이고
# 안 찍히면 리로더가 옛 워커를 못 내려 **새 워커가 아예 시작도 못 한 것**이다.
# 둘은 고칠 자리가 전혀 다른데, 이 줄이 없으면 로그만 보고는 못 가린다.
logger.info("[boot] 워커 프로세스 시작 pid=%s", os.getpid())
# ─────────────────────────────────────────────────────────────────────────
# 프론트엔드 빌드 및 서빙 함수
# ─────────────────────────────────────────────────────────────────────────
@@ -311,6 +318,38 @@ async def cleanup_expired_sessions() -> None:
# 라이프사이클: 앱 시작/종료
# ─────────────────────────────────────────────────────────────────────────
#: 종료 한 걸음에 줄 시한(초). 짧게 둔다 — 종료는 **기다리는 곳이 아니라 끝내는 곳**이다.
_SHUTDOWN_STEP_TIMEOUT_S = 5.0
async def _cancel_tasks(*tasks: asyncio.Task) -> None:
"""취소를 걸고 **실제로 끝났는지 확인한다.**
`cancel()` 은 「끝내 달라」는 요청일 뿐이라 부르고 지나가면 그 태스크가 아직 도는 중일
수 있다. 여기서 한 번 거둬야 종료가 깔끔하다.
"""
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
async def _shutdown_step(what: str, coro: Any) -> None:
"""종료 한 걸음 — **시한을 두고** 기다리고, 못 끝내면 넘어간다.
⚠ **종료는 어떤 경우에도 멈추면 안 된다** (2026-09-12 실측). 여기서 멈추면 uvicorn
리로더가 옛 프로세스를 못 내리고, 새 프로세스는 포트를 못 잡아 서버가 `stale` 인 채
**옛 코드를 계속 내준다** — 옛 화면을 보고 헛검증하게 되는 자리다.
못 끝낸 걸음은 **경고만 남기고 넘어간다.** 어차피 프로세스가 곧 사라지므로 남은
정리는 OS 가 한다. 무엇이 못 끝났는지는 로그에 이름으로 남아 다음에 바로 짚인다.
"""
try:
await asyncio.wait_for(coro, timeout=_SHUTDOWN_STEP_TIMEOUT_S)
logger.info("✓ 종료: %s", what)
except TimeoutError:
logger.warning("⚠ 종료: %s%.0f초 안에 안 끝나 건너뜀", what, _SHUTDOWN_STEP_TIMEOUT_S)
except Exception as exc:
logger.warning("⚠ 종료: %s 중 예외 — %s", what, exc)
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -375,12 +414,12 @@ async def lifespan(app: FastAPI):
# 종료
logger.info("앱 종료 중...")
cleanup_task.cancel()
resource_task.cancel()
temp_cleanup_task.cancel()
await _shutdown_step(
"백그라운드 작업 정리", _cancel_tasks(cleanup_task, resource_task, temp_cleanup_task)
)
stop_frontend_dev()
await close_db_pool()
logger.info("✓ DB 풀 종료 완료")
await _shutdown_step("DB 풀 종료", close_db_pool())
logger.info(" 종료 ")
# ─────────────────────────────────────────────────────────────────────────
@@ -659,9 +698,52 @@ _RELOAD_SKIP = {
}
def _patch_windows_reload() -> None:
"""윈도우에서 uvicorn 리로더가 **영원히 멈추는 것**을 고친다 (2026-09-12 실측).
무엇이 잘못됐나
uvicorn 0.24 의 `BaseReload.restart()` 는 윈도우에서만 옛 워커를
`os.kill(worker_pid, CTRL_C_EVENT)` 로 내리려 한다. 그런데 CTRL_C_EVENT 는
**프로세스 그룹을 지정해 보낼 수 없다** — MSDN 이 「그룹 id 가 0 이 아니면 호출은
성공하지만 신호는 전달되지 않는다」고 못박아 두었다. 워커는 리로더의 평범한
자식이라 그룹 우두머리가 아니므로 그 신호는 **아무 데도 가지 않는다.**
바로 다음 줄 `join()` 이 영원히 안 풀려서 —
· 옛 워커는 살아서 **옛 코드로 요청을 계속 처리**하고
· 새 워커는 태어나지 않으며
· 그 뒤의 파일 변경은 **감지조차 안 된다**(리로더가 그 자리에 갇혀 있다)
「고쳤는데 화면이 안 바뀐다」로 보이는 자리라 그냥 느린 것이 아니다.
실측 (그 자리를 그대로 흉내 낸 부모·자식 한 쌍)
CTRL_C_EVENT → **8초 안에 안 풀림** (DETACHED · CREATE_NO_WINDOW ·
CREATE_NO_WINDOW+NEWGROUP 셋 모두) · `terminate()` → **0.00초** ·
CTRL_BREAK_EVENT(자식을 그룹 우두머리로) → **0.00초**
⇒ uvicorn 이 **리눅스에서 이미 쓰는** `terminate()` 가 윈도우에서도 즉시 먹는다.
그래서 여기서는 그 리눅스 경로를 그대로 쓴다(새 방식을 지어내지 않는다).
⚠ 개발 기동(`DEBUG`)에서만 갈아 끼운다. uvicorn 이 위쪽에서 고치면 **이 함수를 지우면
된다** — 다른 자리는 아무것도 안 건드린다.
"""
from uvicorn._subprocess import get_subprocess
from uvicorn.supervisors.basereload import BaseReload
def restart(self: BaseReload) -> None:
self.process.terminate()
self.process.join()
self.process = get_subprocess(config=self.config, target=self.target, sockets=self.sockets)
self.process.start()
BaseReload.restart = restart
logger.info("[reload] 윈도우 리로더를 terminate() 방식으로 갈아 끼움")
if __name__ == "__main__":
import sys
import uvicorn
if DEBUG and sys.platform == "win32":
_patch_windows_reload()
logger.info(f"Starting server on {SERVER_HOST}:{SERVER_PORT}")
uvicorn.run(
"main:app",