# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0

"""Tests for the parent-lifetime reaper (utils/process_lifetime).

The Linux PDEATHSIG cases spawn real processes and assert actual liveness; the
Windows Job Object path is exercised with a mocked kernel32 so it runs on CI.
"""

from __future__ import annotations

import os
import signal
import subprocess
import sys
import time
from pathlib import Path

import pytest

_BACKEND = Path(__file__).resolve().parent.parent
if str(_BACKEND) not in sys.path:
    sys.path.insert(0, str(_BACKEND))

import utils.process_lifetime as pl  # noqa: E402

IS_POSIX = os.name == "posix"
IS_LINUX = sys.platform.startswith("linux")


@pytest.fixture(autouse = True)
def _reset_module_state():
    pl._tracked_pids.clear()
    pl._win_job_handle = None
    pl._initialized = False
    yield
    pl._tracked_pids.clear()
    pl._win_job_handle = None
    pl._initialized = False


def _alive(pid: int) -> bool:
    if sys.platform == "win32":
        return _win_alive(pid)
    try:
        os.kill(pid, 0)  # POSIX existence probe (on Windows this would terminate it)
        return True
    except OSError:
        return False


def _win_alive(pid: int) -> bool:
    import ctypes

    PROCESS_QUERY_LIMITED_INFORMATION, STILL_ACTIVE = 0x1000, 259
    kernel32 = ctypes.windll.kernel32
    handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
    if not handle:
        return False
    code = ctypes.c_ulong()
    kernel32.GetExitCodeProcess(handle, ctypes.byref(code))
    kernel32.CloseHandle(handle)
    return code.value == STILL_ACTIVE


def _wait_dead(pid: int, timeout: float) -> bool:
    end = time.time() + timeout
    while time.time() < end:
        if not _alive(pid):
            return True
        time.sleep(0.05)
    return not _alive(pid)


# ── No-op safety / composition ──


def test_initialize_idempotent_and_noop_on_posix():
    pl.initialize_parent_lifetime()
    pl.initialize_parent_lifetime()  # second call short-circuits
    if IS_POSIX:
        assert pl._win_job_handle is None  # POSIX installs no job


def test_adopt_pid_tolerates_none_and_dead_pid():
    pl.adopt_pid(None)  # ignored
    pl.adopt_pid(2**31 - 1)  # almost-certainly-dead pid: recorded, never raises
    assert None not in pl._tracked_pids


def test_child_popen_kwargs_linux_vs_other(monkeypatch):
    monkeypatch.setattr(pl, "_is_linux", lambda: True)
    assert "preexec_fn" in pl.child_popen_kwargs()
    monkeypatch.setattr(pl, "_is_linux", lambda: False)
    assert pl.child_popen_kwargs() == {}  # Windows/macOS add nothing here


def test_compose_preexec_runs_pdeathsig_then_existing(monkeypatch):
    calls = []
    monkeypatch.setattr(pl, "_is_linux", lambda: True)
    monkeypatch.setattr(pl, "_pdeathsig_preexec", lambda: calls.append("death"))
    pl.compose_preexec(lambda: calls.append("existing"))()
    assert calls == ["death", "existing"]  # ordering matters for sandbox hooks


def test_compose_preexec_passthrough_off_linux(monkeypatch):
    monkeypatch.setattr(pl, "_is_linux", lambda: False)
    sentinel = lambda: None  # noqa: E731
    assert pl.compose_preexec(sentinel) is sentinel
    assert pl.compose_preexec(None) is None


# ── Real Linux PDEATHSIG: child dies when the parent dies abnormally ──


@pytest.mark.skipif(not IS_LINUX, reason = "PR_SET_PDEATHSIG is Linux-only")
def test_pdeathsig_child_dies_when_parent_sigkilled(tmp_path):
    mid = tmp_path / "mid.py"
    mid.write_text(
        "import sys, subprocess, time\n"
        f"sys.path.insert(0, {str(_BACKEND)!r})\n"
        "from utils.process_lifetime import child_popen_kwargs\n"
        "p = subprocess.Popen(['sleep', '300'], **child_popen_kwargs())\n"
        "print(p.pid, flush = True)\n"
        "time.sleep(300)\n"
    )
    proc = subprocess.Popen([sys.executable, str(mid)], stdout = subprocess.PIPE, text = True)
    try:
        sleeper_pid = int(proc.stdout.readline().strip())
        assert _alive(sleeper_pid)
        proc.kill()  # hard-kill the parent (no graceful shutdown runs)
        proc.wait(timeout = 5)
        assert _wait_dead(sleeper_pid, 5.0), "child orphaned after parent SIGKILL"
    finally:
        proc.kill()


@pytest.mark.skipif(sys.platform != "win32", reason = "Windows Job Object")
def test_windows_job_kills_child_when_parent_dies(tmp_path):
    # Real kill-on-job-close: the parent installs the job and assigns itself, a
    # child inherits it automatically, and terminating the parent must reap the
    # child (the orphaned-cloudflared.exe scenario).
    mid = tmp_path / "mid.py"
    mid.write_text(
        "import sys, subprocess, time\n"
        f"sys.path.insert(0, {str(_BACKEND)!r})\n"
        "import utils.process_lifetime as pl\n"
        "pl.initialize_parent_lifetime()\n"
        "p = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(300)'])\n"
        "print(p.pid, int(pl._win_job_handle is not None), flush = True)\n"
        "time.sleep(300)\n"
    )
    proc = subprocess.Popen([sys.executable, str(mid)], stdout = subprocess.PIPE, text = True)
    try:
        first = proc.stdout.readline().split()
        child_pid, installed = int(first[0]), first[1] == "1"
        assert installed, "Windows Job Object was not installed"
        assert _alive(child_pid)
        proc.kill()  # TerminateProcess the parent -> last job handle closes
        proc.wait(timeout = 5)
        assert _wait_dead(child_pid, 5.0), "child orphaned after parent killed"
    finally:
        proc.kill()


# ── terminate_all backstop sweep ──


@pytest.mark.skipif(not IS_POSIX, reason = "POSIX process sweep")
def test_terminate_all_signals_tracked_and_is_idempotent():
    p = subprocess.Popen(["sleep", "300"])
    pl.adopt_pid(p.pid)
    pl.terminate_all()
    assert p.wait(timeout = 5) is not None  # reap + confirm it died
    pl.terminate_all()  # registry now empty; must not raise


@pytest.mark.skipif(not IS_POSIX, reason = "POSIX process sweep")
def test_terminate_all_escalates_to_sigkill():
    # A child that ignores SIGTERM must still be reaped via SIGKILL after timeout.
    p = subprocess.Popen(
        [
            sys.executable,
            "-c",
            "import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(300)",
        ]
    )
    time.sleep(0.5)  # let the handler install
    pl.adopt_pid(p.pid)
    pl.terminate_all(timeout = 0.3)
    assert p.wait(timeout = 5) == -signal.SIGKILL  # SIGTERM ignored, SIGKILL wins


@pytest.mark.skipif(not IS_POSIX, reason = "POSIX process sweep")
def test_terminate_all_lets_cooperative_child_exit_cleanly(tmp_path):
    # A child that handles SIGTERM gets `timeout` to exit cleanly (not -SIGKILL).
    marker = tmp_path / "clean.txt"
    p = subprocess.Popen(
        [
            sys.executable,
            "-c",
            "import signal, sys, time\n"
            f"def h(*a): open({str(marker)!r}, 'w').write('clean'); sys.exit(0)\n"
            "signal.signal(signal.SIGTERM, h)\n"
            "time.sleep(300)\n",
        ]
    )
    time.sleep(0.5)
    pl.adopt_pid(p.pid)
    pl.terminate_all(timeout = 3.0)
    assert p.wait(timeout = 3) == 0  # exited via its own handler, not SIGKILL
    assert marker.read_text() == "clean"


def test_forget_pid_unregisters():
    pl.adopt_pid(4242)
    assert 4242 in pl._tracked_pids
    pl.forget_pid(4242)
    assert 4242 not in pl._tracked_pids


@pytest.mark.skipif(not IS_POSIX, reason = "POSIX process sweep")
def test_terminate_all_skips_recycled_pid(monkeypatch):
    # A tracked pid whose identity changed (recycled) must not be signalled.
    p = subprocess.Popen(["sleep", "300"])
    pl.adopt_pid(p.pid)  # records the real identity
    monkeypatch.setattr(pl, "_pid_identity", lambda _pid: "DIFFERENT")
    pl.terminate_all()
    assert _alive(p.pid)  # left untouched: identity mismatch
    p.kill()
    p.wait(timeout = 5)


@pytest.mark.skipif(not IS_LINUX, reason = "PR_SET_PDEATHSIG is Linux-only")
def test_bind_kills_multiprocessing_child_on_parent_death(tmp_path):
    # multiprocessing workers can't take a preexec_fn, so the child binds itself
    # via bind_current_process_to_parent_lifetime(). Killing the parent must reap
    # it (the gap reviewers found in adopt_pid alone).
    mid = tmp_path / "mid_mp.py"
    mid.write_text(
        "import sys, time, multiprocessing as mp\n"
        f"sys.path.insert(0, {str(_BACKEND)!r})\n"
        "from utils.process_lifetime import bind_current_process_to_parent_lifetime\n"
        "def _child():\n"
        "    bind_current_process_to_parent_lifetime()\n"
        "    time.sleep(300)\n"
        "if __name__ == '__main__':\n"
        "    p = mp.get_context('spawn').Process(target = _child, daemon = True)\n"
        "    p.start()\n"
        "    print(p.pid, flush = True)\n"
        "    time.sleep(300)\n"
    )
    proc = subprocess.Popen([sys.executable, str(mid)], stdout = subprocess.PIPE, text = True)
    try:
        child_pid = int(proc.stdout.readline().strip())
        assert _alive(child_pid)
        proc.kill()
        proc.wait(timeout = 5)
        assert _wait_dead(child_pid, 5.0), "mp child orphaned after parent SIGKILL"
    finally:
        proc.kill()


# ── Windows Job Object path (mocked kernel32, runs on Linux CI) ──


class _Call:
    def __init__(self, name, log, ret):
        self.name, self.log, self.ret = name, log, ret
        self.restype = self.argtypes = None

    def __call__(self, *a, **k):
        self.log.append(self.name)
        return self.ret


class _FakeKernel32:
    def __init__(
        self,
        log,
        create_ret = 4321,
        set_ret = 1,
        assign_ret = 1,
    ):
        self.CreateJobObjectW = _Call("create", log, create_ret)
        self.SetInformationJobObject = _Call("set", log, set_ret)
        self.AssignProcessToJobObject = _Call("assign", log, assign_ret)
        self.GetCurrentProcess = _Call("getcur", log, -1)
        self.CloseHandle = _Call("close", log, 1)


def _patch_windows(monkeypatch, fake):
    import ctypes
    monkeypatch.setattr(pl, "_is_windows", lambda: True)
    monkeypatch.setattr(ctypes, "WinDLL", lambda *a, **k: fake, raising = False)


def test_windows_job_install_order(monkeypatch):
    log: list[str] = []
    _patch_windows(monkeypatch, _FakeKernel32(log))
    pl._install_windows_job()
    assert log.index("create") < log.index("set") < log.index("assign")
    assert pl._win_job_handle == 4321  # handle retained


def test_windows_job_install_degrades_on_create_failure(monkeypatch):
    log: list[str] = []
    _patch_windows(monkeypatch, _FakeKernel32(log, create_ret = 0))
    pl._install_windows_job()  # must not raise
    assert pl._win_job_handle is None
    assert "set" not in log  # short-circuited after the failed create


def test_windows_job_install_degrades_on_assign_failure(monkeypatch):
    log: list[str] = []
    _patch_windows(monkeypatch, _FakeKernel32(log, assign_ret = 0))
    pl._install_windows_job()
    assert pl._win_job_handle is None  # not retained when assignment fails
    assert "close" in log  # the orphaned job handle is closed
