fix(dev): 종료가 멈추지 않게 시한을 두고, 리로드 멈춤 자리를 로그로 가름
- 백그라운드 태스크 셋을 cancel() 만 하고 지나가던 것을 gather 로 실제로 거둠 - 「백그라운드 작업 정리」·「DB 풀 종료」 두 걸음에 각 5초 시한. 못 끝내면 경고만 남기고 넘어감 — 종료는 기다리는 곳이 아니라 끝내는 곳이고, 남은 정리는 OS 몫 - 끝에 「앱 종료 끝」을 찍어 종료가 끝까지 갔는지 로그로 갈림 - 모듈 맨 위에 [boot] 워커 프로세스 시작 pid 를 찍음. 리로드 뒤 이 줄이 찍히면 새 워커는 뜬 것(포트를 못 잡은 자리), 안 찍히면 아예 시작도 못 한 것(리로더 자리). 고칠 자리가 전혀 다른데 종전 로그로는 못 가렸음 ⚠ 이번 멈춤의 원인이라는 증거는 아직 없음 — 로그에 「앱 종료 중」이 안 찍혀 종료 코드까지 가지도 못했을 수 있음. 위 [boot] 표식이 다음 멈춤 한 번으로 그것을 가름. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GrXDD23Dvt2sR7q3X6oekp
This commit is contained in:
@@ -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("앱 종료 끝")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user