#!/usr/bin/env python3
"""DyzineFrame watchdog — keep app alive across crashes (Win11 OHD + Pi Topbox).

Auto-restart policy (persists): run/watchdog.disabled
  - Present = Auto-Restart off (survives reboot). Does NOT kill a running DF.
  - Absent = Auto-Restart on.
Stop hold (session): run/supervisor.hold — cleared on Start; cleared on boot.
UI (Pi + Win11): Auto-Restart Enable/Disable + Stop/Start (supervisor.cmd).
  Start = one-shot run session; does not clear watchdog.disabled.
Interactive: waits while blocked until Enable (idle) or Start; polls child.
Pi autostart: topboxboot.desktop → run_supervisor.sh
Win11 autostart: install_win11_autostart.ps1 → run_win11_supervisor.bat
"""

from __future__ import annotations

import argparse
import os
import signal
import subprocess
import sys
import time
from pathlib import Path

APP_ROOT = Path(__file__).resolve().parent
DEFAULT_DISABLE = APP_ROOT / "run" / "watchdog.disabled"
DEFAULT_HOLD = APP_ROOT / "run" / "supervisor.hold"
DEFAULT_CMD = APP_ROOT / "run" / "supervisor.cmd"
DEFAULT_BACKOFF = APP_ROOT / "run" / "supervisor.backoff"
DEFAULT_LOG = APP_ROOT / "log" / "supervisor.log"
DEFAULT_APP_LOG = APP_ROOT / "log" / "log.txt"
LOCK_PATH = APP_ROOT / "run" / "dyzineframe.lock"
UI_SCRIPT = APP_ROOT / "dyzineframe_supervisor_ui.py"
POLL_SEC = 0.5

try:
    if sys.platform == "win32":
        from ohd_wallpaper import (
            ensure_default_wallpaper,
            enter_out_of_order,
            exit_out_of_order,
            is_out_of_order_active,
        )
    else:
        from pi_wallpaper import (
            ensure_default_wallpaper,
            enter_out_of_order,
            exit_out_of_order,
            is_out_of_order_active,
        )
except ImportError:
    ensure_default_wallpaper = None  # type: ignore[assignment]
    enter_out_of_order = None  # type: ignore[assignment,misc]
    exit_out_of_order = None  # type: ignore[assignment,misc]
    is_out_of_order_active = lambda: False  # type: ignore[assignment,misc]

RAPID_CRASH_SEC = float(os.environ.get("DYZINEFRAME_RAPID_CRASH_SEC", "30"))
BACKOFF_SEC = float(os.environ.get("DYZINEFRAME_CRASH_BACKOFF_SEC", "300"))
SETTLE_SEC = float(os.environ.get("DYZINEFRAME_RESTART_SETTLE_SEC", "3"))
BOOT_DELAY_SEC = float(os.environ.get("DYZINEFRAME_BOOT_DELAY_SEC", "15"))
MIN_HEALTHY_RUN_SEC = float(os.environ.get("DYZINEFRAME_MIN_HEALTHY_RUN_SEC", "30"))
ROLLBACK_RAPID_THRESHOLD = int(os.environ.get("DYZINEFRAME_ROLLBACK_RAPID_COUNT", "2"))
# Matches singleton.acquire_singleton peer-hold exit (not a crash).
ALREADY_RUNNING_EXIT = 2
PEER_HOLD_RETRY_SEC = float(os.environ.get("DYZINEFRAME_PEER_HOLD_RETRY_SEC", "30"))


def utc_now() -> str:
    return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())


def log_line(path: Path, msg: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("a", encoding="utf-8") as f:
        f.write(f"{utc_now()} {msg}\n")


def disabled(path: Path) -> bool:
    return path.is_file()


def held(path: Path) -> bool:
    """User Stop while Enable — do not auto-start until Start."""
    return path.is_file()


def blocked(*, disable_file: Path, hold_file: Path) -> bool:
    return disabled(disable_file) or held(hold_file)


def pid_alive(pid: int) -> bool:
    if pid <= 0:
        return False
    if sys.platform == "win32":
        import ctypes

        handle = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid)
        if handle:
            ctypes.windll.kernel32.CloseHandle(handle)
            return True
        return False
    try:
        os.kill(pid, 0)
    except OSError:
        return False
    return True


def clear_stale_lock() -> None:
    if not LOCK_PATH.is_file():
        return
    try:
        text = LOCK_PATH.read_text(encoding="utf-8").strip()
        pid = int(text) if text.isdigit() else None
    except (OSError, ValueError):
        pid = None
    if pid is None or not pid_alive(pid):
        try:
            LOCK_PATH.unlink(missing_ok=True)
            log_line(DEFAULT_LOG, f"supervisor cleared stale lock pid={pid}")
        except OSError:
            pass


def python_exe() -> str:
    return sys.executable


def append_app_log_header() -> None:
    DEFAULT_APP_LOG.parent.mkdir(parents=True, exist_ok=True)
    with DEFAULT_APP_LOG.open("a", encoding="utf-8") as f:
        f.write(f"START {time.strftime('%m/%d/%Y %H:%M:%S')}\n")


def _ensure_pi_display_env() -> None:
    """OTA/SSH often has no DISPLAY. Pin :0 so HDMI child + Supervisor UI spawn."""
    if sys.platform == "win32":
        return
    os.environ.setdefault("DISPLAY", ":0")
    if not os.environ.get("XAUTHORITY"):
        xauth = Path.home() / ".Xauthority"
        if xauth.is_file():
            os.environ["XAUTHORITY"] = str(xauth)


def _child_env() -> dict[str, str]:
    """Ensure child sees app-local FFmpeg before WinGet Links / Store stubs."""
    _ensure_pi_display_env()
    env = os.environ.copy()
    ffmpeg_bin = APP_ROOT / "runtime" / "ffmpeg" / "bin"
    if (ffmpeg_bin / "ffmpeg.exe").is_file():
        env["PATH"] = str(ffmpeg_bin) + os.pathsep + env.get("PATH", "")
    return env


def start_child() -> subprocess.Popen:
    clear_stale_lock()
    append_app_log_header()
    app_log = DEFAULT_APP_LOG.open("a", encoding="utf-8")
    cmd = [python_exe(), str(APP_ROOT / "DyzineFrame.py")]
    proc = subprocess.Popen(
        cmd,
        cwd=str(APP_ROOT),
        env=_child_env(),
        stdout=app_log,
        stderr=subprocess.STDOUT,
        creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
    )
    proc._dyzine_log_handle = app_log  # keep handle open until child exits
    return proc


def read_lock_pid() -> int | None:
    if not LOCK_PATH.is_file():
        return None
    try:
        text = LOCK_PATH.read_text(encoding="utf-8").strip()
        pid = int(text) if text.isdigit() else 0
    except (OSError, ValueError):
        return None
    return pid if pid > 0 else None


def force_kill_pid(pid: int, *, log_path: Path, why: str) -> None:
    """Hard-kill by PID — does not rely on DF handling Q / SIGTERM."""
    if pid <= 0 or not pid_alive(pid):
        return
    log_line(log_path, f"supervisor force-kill {why} pid={pid}")
    try:
        if sys.platform == "win32":
            # Any non-CTRL signal → TerminateProcess
            os.kill(pid, signal.SIGTERM)
        else:
            os.kill(pid, signal.SIGKILL)
    except OSError as exc:
        log_line(log_path, f"supervisor force-kill fail pid={pid} {exc}")


def stop_child(proc: subprocess.Popen | None, *, log_path: Path) -> None:
    """Stop DF by force-killing PIDs (child + lock). No graceful Q path."""
    targets: set[int] = set()
    if proc is not None and proc.poll() is None:
        targets.add(proc.pid)
    lock_pid = read_lock_pid()
    if lock_pid is not None:
        targets.add(lock_pid)
    if not targets:
        return
    for pid in sorted(targets):
        force_kill_pid(pid, log_path=log_path, why="stop")
    if proc is not None:
        try:
            proc.wait(timeout=3)
        except subprocess.TimeoutExpired:
            force_kill_pid(proc.pid, log_path=log_path, why="stop-retry")
            try:
                proc.wait(timeout=2)
            except subprocess.TimeoutExpired:
                pass
    clear_stale_lock()


def _close_child_log(proc: subprocess.Popen | None) -> None:
    if proc is None:
        return
    log_handle = getattr(proc, "_dyzine_log_handle", None)
    if log_handle is None:
        return
    try:
        log_handle.close()
    except OSError:
        pass
    try:
        delattr(proc, "_dyzine_log_handle")
    except AttributeError:
        pass


def show_out_of_order(*, log_path: Path) -> bool:
    if enter_out_of_order is None:
        log_line(log_path, "supervisor out_of_order skip missing ohd_wallpaper.py")
        return False
    try:
        ok = enter_out_of_order()
        log_line(log_path, f"supervisor out_of_order wallpaper {'ok' if ok else 'fail'}")
        return ok
    except OSError as exc:
        log_line(log_path, f"supervisor out_of_order fail {exc}")
        return False


def clear_out_of_order(*, log_path: Path) -> None:
    if exit_out_of_order is None or not is_out_of_order_active():
        return
    try:
        ok = exit_out_of_order()
        log_line(log_path, f"supervisor wallpaper restore {'ok' if ok else 'fail'}")
    except OSError as exc:
        log_line(log_path, f"supervisor wallpaper restore fail {exc}")


def enforce_default_wallpaper(*, log_path: Path) -> None:
    if ensure_default_wallpaper is None:
        log_line(log_path, "supervisor default wallpaper skip missing helper")
        return
    try:
        ok = ensure_default_wallpaper()
        log_line(log_path, f"supervisor default wallpaper {'ok' if ok else 'fail'}")
    except OSError as exc:
        log_line(log_path, f"supervisor default wallpaper fail {exc}")


def promote_pending_on_boot(*, log_path: Path) -> None:
    """Apply staged Dropbox tree once at supervisor start (before first DF spawn)."""
    if sys.platform == "win32":
        return
    pending = APP_ROOT / "run" / "staging-pending"
    if not pending.is_file():
        return
    script = APP_ROOT / "sync" / "promote_staging.sh"
    if not script.is_file():
        log_line(log_path, f"supervisor boot promote skip missing {script}")
        return
    log_line(log_path, "supervisor boot promote (staging-pending)")
    env = os.environ.copy()
    env["PROMOTE_RESTART"] = "0"
    env["FORCE"] = "0"
    try:
        proc = subprocess.run(
            ["bash", str(script)],
            cwd=str(APP_ROOT / "sync"),
            capture_output=True,
            text=True,
            timeout=180,
            check=False,
            env=env,
        )
    except (OSError, subprocess.TimeoutExpired) as exc:
        log_line(log_path, f"supervisor boot promote fail {exc}")
        return
    if proc.stdout.strip():
        log_line(log_path, f"supervisor boot promote stdout {proc.stdout.strip()[:500]}")
    if proc.stderr.strip():
        log_line(log_path, f"supervisor boot promote stderr {proc.stderr.strip()[:500]}")
    log_line(log_path, f"supervisor boot promote exit={proc.returncode}")


def rollback_live_tree(*, log_path: Path) -> bool:
    script = APP_ROOT / "sync" / "rollback_live.sh"
    if not script.is_file():
        log_line(log_path, f"supervisor rollback skip missing {script}")
        return False
    log_line(log_path, "supervisor rollback start")
    try:
        proc = subprocess.run(
            ["bash", str(script)],
            cwd=str(APP_ROOT),
            capture_output=True,
            text=True,
            timeout=120,
            check=False,
        )
    except (OSError, subprocess.TimeoutExpired) as exc:
        log_line(log_path, f"supervisor rollback fail {exc}")
        return False
    if proc.stdout.strip():
        log_line(log_path, f"supervisor rollback stdout {proc.stdout.strip()[:500]}")
    if proc.stderr.strip():
        log_line(log_path, f"supervisor rollback stderr {proc.stderr.strip()[:500]}")
    ok = proc.returncode == 0
    log_line(log_path, f"supervisor rollback {'ok' if ok else 'fail'} exit={proc.returncode}")
    return ok


def _display_available() -> bool:
    if sys.platform == "win32":
        return True
    return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))


def _ui_python_exe() -> str:
    """Prefer pythonw on Windows so the UI has no console flash."""
    exe = python_exe()
    if sys.platform == "win32":
        candidate = Path(exe).with_name("pythonw.exe")
        if candidate.is_file():
            return str(candidate)
    return exe


def spawn_ui(*, log_path: Path) -> subprocess.Popen | None:
    """Control window (Alt-Tab target) on Pi + Win11. Best-effort."""
    if not UI_SCRIPT.is_file():
        log_line(log_path, f"supervisor ui skip missing {UI_SCRIPT.name}")
        return None
    if not _display_available():
        log_line(log_path, "supervisor ui skip no DISPLAY/WAYLAND_DISPLAY")
        return None
    kwargs: dict = {
        "cwd": str(APP_ROOT),
        "env": _child_env(),
        "stdout": subprocess.DEVNULL,
        "stderr": subprocess.DEVNULL,
    }
    if sys.platform == "win32":
        # DETACHED + new process group — UI outlives console quirks of the bat.
        kwargs["creationflags"] = (
            getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
            | getattr(subprocess, "DETACHED_PROCESS", 0)
        )
    else:
        kwargs["start_new_session"] = True
    try:
        proc = subprocess.Popen([_ui_python_exe(), str(UI_SCRIPT)], **kwargs)
    except OSError as exc:
        log_line(log_path, f"supervisor ui spawn fail {exc}")
        return None
    log_line(log_path, f"supervisor ui start pid={proc.pid}")
    return proc


def ensure_ui(ui: subprocess.Popen | None, *, log_path: Path) -> subprocess.Popen | None:
    if ui is not None and ui.poll() is None:
        return ui
    if ui is not None:
        log_line(log_path, f"supervisor ui exited code={ui.returncode} — respawn")
    return spawn_ui(log_path=log_path)


def consume_cmd(cmd_path: Path) -> str | None:
    """Read and clear one-shot supervisor.cmd.

    Returns 'stop' | 'start' | 'restart' | None.
    """
    if not cmd_path.is_file():
        return None
    try:
        text = cmd_path.read_text(encoding="utf-8").strip().lower()
    except OSError:
        return None
    try:
        cmd_path.unlink(missing_ok=True)
    except OSError:
        pass
    if not text:
        return None
    first = text.splitlines()[0].strip()
    if first.startswith("{"):
        try:
            import json

            payload = json.loads(first)
            first = str(payload.get("cmd", "")).strip().lower()
        except (ValueError, TypeError):
            return None
    if first in ("stop", "start", "restart"):
        return first
    return None


def wait_child_polled(
    proc: subprocess.Popen,
    *,
    hold_file: Path,
    cmd_path: Path,
    log_path: Path,
) -> tuple[int | None, str]:
    """Poll until child exits, Stop-hold, or stop/restart cmd.

    Auto-Restart Disable must NOT kill a running child — only Stop/Q do.
    Returns (exit_code_or_None, reason): 'exited' | 'stopped' | 'restart'.
    """
    while True:
        code = proc.poll()
        if code is not None:
            return code, "exited"
        if held(hold_file):
            stop_child(proc, log_path=log_path)
            return None, "stopped"
        cmd = consume_cmd(cmd_path)
        if cmd == "stop":
            log_line(log_path, "supervisor cmd stop")
            hold_file.parent.mkdir(parents=True, exist_ok=True)
            try:
                hold_file.write_text("stopped by supervisor cmd\n", encoding="utf-8")
            except OSError:
                pass
            stop_child(proc, log_path=log_path)
            return None, "stopped"
        if cmd == "restart":
            log_line(log_path, "supervisor cmd restart")
            stop_child(proc, log_path=log_path)
            return None, "restart"
        if cmd == "start":
            # Already running — ignore
            pass
        time.sleep(POLL_SEC)


def wait_while_blocked(
    *,
    disable_file: Path,
    hold_file: Path,
    cmd_path: Path,
    log_path: Path,
    ui: subprocess.Popen | None,
) -> tuple[subprocess.Popen | None, bool]:
    """Wait until Auto-Restart can start, or Start begins a one-shot session.

    Returns (ui, one_shot_session).
    one_shot_session True = Start (or restart) while Auto-Restart may still be off;
    Disable file is left alone so reboot stays Disabled.
    """
    logged = False
    while blocked(disable_file=disable_file, hold_file=hold_file):
        if not logged:
            clear_out_of_order(log_path=log_path)
            enforce_default_wallpaper(log_path=log_path)
            why = "auto_restart_off" if disabled(disable_file) else "held"
            log_line(log_path, f"supervisor {why} — waiting for enable/start")
            logged = True
        cmd = consume_cmd(cmd_path)
        if cmd in ("start", "restart"):
            try:
                hold_file.unlink(missing_ok=True)
            except OSError:
                pass
            still_off = disabled(disable_file)
            log_line(
                log_path,
                f"supervisor cmd {cmd} — one-shot session"
                + (" (auto-restart still off)" if still_off else ""),
            )
            return ui, True
        if cmd == "stop":
            hold_file.parent.mkdir(parents=True, exist_ok=True)
            try:
                hold_file.write_text("stopped by supervisor cmd\n", encoding="utf-8")
            except OSError:
                pass
        ui = ensure_ui(ui, log_path=log_path)
        time.sleep(POLL_SEC)
    if logged:
        log_line(log_path, "supervisor auto-restart on — starting child")
    return ui, False


def write_backoff_file(
    path: Path,
    *,
    until: float,
    reason: str,
    count: int,
    runtime_s: float,
) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    body = (
        f"until={until:.3f}\n"
        f"reason={reason}\n"
        f"count={count}\n"
        f"runtime_s={runtime_s:.1f}\n"
    )
    path.write_text(body, encoding="utf-8")


def clear_backoff_file(path: Path) -> None:
    try:
        path.unlink(missing_ok=True)
    except OSError:
        pass


def wait_crash_backoff(
    *,
    seconds: float,
    cmd_path: Path,
    hold_file: Path,
    backoff_path: Path,
    log_path: Path,
    ui: subprocess.Popen | None,
    reason: str,
    count: int,
    runtime_s: float,
) -> tuple[str, subprocess.Popen | None]:
    """Poll instead of sleep(300). Returns elapsed | skipped | stopped."""
    until = time.time() + max(0.0, seconds)
    try:
        write_backoff_file(
            backoff_path,
            until=until,
            reason=reason,
            count=count,
            runtime_s=runtime_s,
        )
    except OSError as exc:
        log_line(log_path, f"supervisor backoff file fail {exc}")
    log_line(log_path, f"supervisor quick_crash backoff {seconds:.0f}s (poll)")
    try:
        while time.time() < until:
            ui = ensure_ui(ui, log_path=log_path)
            if held(hold_file):
                return "stopped", ui
            cmd = consume_cmd(cmd_path)
            if cmd in ("start", "restart"):
                log_line(log_path, f"supervisor cmd {cmd} — skip crash backoff")
                try:
                    hold_file.unlink(missing_ok=True)
                except OSError:
                    pass
                return "skipped", ui
            if cmd == "stop":
                hold_file.parent.mkdir(parents=True, exist_ok=True)
                try:
                    hold_file.write_text("stopped by supervisor cmd\n", encoding="utf-8")
                except OSError:
                    pass
                return "stopped", ui
            time.sleep(POLL_SEC)
        return "elapsed", ui
    finally:
        clear_backoff_file(backoff_path)


def _handle_child_exit(
    *,
    exit_code: int,
    runtime: float,
    child: subprocess.Popen | None,
    consecutive_rapid_crashes: int,
    log_path: Path,
    once: bool,
    cmd_path: Path,
    hold_file: Path,
    backoff_path: Path,
    ui: subprocess.Popen | None,
) -> tuple[int, bool, subprocess.Popen | None]:
    """Crash / peer-hold / settle logic after a real child exit.

    Returns (new_crash_count, should_return_from_main, ui).
    """
    if once:
        return consecutive_rapid_crashes, True, ui

    if exit_code == ALREADY_RUNNING_EXIT:
        log_line(
            log_path,
            f"supervisor peer already running - retry in {PEER_HOLD_RETRY_SEC:.0f}s",
        )
        if PEER_HOLD_RETRY_SEC > 0:
            time.sleep(PEER_HOLD_RETRY_SEC)
        return consecutive_rapid_crashes, False, ui

    crashes = consecutive_rapid_crashes
    if runtime >= MIN_HEALTHY_RUN_SEC:
        crashes = 0

    quick_crash = runtime < RAPID_CRASH_SEC
    if quick_crash:
        crashes += 1
        log_line(
            log_path,
            f"supervisor quick_crash count={crashes} runtime={runtime:.1f}s",
        )
        if crashes >= ROLLBACK_RAPID_THRESHOLD:
            stop_child(child, log_path=log_path)
            if rollback_live_tree(log_path=log_path):
                crashes = 0
                clear_stale_lock()
                if SETTLE_SEC > 0:
                    time.sleep(SETTLE_SEC)
                return crashes, False, ui
            log_line(log_path, "supervisor rollback failed — continuing with backoff")
        show_out_of_order(log_path=log_path)
        outcome, ui = wait_crash_backoff(
            seconds=BACKOFF_SEC,
            cmd_path=cmd_path,
            hold_file=hold_file,
            backoff_path=backoff_path,
            log_path=log_path,
            ui=ui,
            reason="quick_crash",
            count=crashes,
            runtime_s=runtime,
        )
        clear_out_of_order(log_path=log_path)
        if outcome == "skipped":
            return 0, False, ui
        if outcome == "stopped":
            return 0, False, ui
        return crashes, False, ui

    if SETTLE_SEC > 0:
        time.sleep(SETTLE_SEC)
    return 0, False, ui


def main() -> int:
    parser = argparse.ArgumentParser(description="DyzineFrame watchdog")
    parser.add_argument("--disable-file", type=Path, default=DEFAULT_DISABLE)
    parser.add_argument("--hold-file", type=Path, default=DEFAULT_HOLD)
    parser.add_argument("--cmd-file", type=Path, default=DEFAULT_CMD)
    parser.add_argument("--log", type=Path, default=DEFAULT_LOG)
    parser.add_argument("--boot-delay", type=float, default=BOOT_DELAY_SEC)
    parser.add_argument("--once", action="store_true", help="Run one child exit then stop")
    parser.add_argument(
        "--no-ui",
        action="store_true",
        help="Do not spawn DyzineFrame Supervisor window",
    )
    args = parser.parse_args()

    _ensure_pi_display_env()
    log_path = args.log
    disable_file = args.disable_file
    hold_file = args.hold_file
    cmd_path = args.cmd_file
    backoff_path = DEFAULT_BACKOFF
    # Pi + Win11: interactive poll/UI unless --once (single-shot child).
    interactive = not args.once

    if args.boot_delay > 0:
        log_line(log_path, f"supervisor boot_delay {args.boot_delay}s")
        time.sleep(args.boot_delay)

    log_line(log_path, "supervisor start")
    clear_backoff_file(backoff_path)
    promote_pending_on_boot(log_path=log_path)
    consecutive_rapid_crashes = 0
    child: subprocess.Popen | None = None
    ui: subprocess.Popen | None = None

    if interactive:
        # Stop-hold is session-only; Auto-Restart Disable persists across reboot.
        try:
            hold_file.unlink(missing_ok=True)
        except OSError:
            pass

    if interactive and not args.no_ui:
        ui = spawn_ui(log_path=log_path)

    try:
        while True:
            if interactive:
                ui = ensure_ui(ui, log_path=log_path)

            one_shot_session = False
            if blocked(disable_file=disable_file, hold_file=hold_file):
                stop_child(child, log_path=log_path)
                _close_child_log(child)
                child = None
                ui, one_shot_session = wait_while_blocked(
                    disable_file=disable_file,
                    hold_file=hold_file,
                    cmd_path=cmd_path,
                    log_path=log_path,
                    ui=ui,
                )
                if not one_shot_session and blocked(
                    disable_file=disable_file, hold_file=hold_file
                ):
                    continue
                if one_shot_session:
                    consecutive_rapid_crashes = 0

            clear_out_of_order(log_path=log_path)
            enforce_default_wallpaper(log_path=log_path)
            child = start_child()
            child_start = time.monotonic()
            session_note = " one-shot" if one_shot_session else ""
            log_line(
                log_path,
                f"supervisor child start pid={child.pid}{session_note}",
            )

            if interactive:
                exit_code, reason = wait_child_polled(
                    child,
                    hold_file=hold_file,
                    cmd_path=cmd_path,
                    log_path=log_path,
                )
                _close_child_log(child)
                runtime = time.monotonic() - child_start
                if reason == "stopped":
                    log_line(
                        log_path,
                        f"supervisor child stopped runtime={runtime:.1f}s",
                    )
                    child = None
                    continue
                if reason == "restart":
                    log_line(
                        log_path,
                        f"supervisor child stopped for restart runtime={runtime:.1f}s",
                    )
                    child = None
                    if SETTLE_SEC > 0:
                        time.sleep(SETTLE_SEC)
                    continue
                # exited
                assert exit_code is not None
                log_line(
                    log_path,
                    f"supervisor child exit code={exit_code} runtime={runtime:.1f}s",
                )
                consecutive_rapid_crashes, should_return, ui = _handle_child_exit(
                    exit_code=exit_code,
                    runtime=runtime,
                    child=child,
                    consecutive_rapid_crashes=consecutive_rapid_crashes,
                    log_path=log_path,
                    once=args.once,
                    cmd_path=cmd_path,
                    hold_file=hold_file,
                    backoff_path=backoff_path,
                    ui=ui,
                )
                child = None
                if should_return:
                    return exit_code
                continue

            # --once: blocking wait
            exit_code = child.wait()
            _close_child_log(child)
            runtime = time.monotonic() - child_start
            log_line(
                log_path,
                f"supervisor child exit code={exit_code} runtime={runtime:.1f}s",
            )
            return exit_code
    except KeyboardInterrupt:
        log_line(log_path, "supervisor interrupted")
        stop_child(child, log_path=log_path)
        if ui is not None and ui.poll() is None:
            ui.terminate()
        clear_out_of_order(log_path=log_path)
        return 130


if __name__ == "__main__":
    sys.exit(main())
