게시판장기로 이해하는 규칙 엔진과 평가 함수 탐색예제 소스

예제 소스

장기로 이해하는 규칙 엔진과 평가 함수 탐색 · 파일 59개

daemon_janggy_stack_watchdog.py

#!/usr/bin/env python3
"""장기(janggy) 웹 감시: Next(:3007) + IDC 터널(:18088).

죽으면 재기동. Cursor 터미널과 무관하게 :3007 과 IDC :18088 터널을 유지한다.

  python3 scripts/daemon_janggy_stack_watchdog.py
"""

from __future__ import annotations

import os
import signal
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo

ROOT = Path(os.environ.get("JANGGY_ROOT", Path(__file__).resolve().parents[1]))
WEB = ROOT / "web"
OUT = Path.home() / "Library" / "Logs" / "Janggy"
HEART = OUT / "HEARTBEAT.md"
LOG = OUT / "watchdog.log"
NEXT_LOG = OUT / "next.watchdog.log"
TUNNEL_LOG = OUT / "janggy.tunnel.log"
RESTART_FLAG = OUT / "RESTART_REQUESTED"
# 있으면 next start 대신 next dev 유지 (옛 빌드 회피용 임시 모드)
DEV_FLAG = OUT / "PREFER_DEV"
KST = ZoneInfo("Asia/Seoul")

NEXT_PORT = 3007
REMOTE_PORT = 18088
SSH_HOST = "[REDACTED]"
POLL_S = 20
HTTP_TIMEOUT_S = 3.0
NEXT_RESTART_WAIT_S = 90

NODE_BIN = Path.home() / ".nvm" / "versions" / "node" / "v24.14.1" / "bin"
NPM = str(NODE_BIN / "npm")
PNPM = str(NODE_BIN / "pnpm")


def now_kst() -> datetime:
    return datetime.now(KST)


def log(msg: str) -> None:
    line = f"[{now_kst():%Y-%m-%d %H:%M:%S}] {msg}"
    print(line, flush=True)
    OUT.mkdir(parents=True, exist_ok=True)
    with LOG.open("a", encoding="utf-8") as f:
        f.write(line + "\n")


def port_open(port: int, host: str = "127.0.0.1") -> bool:
    try:
        with socket.create_connection((host, port), timeout=1.5):
            return True
    except OSError:
        return False


def http_ok(url: str, expect: int = 200) -> bool:
    try:
        req = urllib.request.Request(url, method="GET")
        with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as resp:
            return int(resp.status) == expect
    except (urllib.error.URLError, TimeoutError, OSError, ValueError):
        return False


def listeners_on(port: int) -> list[int]:
    try:
        out = subprocess.check_output(
            ["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
            text=True,
        )
    except (subprocess.CalledProcessError, FileNotFoundError, PermissionError, OSError):
        return []
    pids: list[int] = []
    for tok in out.split():
        try:
            pids.append(int(tok))
        except ValueError:
            pass
    return pids


def next_healthy() -> bool:
    return http_ok(f"http://127.0.0.1:{NEXT_PORT}/", 200) or http_ok(
        f"http://127.0.0.1:{NEXT_PORT}/play", 200
    )


def pgrep(pattern: str) -> list[int]:
    try:
        out = subprocess.check_output(["pgrep", "-f", pattern], text=True)
    except (subprocess.CalledProcessError, FileNotFoundError, PermissionError, OSError):
        return []
    me = os.getpid()
    pids: list[int] = []
    for tok in out.split():
        try:
            p = int(tok)
        except ValueError:
            continue
        if p != me:
            pids.append(p)
    return pids


def kill_pids(pids: list[int], label: str) -> None:
    if not pids:
        return
    log(f"kill {label}: {pids}")
    for p in pids:
        try:
            os.kill(p, signal.SIGTERM)
        except (ProcessLookupError, PermissionError, OSError):
            pass
    time.sleep(2)
    for p in pids:
        try:
            os.kill(p, signal.SIGKILL)
        except (ProcessLookupError, PermissionError, OSError):
            pass


def free_port(port: int) -> None:
    kill_pids(listeners_on(port), f"port:{port}")


def spawn(cmd: list[str], cwd: Path, logfile: Path) -> subprocess.Popen:
    env = os.environ.copy()
    env["PYTHONUNBUFFERED"] = "1"
    path_parts = [str(NODE_BIN), "/opt/homebrew/bin", "/usr/local/bin"]
    if env.get("PATH"):
        path_parts.append(env["PATH"])
    env["PATH"] = ":".join(path_parts)
    env["PORT"] = str(NEXT_PORT)
    logfile.parent.mkdir(parents=True, exist_ok=True)
    fp = open(logfile, "a", encoding="utf-8")
    fp.write(f"\n===== spawn {now_kst():%Y-%m-%d %H:%M:%S} =====\n")
    fp.write(" ".join(cmd) + "\n")
    fp.flush()
    return subprocess.Popen(
        cmd,
        cwd=str(cwd),
        env=env,
        stdout=fp,
        stderr=subprocess.STDOUT,
        start_new_session=True,
    )


def ensure_caffeinate() -> None:
    me = str(os.getpid())
    try:
        if pgrep(f"caffeinate -dims -w {me}"):
            return
        spawn(["caffeinate", "-dims", "-w", me], Path.home(), OUT / "caffeinate.log")
        log("caffeinate started (tied to this watchdog pid)")
    except (PermissionError, OSError) as e:
        log(f"caffeinate skip: {e}")


def proc_cmdline(pid: int) -> str:
    try:
        return subprocess.check_output(
            ["ps", "-p", str(pid), "-o", "command="], text=True
        )
    except (subprocess.CalledProcessError, FileNotFoundError, PermissionError, OSError):
        return ""


def proc_ppid(pid: int) -> int | None:
    try:
        out = subprocess.check_output(
            ["ps", "-p", str(pid), "-o", "ppid="], text=True
        ).strip()
        return int(out)
    except (subprocess.CalledProcessError, FileNotFoundError, PermissionError, OSError, ValueError):
        return None


def cmdline_is_dev(cmd: str) -> bool:
    low = cmd.lower()
    return "next dev" in low or "dev-with-ts.mjs dev" in low


def next_is_dev() -> bool:
    # listen 소켓은 next-server 자식일 수 있어 조상 cmdline 까지 본다.
    for pid in listeners_on(NEXT_PORT):
        cur: int | None = pid
        for _ in range(8):
            if cur is None or cur <= 1:
                break
            if cmdline_is_dev(proc_cmdline(cur)):
                return True
            cur = proc_ppid(cur)
    for pid in pgrep(r"/janggy/web|/janggy-web"):
        if cmdline_is_dev(proc_cmdline(pid)):
            return True
    return False


def prefer_dev() -> bool:
    return DEV_FLAG.exists() or os.environ.get("JANGGY_DEV", "").strip() in {
        "1",
        "true",
        "yes",
    }


def tunnel_up() -> bool:
    needle = f"{REMOTE_PORT}:127.0.0.1:{NEXT_PORT}"
    for pid in pgrep("ssh "):
        if needle in proc_cmdline(pid):
            return True
    return False


def ensure_tunnel() -> bool:
    if tunnel_up():
        return True
    log(f"janggy tunnel IDC :{REMOTE_PORT}→:{NEXT_PORT} DOWN → spawn")
    # 잔여 불량 포워드 정리
    kill_pids(
        [p for p in pgrep("ssh ") if f"{REMOTE_PORT}:127.0.0.1:{NEXT_PORT}" in proc_cmdline(p)],
        "janggy-tunnel",
    )
    spawn(
        [
            "ssh",
            "-N",
            "-o",
            "BatchMode=yes",
            "-o",
            "ExitOnForwardFailure=yes",
            "-o",
            "ServerAliveInterval=30",
            "-o",
            "ServerAliveCountMax=3",
            "-o",
            "StrictHostKeyChecking=accept-new",
            "-o",
            "TCPKeepAlive=yes",
            "-R",
            f"127.0.0.1:{REMOTE_PORT}:127.0.0.1:{NEXT_PORT}",
            SSH_HOST,
        ],
        Path.home(),
        TUNNEL_LOG,
    )
    deadline = time.time() + 20
    while time.time() < deadline:
        if tunnel_up():
            log("janggy tunnel UP")
            return True
        time.sleep(1)
    log("janggy tunnel STILL DOWN")
    return False


def pkg_runner() -> list[str] | None:
    if Path(PNPM).exists() and (WEB / "pnpm-lock.yaml").exists():
        return [PNPM]
    if Path(NPM).exists():
        return [NPM]
    return None


def ensure_next_build() -> bool:
    marker = WEB / ".next" / "BUILD_ID"
    if marker.exists():
        return True
    runner = pkg_runner()
    if not runner:
        log(f"npm/pnpm missing (npm={NPM})")
        return False
    if not (WEB / "node_modules").is_dir():
        log("node_modules missing → skip build")
        return False
    log("Next .next missing → run build")
    try:
        subprocess.run(
            [*runner, "run", "build"],
            cwd=str(WEB),
            check=True,
            env={
                **os.environ,
                "PATH": f"{NODE_BIN}:{os.environ.get('PATH', '')}",
                "PORT": str(NEXT_PORT),
            },
        )
    except subprocess.CalledProcessError as e:
        log(f"build failed: {e}")
        return False
    return marker.exists()


def restart_next(n: int, *, as_dev: bool | None = None) -> bool:
    use_dev = prefer_dev() if as_dev is None else as_dev
    mode = "dev" if use_dev else "service"
    log(f"Next :{NEXT_PORT} → {mode} restart #{n}")
    kill_pids(
        pgrep(r"/janggy/web|/janggy-web.*(next|npm|pnpm)"),
        "janggy-next",
    )
    free_port(NEXT_PORT)
    if not use_dev and not ensure_next_build():
        return False
    runner = pkg_runner()
    if not runner:
        log("npm/pnpm missing")
        return False
    spawn([*runner, "run", "dev" if use_dev else "start"], WEB, NEXT_LOG)
    deadline = time.time() + NEXT_RESTART_WAIT_S
    while time.time() < deadline:
        if use_dev:
            if next_healthy() and next_is_dev():
                log(f"Next dev UP after restart #{n}")
                return True
        elif next_healthy() and not next_is_dev():
            log(f"Next service UP after restart #{n}")
            return True
        time.sleep(2)
    log(f"Next STILL DOWN after restart #{n} ({mode})")
    return False


def write_heartbeat(
    *,
    next_ok: bool,
    tunnel_ok: bool,
    next_restarts: int,
    note: str,
) -> None:
    mode = "service" if next_ok and not next_is_dev() else ("dev" if next_ok else "down")
    prefer = "on" if prefer_dev() else "off"
    lines = [
        f"# Janggy stack watchdog — {now_kst():%Y-%m-%d %H:%M:%S KST}",
        "",
        f"- **Next :{NEXT_PORT}**: `{'up' if next_ok else 'DOWN'}` ({mode})",
        f"- **prefer_dev**: `{prefer}` (`{DEV_FLAG}`)",
        f"- **IDC tunnel :{REMOTE_PORT}→:{NEXT_PORT}**: `{'up' if tunnel_ok else 'DOWN'}`",
        f"- **public**: `https://janggy.mariago.kr/`",
        f"- **next_restarts**: `{next_restarts}`",
        f"- **note**: {note}",
        "",
        "로그: `~/Library/Logs/Janggy/watchdog.log`",
        "",
    ]
    HEART.write_text("\n".join(lines), encoding="utf-8")


def acquire_singleton() -> object:
    OUT.mkdir(parents=True, exist_ok=True)
    lock_path = OUT / "watchdog.lock"
    fh = lock_path.open("a+", encoding="utf-8")
    try:
        import fcntl

        fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
    except OSError as e:
        fh.close()
        raise SystemExit(f"another janggy watchdog holds {lock_path}: {e}") from e
    fh.seek(0)
    fh.truncate()
    fh.write(f"{os.getpid()}\n")
    fh.flush()
    return fh


def main() -> int:
    OUT.mkdir(parents=True, exist_ok=True)
    lock_fh = acquire_singleton()
    log(f"janggy stack watchdog start (Next:{NEXT_PORT} tunnel:{REMOTE_PORT})")
    ensure_caffeinate()
    next_restarts = 0
    migrated_to_service = False

    while True:
        want_dev = prefer_dev()
        if RESTART_FLAG.exists():
            reason = RESTART_FLAG.read_text(encoding="utf-8").strip()
            try:
                RESTART_FLAG.unlink()
            except OSError:
                pass
            log(f"ops requested restart: {reason}")
            next_restarts += 1
            restart_next(next_restarts, as_dev=want_dev)

        nxt = next_healthy()
        if want_dev:
            if not nxt or not next_is_dev():
                if nxt and not next_is_dev():
                    log("prefer_dev on → migrate service → next dev")
                next_restarts += 1
                nxt = restart_next(next_restarts, as_dev=True)
            migrated_to_service = False
        elif nxt and next_is_dev() and not migrated_to_service:
            log("Next is dev mode → migrate to service (next start)")
            next_restarts += 1
            nxt = restart_next(next_restarts, as_dev=False)
            migrated_to_service = True
        elif not nxt:
            next_restarts += 1
            nxt = restart_next(next_restarts, as_dev=False)
            migrated_to_service = True

        tun = ensure_tunnel()
        write_heartbeat(
            next_ok=nxt,
            tunnel_ok=tun,
            next_restarts=next_restarts,
            note="polling",
        )
        time.sleep(POLL_S)


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except KeyboardInterrupt:
        log("watchdog interrupted")
        raise SystemExit(0)
    except Exception:
        log("watchdog crashed")
        import traceback

        OUT.mkdir(parents=True, exist_ok=True)
        with LOG.open("a", encoding="utf-8") as f:
            f.write(traceback.format_exc() + "\n")
        raise