"""Cancel tracker must register BEFORE StreamingResponse returns and clean up
in each async generator's `finally`, else a Stop before the first SSE chunk
leaves a zombie decode (a BackgroundTask would be skipped when stream_response raises).

Structural verifies registration placement and try/finally cleanup; behavioral
verifies the extracted `_TrackedCancel` cleans up across completion/OSError/aclose
and that a pre-set cancel_event breaks the GGUF loop cleanly with final_chunk + [DONE]."""

from __future__ import annotations

import ast
import asyncio
import json
import threading
import time
from pathlib import Path


SOURCE_PATH = Path(__file__).resolve().parents[2] / "studio" / "backend" / "routes" / "inference.py"
SRC = SOURCE_PATH.read_text()
_TREE = ast.parse(SRC)


# ── Structural (AST) helpers ─────────────────────────────────


def _collect_async_functions(tree: ast.AST):
    return [n for n in ast.walk(tree) if isinstance(n, ast.AsyncFunctionDef)]


def _has_tracker_enter_call(node: ast.AST) -> bool:
    for sub in ast.walk(node):
        if not isinstance(sub, ast.Call):
            continue
        fn = sub.func
        if (
            isinstance(fn, ast.Attribute)
            and fn.attr == "__enter__"
            and isinstance(fn.value, ast.Name)
            and fn.value.id.startswith("_tracker")
        ):
            return True
    return False


def _finalbody_has_tracker_exit(finalbody) -> bool:
    for stmt in finalbody:
        if not isinstance(stmt, ast.Expr):
            continue
        call = stmt.value
        if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Attribute)):
            continue
        fn = call.func
        if (
            fn.attr == "__exit__"
            and isinstance(fn.value, ast.Name)
            and fn.value.id.startswith("_tracker")
        ):
            return True
    return False


def _async_function(name: str) -> ast.AsyncFunctionDef:
    for node in ast.walk(_TREE):
        if isinstance(node, ast.AsyncFunctionDef) and node.name == name:
            return node
    raise AssertionError(f"{name} handler missing")


def _calls_name(node: ast.AST, name: str) -> bool:
    for sub in ast.walk(node):
        if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Name):
            if sub.func.id == name:
                return True
    return False


# ── Structural tests ─────────────────────────────────────────


def test_no_tracker_enter_inside_async_generators():
    offenders = []
    for fn in _collect_async_functions(_TREE):
        if fn.name in {
            "gguf_tool_stream",
            "gguf_stream_chunks",
            "stream_chunks",
            "audio_input_stream",
        }:
            if _has_tracker_enter_call(fn):
                offenders.append(fn.name)
    assert not offenders, (
        f"Cancel tracker registration must live OUTSIDE the async generator "
        f"body so a stop POST can find the registry entry before the first "
        f"SSE chunk. Offending generators: {offenders}"
    )


def test_tracker_enter_exists_in_sync_body_of_chat_completions():
    top = None
    for n in ast.walk(_TREE):
        if isinstance(n, ast.AsyncFunctionDef) and n.name == "openai_chat_completions":
            top = n
            break
    assert top is not None, "openai_chat_completions handler missing"
    count = 0
    for sub in ast.walk(top):
        if not isinstance(sub, ast.Call):
            continue
        fn = sub.func
        if (
            isinstance(fn, ast.Attribute)
            and fn.attr == "__enter__"
            and isinstance(fn.value, ast.Name)
            and fn.value.id.startswith("_tracker")
        ):
            count += 1
    assert count >= 3, (
        f"expected >=3 _tracker.__enter__() calls in openai_chat_completions "
        f"(one per streaming path), got {count}"
    )


def test_async_generators_cleanup_tracker_in_finally():
    required = {
        "gguf_tool_stream",
        "gguf_stream_chunks",
        "stream_chunks",
        "audio_input_stream",
    }
    found: set[str] = set()
    for fn in [n for n in ast.walk(_TREE) if isinstance(n, ast.AsyncFunctionDef)]:
        if fn.name not in required:
            continue
        for sub in ast.walk(fn):
            if isinstance(sub, ast.Try) and sub.finalbody:
                if _finalbody_has_tracker_exit(sub.finalbody):
                    found.add(fn.name)
                    break
    missing = required - found
    assert not missing, (
        f"Cleanup must run via `finally: _tracker.__exit__(None, None, None)` "
        f"inside each streaming generator so ClientDisconnect / OSError paths "
        f"also release registry entries (Starlette skips `background` callbacks "
        f"when stream_response raises). Missing in: {sorted(missing)}"
    )


def test_chat_completions_streams_avoid_starlette_task_group():
    top = _async_function("openai_chat_completions")
    legacy_calls = []
    same_task_calls = 0
    for sub in ast.walk(top):
        if not (isinstance(sub, ast.Call) and isinstance(sub.func, ast.Name)):
            continue
        if sub.func.id == "StreamingResponse":
            legacy_calls.append(sub.lineno)
        if sub.func.id == "_SameTaskStreamingResponse":
            same_task_calls += 1
    assert not legacy_calls, (
        "Streaming /v1/chat/completions must use _SameTaskStreamingResponse, "
        "not Starlette's legacy task-group StreamingResponse. Lines: "
        f"{legacy_calls}"
    )
    assert same_task_calls >= 5


def test_openai_passthrough_stream_avoids_starlette_task_group():
    top = _async_function("_openai_passthrough_stream")
    legacy_calls = []
    same_task_calls = 0
    for sub in ast.walk(top):
        if not (isinstance(sub, ast.Call) and isinstance(sub.func, ast.Name)):
            continue
        if sub.func.id == "StreamingResponse":
            legacy_calls.append(sub.lineno)
        if sub.func.id == "_SameTaskStreamingResponse":
            same_task_calls += 1
    assert not legacy_calls, (
        "OpenAI passthrough streams must use _SameTaskStreamingResponse, "
        "not Starlette's legacy task-group StreamingResponse. Lines: "
        f"{legacy_calls}"
    )
    assert same_task_calls >= 2


def test_local_chat_streams_install_same_task_disconnect_watcher():
    top = _async_function("openai_chat_completions")
    assert _calls_name(top, "_await_disconnect_then_cancel"), (
        "Local same-task streams must watch request disconnects themselves; "
        "do not restore Starlette's task-group StreamingResponse for this."
    )


def test_direct_llama_server_streams_install_disconnect_watcher():
    required = {
        "openai_completions",
        "_responses_stream",
        "_anthropic_passthrough_stream",
        "_openai_passthrough_stream",
    }
    missing = [
        name
        for name in sorted(required)
        if not _calls_name(_async_function(name), "_await_disconnect_then_close")
    ]
    assert not missing, (
        "Direct httpx streams to llama-server must close the upstream response "
        "when the downstream client disconnects during prefill. Missing in: "
        f"{missing}"
    )


def test_audio_input_stream_installs_disconnect_watcher():
    audio = _async_function("audio_input_stream")
    has_watcher = False
    has_cleanup = False
    for sub in ast.walk(audio):
        if isinstance(sub, ast.Call):
            fn = sub.func
            if (
                isinstance(fn, ast.Attribute)
                and fn.attr == "create_task"
                and isinstance(fn.value, ast.Name)
                and fn.value.id == "asyncio"
                and sub.args
                and isinstance(sub.args[0], ast.Call)
                and isinstance(sub.args[0].func, ast.Name)
                and sub.args[0].func.id == "_await_disconnect_then_cancel"
            ):
                has_watcher = True
            if isinstance(fn, ast.Name) and fn.id == "_stop_local_disconnect_cancel_watcher":
                has_cleanup = True
    assert has_watcher, (
        "audio_input_stream must install a disconnect watcher so client "
        "disconnects set cancel_event while asyncio.to_thread(next, ...) is blocked"
    )
    assert has_cleanup, "audio_input_stream must stop its disconnect watcher in finally"


# ── Behavioral helpers ───────────────────────────────────────

_WANTED = {
    "_CANCEL_REGISTRY",
    "_CANCEL_LOCK",
    "_PENDING_CANCELS",
    "_PENDING_CANCEL_TTL_S",
    "_prune_pending",
    "_TrackedCancel",
    "_cancel_by_keys",
    "_cancel_by_cancel_id_or_stash",
}


def _load_registry_module():
    chunks = []
    for n in _TREE.body:
        seg = ast.get_source_segment(SRC, n)
        if seg is None:
            continue
        if isinstance(n, (ast.FunctionDef, ast.ClassDef)) and n.name in _WANTED:
            chunks.append(seg)
        elif isinstance(n, ast.Assign):
            names = [t.id for t in n.targets if isinstance(t, ast.Name)]
            if any(name in _WANTED for name in names):
                chunks.append(seg)
        elif (
            isinstance(n, ast.AnnAssign)
            and isinstance(n.target, ast.Name)
            and n.target.id in _WANTED
        ):
            chunks.append(seg)
    mod = {}
    exec("import threading, time\n" + "\n\n".join(chunks), mod)
    return mod


def _load_same_task_response_module():
    for n in _TREE.body:
        if isinstance(n, ast.ClassDef) and n.name == "_SameTaskStreamingResponse":
            source = ast.get_source_segment(SRC, n)
            break
    else:
        raise AssertionError("_SameTaskStreamingResponse missing")
    mod = {}
    exec(
        "class StreamingResponse: pass\nclass ClientDisconnect(Exception): pass\n" + source,
        mod,
    )
    return mod


def _make_stream(tracker, raise_exc):
    async def gen():
        try:
            try:
                yield "data: first\n\n"
                if raise_exc is not None:
                    raise raise_exc
                yield "data: [DONE]\n\n"
            except asyncio.CancelledError:
                raise
            except Exception:
                yield "data: error\n\n"
            finally:
                tracker.__exit__(None, None, None)
        except BaseException:
            raise

    return gen()


async def _consume(agen):
    out = []
    try:
        async for ch in agen:
            out.append(ch)
    except BaseException as e:
        out.append(type(e).__name__)
    return out


def _llama_stub_raises_on_preset_cancel(cancel_event):
    # Reproduces llama_cpp.py _stream_with_retry `raise GeneratorExit` when
    # cancel_event is already set at entry.
    if cancel_event.is_set():
        raise GeneratorExit
    yield "cumulative-1"
    yield "cumulative-2"


async def _post_fix_gguf_loop(cancel_event):
    yield "first_chunk"
    gen = _llama_stub_raises_on_preset_cancel(cancel_event)
    sentinel = object()
    while True:
        if cancel_event.is_set():
            break
        cumulative = await asyncio.to_thread(next, gen, sentinel)
        if cumulative is sentinel:
            break
        yield cumulative
    yield "final_chunk"
    yield "[DONE]"


# ── Behavioral tests ─────────────────────────────────────────


def test_finally_cleanup_on_normal_completion():
    m = _load_registry_module()
    m["_CANCEL_REGISTRY"].clear()
    ev = threading.Event()
    tr = m["_TrackedCancel"](ev, "cid-ok", "sid-ok")
    tr.__enter__()
    assert "cid-ok" in m["_CANCEL_REGISTRY"]
    chunks = asyncio.run(_consume(_make_stream(tr, None)))
    assert chunks == ["data: first\n\n", "data: [DONE]\n\n"]
    assert "cid-ok" not in m["_CANCEL_REGISTRY"]
    assert "sid-ok" not in m["_CANCEL_REGISTRY"]


def test_finally_cleanup_on_mid_stream_exception():
    # OSError mid-stream: the exact case where pre-fix `background=BackgroundTask(...)`
    # was skipped and leaked the registry entry.
    m = _load_registry_module()
    m["_CANCEL_REGISTRY"].clear()
    ev = threading.Event()
    tr = m["_TrackedCancel"](ev, "cid-err", "sid-err")
    tr.__enter__()
    assert "cid-err" in m["_CANCEL_REGISTRY"]
    asyncio.run(_consume(_make_stream(tr, OSError("disconnect"))))
    assert "cid-err" not in m["_CANCEL_REGISTRY"]
    assert "sid-err" not in m["_CANCEL_REGISTRY"]


def test_finally_cleanup_on_aclose():
    # Starlette calls aclose() on client disconnect; the finally block must run.
    m = _load_registry_module()
    m["_CANCEL_REGISTRY"].clear()
    ev = threading.Event()
    tr = m["_TrackedCancel"](ev, "cid-abort", "sid-abort")
    tr.__enter__()
    assert "cid-abort" in m["_CANCEL_REGISTRY"]

    async def run():
        gen = _make_stream(tr, None)
        it = gen.__aiter__()
        await it.__anext__()
        await gen.aclose()

    asyncio.run(run())
    assert "cid-abort" not in m["_CANCEL_REGISTRY"]
    assert "sid-abort" not in m["_CANCEL_REGISTRY"]


def test_same_task_response_closes_body_iterator_on_send_disconnect():
    m = _load_same_task_response_module()
    closed = False

    async def body():
        nonlocal closed
        try:
            yield "data: first\n\n"
        finally:
            closed = True

    async def run():
        agen = body()
        await agen.__anext__()
        response = m["_SameTaskStreamingResponse"].__new__(m["_SameTaskStreamingResponse"])
        response.body_iterator = agen
        response.background = None
        # __new__ bypasses __init__; __call__'s disconnect branch reads _unstarted_cleanup.
        response._unstarted_cleanup = None

        async def stream_response(_send):
            raise OSError("client disconnected")

        response.stream_response = stream_response
        try:
            await response({}, None, lambda _message: None)
        except m["ClientDisconnect"]:
            pass
        else:
            raise AssertionError("expected ClientDisconnect")

    asyncio.run(run())
    assert closed


def test_preset_cancel_event_exits_cleanly_with_done():
    # Pending-replay: a stashed cancel pre-set cancel_event. The loop must break
    # cleanly with final_chunk + [DONE], not propagate GeneratorExit from the GGUF wrapper.
    ev = threading.Event()
    ev.set()
    chunks = asyncio.run(_consume(_post_fix_gguf_loop(ev)))
    assert "first_chunk" in chunks
    assert "final_chunk" in chunks
    assert "[DONE]" in chunks
    assert "GeneratorExit" not in chunks
    assert "cumulative-1" not in chunks
    assert "cumulative-2" not in chunks


def test_normal_path_streams_all_tokens():
    # Regression: the top-of-loop cancel_event check must not short-circuit when unset.
    ev = threading.Event()
    chunks = asyncio.run(_consume(_post_fix_gguf_loop(ev)))
    assert chunks == ["first_chunk", "cumulative-1", "cumulative-2", "final_chunk", "[DONE]"]


def test_cancel_during_streaming_stops_iteration_promptly():
    # Setting cancel_event between yields breaks on the next iteration, not draining the generator.
    ev = threading.Event()

    async def _run():
        gen = _post_fix_gguf_loop(ev)
        seen = []
        async for ch in gen:
            seen.append(ch)
            if ch == "cumulative-1":
                ev.set()
        return seen

    seen = asyncio.run(_run())
    assert "first_chunk" in seen
    assert "cumulative-1" in seen
    assert "cumulative-2" not in seen
    assert "final_chunk" in seen
    assert "[DONE]" in seen


# ── Cancel-event responsiveness in the streaming loops ───────


def _loop_has_cancel_event_check(fn) -> bool:
    # An `if cancel_event.is_set():` inside a loop body is sufficient -- without it
    # a cancel POST can't interrupt, since Colab-style proxies drop request.is_disconnected().
    for sub in ast.walk(fn):
        if not isinstance(sub, (ast.While, ast.For, ast.AsyncFor)):
            continue
        for stmt in ast.walk(sub):
            if not isinstance(stmt, ast.If):
                continue
            t = stmt.test
            if (
                isinstance(t, ast.Call)
                and isinstance(t.func, ast.Attribute)
                and t.func.attr == "is_set"
                and isinstance(t.func.value, ast.Name)
                and t.func.value.id == "cancel_event"
            ):
                return True
    return False


def test_streaming_generators_check_cancel_event_in_loop():
    required = {
        "gguf_tool_stream",
        "gguf_stream_chunks",
        "stream_chunks",
        "audio_input_stream",
    }
    missing = []
    for fn in [n for n in ast.walk(_TREE) if isinstance(n, ast.AsyncFunctionDef)]:
        if fn.name not in required:
            continue
        if not _loop_has_cancel_event_check(fn):
            missing.append(fn.name)
    assert not missing, (
        f"Each streaming generator must check `cancel_event.is_set()` inside "
        f"its main loop so `POST /api/inference/cancel` can interrupt the "
        f"stream through proxies that do not forward fetch aborts. "
        f"Missing in: {sorted(missing)}"
    )


def test_audio_input_stream_offloads_blocking_next_to_thread():
    # Guards against regressing to `for chunk_text in audio_input_generate():`, which
    # blocks the event loop per whisper chunk and stalls POST /api/inference/cancel.
    audio = None
    for fn in ast.walk(_TREE):
        if isinstance(fn, ast.AsyncFunctionDef) and fn.name == "audio_input_stream":
            audio = fn
            break
    assert audio is not None, "audio_input_stream generator missing"

    for sub in ast.walk(audio):
        if isinstance(sub, (ast.For, ast.AsyncFor)):
            it_src = ast.unparse(sub.iter)
            assert "audio_input_generate" not in it_src, (
                "audio_input_stream must not iterate audio_input_generate() "
                "directly -- that blocks the event loop. Use "
                "`await asyncio.to_thread(next, gen, _DONE)` inside a "
                "`while True` loop instead"
            )

    found_to_thread_next = False
    for sub in ast.walk(audio):
        if not isinstance(sub, ast.Call):
            continue
        fn_expr = sub.func
        if not (
            isinstance(fn_expr, ast.Attribute)
            and fn_expr.attr == "to_thread"
            and isinstance(fn_expr.value, ast.Name)
            and fn_expr.value.id == "asyncio"
        ):
            continue
        if sub.args and isinstance(sub.args[0], ast.Name) and sub.args[0].id == "next":
            found_to_thread_next = True
            break
    assert found_to_thread_next, (
        "audio_input_stream must call `asyncio.to_thread(next, gen, ...)` "
        "to keep the event loop free while whisper yields the next chunk"
    )


def test_generate_stream_offloads_blocking_next_to_thread():
    outer = None
    for fn in ast.walk(_TREE):
        if isinstance(fn, ast.AsyncFunctionDef) and fn.name == "generate_stream":
            outer = fn
            break
    assert outer is not None, "generate_stream handler missing"

    inner = None
    for sub in ast.walk(outer):
        if isinstance(sub, ast.AsyncFunctionDef) and sub.name == "stream":
            inner = sub
            break
    assert inner is not None, "generate_stream inner stream() generator missing"

    for sub in ast.walk(inner):
        if isinstance(sub, (ast.For, ast.AsyncFor)):
            it_src = ast.unparse(sub.iter)
            assert "generate_chat_response" not in it_src, (
                "generate_stream's inner stream() must not iterate "
                "backend.generate_chat_response() directly -- that blocks the event "
                "loop on every blocking subprocess read between tokens. Use "
                "`await asyncio.to_thread(next, gen, _DONE)` inside a `while True` "
                "loop instead"
            )

    found_to_thread_next = False
    for sub in ast.walk(inner):
        if not isinstance(sub, ast.Call):
            continue
        fn_expr = sub.func
        if not (
            isinstance(fn_expr, ast.Attribute)
            and fn_expr.attr == "to_thread"
            and isinstance(fn_expr.value, ast.Name)
            and fn_expr.value.id == "asyncio"
        ):
            continue
        if sub.args and isinstance(sub.args[0], ast.Name) and sub.args[0].id == "next":
            found_to_thread_next = True
            break
    assert found_to_thread_next, (
        "generate_stream's inner stream() must call "
        "`asyncio.to_thread(next, gen, _DONE)` to keep the event loop free while the "
        "worker subprocess produces the next token"
    )


def test_generate_stream_cancels_backend_on_stream_cancelled_error():
    outer = None
    for fn in ast.walk(_TREE):
        if isinstance(fn, ast.AsyncFunctionDef) and fn.name == "generate_stream":
            outer = fn
            break
    assert outer is not None, "generate_stream handler missing"

    outer_src = ast.unparse(outer)
    assert "cancel_event = threading.Event()" in outer_src

    inner = None
    for sub in ast.walk(outer):
        if isinstance(sub, ast.AsyncFunctionDef) and sub.name == "stream":
            inner = sub
            break
    assert inner is not None, "generate_stream inner stream() generator missing"

    def _awaits_to_thread_gen_close(node: ast.AST) -> bool:
        for sub in ast.walk(node):
            if not isinstance(sub, ast.Await):
                continue
            call = sub.value
            if not isinstance(call, ast.Call):
                continue
            fn_expr = call.func
            if not (
                isinstance(fn_expr, ast.Attribute)
                and fn_expr.attr == "to_thread"
                and isinstance(fn_expr.value, ast.Name)
                and fn_expr.value.id == "asyncio"
            ):
                continue
            if not call.args:
                continue
            close_expr = call.args[0]
            if (
                isinstance(close_expr, ast.Attribute)
                and close_expr.attr == "close"
                and isinstance(close_expr.value, ast.Name)
                and close_expr.value.id == "gen"
            ):
                return True
        return False

    found_cancel_kwarg = False
    found_cancel_handler = False
    found_finally_cleanup = False
    for sub in ast.walk(inner):
        if isinstance(sub, ast.Call):
            call_src = ast.unparse(sub.func)
            if call_src.endswith("generate_chat_response"):
                found_cancel_kwarg = any(
                    kw.arg == "cancel_event"
                    and isinstance(kw.value, ast.Name)
                    and kw.value.id == "cancel_event"
                    for kw in sub.keywords
                )
        if isinstance(sub, ast.ExceptHandler):
            exc_src = ast.unparse(sub.type) if sub.type is not None else ""
            if exc_src != "asyncio.CancelledError":
                continue
            body_src = "\n".join(ast.unparse(stmt) for stmt in sub.body)
            found_cancel_handler = (
                "cancel_event.set()" in body_src
                and "backend.reset_generation_state()" in body_src
                and any(isinstance(stmt, ast.Raise) and stmt.exc is None for stmt in sub.body)
            )
        if isinstance(sub, ast.Try) and sub.finalbody:
            final_src = "\n".join(ast.unparse(stmt) for stmt in sub.finalbody)
            found_finally_cleanup = (
                "not completed" in final_src
                and "not cancel_event.is_set()" in final_src
                and "cancel_event.set()" in final_src
                and "backend.reset_generation_state()" in final_src
                and _awaits_to_thread_gen_close(sub)
            )

    assert found_cancel_kwarg, (
        "generate_stream must pass cancel_event into backend.generate_chat_response "
        "so cancelled streams can stop backend generation"
    )
    assert found_cancel_handler, (
        "generate_stream must catch asyncio.CancelledError, set cancel_event, "
        "reset backend state, and re-raise"
    )
    assert found_finally_cleanup, (
        "generate_stream cleanup must cancel/reset incomplete streams and "
        "offload gen.close() with asyncio.to_thread so backend joins cannot "
        "block the event loop"
    )


def test_stream_chunks_cancel_branch_resets_backend_state():
    # The cancel branch must call backend.reset_generation_state() to flush
    # GPU/KV-cache state, else cancel-via-POST leaves the subprocess dirty.
    fn = None
    top = None
    for n in ast.walk(_TREE):
        if isinstance(n, ast.AsyncFunctionDef) and n.name == "openai_chat_completions":
            top = n
            break
    assert top is not None
    for n in ast.walk(top):
        if isinstance(n, ast.AsyncFunctionDef) and n.name == "stream_chunks":
            fn = n
            break
    assert fn is not None, "stream_chunks generator missing"

    for sub in ast.walk(fn):
        if not isinstance(sub, ast.If):
            continue
        t = sub.test
        if not (
            isinstance(t, ast.Call)
            and isinstance(t.func, ast.Attribute)
            and t.func.attr == "is_set"
            and isinstance(t.func.value, ast.Name)
            and t.func.value.id == "cancel_event"
        ):
            continue
        body_src = "\n".join(ast.unparse(s) for s in sub.body)
        if "backend.reset_generation_state()" in body_src:
            return
    raise AssertionError(
        "stream_chunks `if cancel_event.is_set():` branch must call "
        "backend.reset_generation_state() -- matches the existing "
        "request.is_disconnected() / CancelledError cleanup paths and "
        "prevents KV-cache drift after cancel-via-POST"
    )


# ── Behavioral simulations for the iter-1 fixes ──────────────


def test_unsloth_stream_loop_breaks_on_external_cancel_event():
    cancel_event = threading.Event()
    reset_calls = [0]

    class _Backend:
        def reset_generation_state(self):
            reset_calls[0] += 1

    backend = _Backend()

    def _generate():
        for i in range(200):
            time.sleep(0.005)
            yield f"cum-{i}"

    async def _loop():
        _DONE = object()
        loop = asyncio.get_event_loop()
        gen = _generate()
        seen = []
        while True:
            if cancel_event.is_set():
                backend.reset_generation_state()
                break
            cumulative = await loop.run_in_executor(None, next, gen, _DONE)
            if cumulative is _DONE:
                break
            seen.append(cumulative)
        return seen

    async def _fire():
        await asyncio.sleep(0.05)
        cancel_event.set()

    async def _main():
        return await asyncio.gather(_loop(), _fire())

    seen, _ = asyncio.run(_main())
    assert (
        len(seen) < 200
    ), f"loop must not drain the generator after cancel; got {len(seen)} tokens"
    assert reset_calls[0] == 1, (
        f"backend.reset_generation_state() must be called exactly once on "
        f"cancel-via-POST, got {reset_calls[0]}"
    )


def test_generate_stream_stays_responsive_under_blocking_next():
    # Same sync-generator shape as generate_stream, with resp_queue.get modeled
    # by sleep. The output must stay unchanged while next() moves off-loop.
    chunks = ["alpha", "beta", "gamma", "delta"]

    def _generate_chat_response():
        for chunk in chunks:
            time.sleep(0.08)
            yield chunk

    def _sse(chunk):
        return f"data: {json.dumps({'content': chunk})}\n\n"

    async def _direct_loop():
        out = []
        for chunk in _generate_chat_response():
            out.append(_sse(chunk))
        out.append("data: [DONE]\n\n")
        return out

    async def _to_thread_loop():
        _DONE = object()
        gen = _generate_chat_response()
        out = []
        try:
            while True:
                chunk = await asyncio.to_thread(next, gen, _DONE)
                if chunk is _DONE:
                    break
                out.append(_sse(chunk))
            out.append("data: [DONE]\n\n")
            return out
        finally:
            try:
                gen.close()
            except (RuntimeError, ValueError):
                pass

    async def _run_with_heartbeat(loop_coro):
        ticks = 0
        max_gap = 0.0

        async def _heartbeat():
            nonlocal ticks, max_gap
            last = time.monotonic()
            while True:
                await asyncio.sleep(0.01)
                now = time.monotonic()
                max_gap = max(max_gap, now - last)
                last = now
                ticks += 1

        heartbeat = asyncio.create_task(_heartbeat())
        await asyncio.sleep(0)
        try:
            out = await loop_coro()
        finally:
            heartbeat.cancel()
            try:
                await heartbeat
            except asyncio.CancelledError:
                pass
        return out, ticks, max_gap

    async def _main():
        direct_out, direct_ticks, direct_max_gap = await _run_with_heartbeat(_direct_loop)
        threaded_out, threaded_ticks, threaded_max_gap = await _run_with_heartbeat(_to_thread_loop)
        return (
            direct_out,
            direct_ticks,
            direct_max_gap,
            threaded_out,
            threaded_ticks,
            threaded_max_gap,
        )

    (
        direct_out,
        direct_ticks,
        direct_max_gap,
        threaded_out,
        threaded_ticks,
        threaded_max_gap,
    ) = asyncio.run(_main())

    assert threaded_out == direct_out == [_sse(chunk) for chunk in chunks] + ["data: [DONE]\n\n"]
    assert direct_ticks == 0, (
        f"direct generate_stream loop should block the event loop; "
        f"got {direct_ticks} heartbeat ticks and max gap {direct_max_gap:.3f}s"
    )
    assert threaded_ticks >= 8, (
        f"to_thread generate_stream loop should let the event loop run; "
        f"got {threaded_ticks} heartbeat ticks"
    )
    assert threaded_max_gap < 0.06, (
        f"to_thread generate_stream loop should avoid long heartbeat gaps; "
        f"got {threaded_max_gap:.3f}s"
    )


def test_audio_stream_stays_responsive_under_blocking_next():
    # Assert the pre-fix `for chunk in audio_input_generate()` pattern blocks the
    # event loop, then confirm the post-fix pattern exits promptly.
    cancel_event = threading.Event()

    def _audio_gen():
        for i in range(8):
            time.sleep(0.15)
            yield f"chunk-{i}"

    async def _prefix_loop():
        seen = []
        for chunk_text in _audio_gen():
            if cancel_event.is_set():
                break
            seen.append(chunk_text)
        return seen

    async def _postfix_loop():
        _DONE = object()
        gen = _audio_gen()
        seen = []
        while True:
            if cancel_event.is_set():
                break
            chunk_text = await asyncio.to_thread(next, gen, _DONE)
            if chunk_text is _DONE:
                break
            seen.append(chunk_text)
        return seen

    async def _fire_early():
        await asyncio.sleep(0.05)
        cancel_event.set()

    async def _run(loop_coro):
        return await asyncio.gather(loop_coro, _fire_early())

    cancel_event.clear()
    t0 = time.monotonic()
    prefix_seen, _ = asyncio.run(_run(_prefix_loop()))
    prefix_elapsed = time.monotonic() - t0
    assert prefix_elapsed >= 0.13, (
        f"pre-fix pattern should block event loop for >=1 chunk time "
        f"(~150ms); got {prefix_elapsed:.3f}s, {len(prefix_seen)} chunks"
    )

    cancel_event.clear()
    t0 = time.monotonic()
    postfix_seen, _ = asyncio.run(_run(_postfix_loop()))
    postfix_elapsed = time.monotonic() - t0
    assert postfix_elapsed < prefix_elapsed, (
        f"post-fix pattern must exit faster than pre-fix (blocking) "
        f"pattern; post={postfix_elapsed:.3f}s vs pre={prefix_elapsed:.3f}s"
    )
    assert (
        len(postfix_seen) < 8
    ), f"post-fix loop must not drain all chunks; got {len(postfix_seen)}"


def test_unsloth_stream_loop_emits_zero_tokens_on_preset_cancel():
    # Pending-cancel replay: cancel_event pre-set, so the top-of-loop check must
    # short-circuit iteration 1 (zero tokens). Catches moving the check below next().
    cancel_event = threading.Event()
    cancel_event.set()
    reset_calls = [0]

    class _Backend:
        def reset_generation_state(self):
            reset_calls[0] += 1

    backend = _Backend()

    next_calls = [0]

    def _generate():
        while True:
            next_calls[0] += 1
            yield f"cum-{next_calls[0]}"

    async def _loop():
        _DONE = object()
        loop = asyncio.get_event_loop()
        gen = _generate()
        seen = []
        while True:
            if cancel_event.is_set():
                backend.reset_generation_state()
                break
            cumulative = await loop.run_in_executor(None, next, gen, _DONE)
            if cumulative is _DONE:
                break
            seen.append(cumulative)
        return seen

    seen = asyncio.run(_loop())
    assert seen == [], (
        f"loop must emit zero tokens when cancel_event is pre-set "
        f"(pending-replay path); got {seen}"
    )
    assert next_calls[0] == 0, (
        f"loop must not call next() at all on pre-set cancel; got " f"{next_calls[0]} calls"
    )
    assert reset_calls[0] == 1, (
        f"backend.reset_generation_state() must still fire exactly once "
        f"on pre-set cancel; got {reset_calls[0]}"
    )


def test_audio_stream_emits_zero_chunks_on_preset_cancel():
    # Symmetric to the Unsloth pre-set test: the audio loop must skip
    # asyncio.to_thread(next, ...) when cancel_event was pre-set via pending-replay.
    cancel_event = threading.Event()
    cancel_event.set()

    next_calls = [0]

    def _audio_gen():
        while True:
            next_calls[0] += 1
            yield f"chunk-{next_calls[0]}"

    async def _loop():
        _DONE = object()
        gen = _audio_gen()
        seen = []
        while True:
            if cancel_event.is_set():
                break
            chunk_text = await asyncio.to_thread(next, gen, _DONE)
            if chunk_text is _DONE:
                break
            seen.append(chunk_text)
        return seen

    seen = asyncio.run(_loop())
    assert seen == [], f"audio loop must emit zero chunks on pre-set cancel; got {seen}"
    assert next_calls[0] == 0, (
        f"audio loop must not call next() on pre-set cancel; got " f"{next_calls[0]} calls"
    )
