import asyncio
import contextlib
import operator
import sys
import threading
import time
from collections import deque
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from typing import Annotated, Any
from unittest.mock import Mock, patch
from uuid import uuid4

import pytest
from langchain_core.callbacks import AsyncCallbackManagerForLLMRun, BaseCallbackHandler
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage, HumanMessage
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
from langchain_core.runnables import RunnableConfig, RunnableLambda, RunnableParallel
from langgraph.checkpoint.memory import InMemorySaver, MemorySaver
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
from typing_extensions import TypedDict

from langgraph._internal._constants import (
    CONF,
    CONFIG_KEY_CHECKPOINT_ID,
    CONFIG_KEY_CHECKPOINT_NS,
    CONFIG_KEY_RUNTIME,
    CONFIG_KEY_SEND,
    CONFIG_KEY_STREAM,
    CONFIG_KEY_TASK_ID,
    CONFIG_KEY_THREAD_ID,
    CONFIG_KEY_TIMED_ATTEMPT_OBSERVER,
)
from langgraph._internal._runnable import RunnableCallable
from langgraph._internal._timeout import coerce_timeout_policy
from langgraph.channels.ephemeral_value import EphemeralValue
from langgraph.channels.last_value import LastValue
from langgraph.errors import (
    GraphInterrupt,
    NodeCancelledError,
    NodeError,
    NodeTimeoutError,
    ParentCommand,
)
from langgraph.func import entrypoint, task
from langgraph.graph import END, START, StateGraph, add_messages
from langgraph.pregel import NodeBuilder, Pregel
from langgraph.pregel._read import PregelNode
from langgraph.pregel._retry import (
    _checkpoint_ns_for_parent_command,
    _ensure_execution_info,
    _should_retry_on,
    _TimedAttemptScope,
    arun_with_retry,
    run_with_retry,
)
from langgraph.pregel.protocol import StreamProtocol
from langgraph.runtime import DEFAULT_RUNTIME, ExecutionInfo, Runtime
from langgraph.types import (
    Command,
    PregelExecutableTask,
    RetryPolicy,
    Send,
    TimeoutPolicy,
)

NEEDS_CONTEXTVARS = pytest.mark.skipif(
    sys.version_info < (3, 11),
    reason="Python 3.11+ is required for async contextvars support",
)

# `asyncio.Task.cancelling()` is Python 3.11+. The LSD-1507 fix in
# `langgraph/pregel/_retry.py` falls back to a no-op on 3.10 (preserves the
# existing CancelledError-as-silent-tear-down behaviour) because there is no
# reliable way to distinguish user-raised from framework-initiated
# cancellation without that API. Tests for the converted behaviour gate on
# the same Python version boundary.
NEEDS_TASK_CANCELLING = pytest.mark.skipif(
    sys.version_info < (3, 11),
    reason="LSD-1507 user-cancellation conversion requires Python 3.11+ "
    "(asyncio.Task.cancelling)",
)


def test_should_retry_on_single_exception():
    """Test retry with a single exception type."""
    policy = RetryPolicy(retry_on=ValueError)

    # Should retry on ValueError
    assert _should_retry_on(policy, ValueError("test error")) is True

    # Should not retry on other exceptions
    assert _should_retry_on(policy, TypeError("test error")) is False
    assert _should_retry_on(policy, Exception("test error")) is False


def test_should_retry_on_sequence_of_exceptions():
    """Test retry with a sequence of exception types."""
    policy = RetryPolicy(retry_on=(ValueError, KeyError))

    # Should retry on listed exceptions
    assert _should_retry_on(policy, ValueError("test error")) is True
    assert _should_retry_on(policy, KeyError("test error")) is True

    # Should not retry on other exceptions
    assert _should_retry_on(policy, TypeError("test error")) is False
    assert _should_retry_on(policy, Exception("test error")) is False


def test_should_retry_on_subclass_of_exception():
    """Test retry on subclass of specified exception."""

    class CustomError(ValueError):
        pass

    policy = RetryPolicy(retry_on=ValueError)

    # Should retry on subclass of specified exception
    assert _should_retry_on(policy, CustomError("test error")) is True


def test_should_retry_on_callable():
    """Test retry with a callable predicate."""

    # Only retry on ValueError with message containing 'retry'
    def should_retry(exc: Exception) -> bool:
        return isinstance(exc, ValueError) and "retry" in str(exc)

    policy = RetryPolicy(retry_on=should_retry)

    # Should retry when predicate returns True
    assert _should_retry_on(policy, ValueError("please retry this")) is True

    # Should not retry when predicate returns False
    assert _should_retry_on(policy, ValueError("other error")) is False
    assert _should_retry_on(policy, TypeError("please retry this")) is False


def test_should_retry_on_invalid_type():
    """Test retry with an invalid retry_on type."""
    policy = RetryPolicy(retry_on=123)  # type: ignore

    with pytest.raises(TypeError, match="retry_on must be an Exception class"):
        _should_retry_on(policy, ValueError("test error"))


def test_should_retry_on_empty_sequence():
    """Test retry with an empty sequence."""
    policy = RetryPolicy(retry_on=())

    # Should not retry when sequence is empty
    assert _should_retry_on(policy, ValueError("test error")) is False


def test_checkpoint_ns_for_parent_command() -> None:
    assert _checkpoint_ns_for_parent_command("") == ""
    assert _checkpoint_ns_for_parent_command("node:1") == ""
    assert _checkpoint_ns_for_parent_command("node:1|child:2") == "node:1"
    assert _checkpoint_ns_for_parent_command("node:1|1|child:2") == "node:1"
    assert _checkpoint_ns_for_parent_command("node:1|1|child:2|1") == "node:1"
    assert (
        _checkpoint_ns_for_parent_command("parent:1|1|child:1|1|node:1|1")
        == "parent:1|1|child:1"
    )
    assert (
        _checkpoint_ns_for_parent_command("parent:1|1|child:1|1|node:1")
        == "parent:1|1|child:1"
    )


def test_should_retry_default_retry_on():
    """Test the default retry_on function."""
    import httpx
    import requests

    # Create a RetryPolicy with default_retry_on
    policy = RetryPolicy()

    # Should retry on ConnectionError
    assert _should_retry_on(policy, ConnectionError("connection refused")) is True

    # Should not retry on common programming errors
    assert _should_retry_on(policy, ValueError("invalid value")) is False
    assert _should_retry_on(policy, TypeError("invalid type")) is False
    assert _should_retry_on(policy, ArithmeticError("division by zero")) is False
    assert _should_retry_on(policy, ImportError("module not found")) is False
    assert _should_retry_on(policy, LookupError("key not found")) is False
    assert _should_retry_on(policy, NameError("name not defined")) is False
    assert _should_retry_on(policy, SyntaxError("invalid syntax")) is False
    assert _should_retry_on(policy, RuntimeError("runtime error")) is False
    assert _should_retry_on(policy, ReferenceError("weak reference")) is False
    assert _should_retry_on(policy, StopIteration()) is False
    assert _should_retry_on(policy, StopAsyncIteration()) is False
    assert _should_retry_on(policy, OSError("file not found")) is False

    # Should retry on httpx.HTTPStatusError with 5xx status code
    response_5xx = Mock()
    response_5xx.status_code = 503
    http_error_5xx = httpx.HTTPStatusError(
        "server error", request=Mock(), response=response_5xx
    )
    assert _should_retry_on(policy, http_error_5xx) is True

    # Should not retry on httpx.HTTPStatusError with 4xx status code
    response_4xx = Mock()
    response_4xx.status_code = 404
    http_error_4xx = httpx.HTTPStatusError(
        "not found", request=Mock(), response=response_4xx
    )
    assert _should_retry_on(policy, http_error_4xx) is False

    # Should retry on requests.HTTPError with 5xx status code
    response_req_5xx = Mock()
    response_req_5xx.status_code = 502
    req_error_5xx = requests.HTTPError("bad gateway")
    req_error_5xx.response = response_req_5xx
    assert _should_retry_on(policy, req_error_5xx) is True

    # Should not retry on requests.HTTPError with 4xx status code
    response_req_4xx = Mock()
    response_req_4xx.status_code = 400
    req_error_4xx = requests.HTTPError("bad request")
    req_error_4xx.response = response_req_4xx
    assert _should_retry_on(policy, req_error_4xx) is False

    # Should retry on requests.HTTPError with no response
    req_error_no_resp = requests.HTTPError("connection error")
    req_error_no_resp.response = None
    assert _should_retry_on(policy, req_error_no_resp) is True

    # NodeTimeoutError should be retryable by default
    assert (
        _should_retry_on(
            policy,
            NodeTimeoutError("node", 1.0, kind="run", run_timeout=0.5),
        )
        is True
    )

    # Should retry on other exceptions by default
    class CustomException(Exception):
        pass

    assert _should_retry_on(policy, CustomException("custom error")) is True


def test_graph_with_single_retry_policy():
    """Test a simple graph with a single RetryPolicy for a node."""

    class State(TypedDict):
        foo: str

    attempt_count = 0
    attempt_numbers: list[int] = []
    first_attempt_times: list[float | None] = []

    def failing_node(state: State, runtime: Runtime):
        nonlocal attempt_count
        attempt_count += 1
        assert runtime.execution_info.node_attempt == attempt_count
        attempt_numbers.append(runtime.execution_info.node_attempt)
        first_attempt_times.append(runtime.execution_info.node_first_attempt_time)
        if attempt_count < 3:  # Fail the first two attempts
            raise ValueError("Intentional failure")
        return {"foo": "success"}

    def other_node(state: State):
        return {"foo": "other_node"}

    # Create a retry policy with specific parameters
    retry_policy = RetryPolicy(
        max_attempts=3,
        initial_interval=0.01,  # Short interval for tests
        backoff_factor=2.0,
        jitter=False,  # Disable jitter for predictable timing
        retry_on=ValueError,
    )

    # Create and compile the graph
    graph = (
        StateGraph(State)
        .add_node("failing_node", failing_node, retry_policy=retry_policy)
        .add_node("other_node", other_node)
        .add_edge(START, "failing_node")
        .add_edge("failing_node", "other_node")
        .compile()
    )

    with patch("time.sleep") as mock_sleep:
        result = graph.invoke({"foo": ""})

    # Verify retry behavior
    assert attempt_count == 3  # The node should have been tried 3 times
    assert attempt_numbers == [1, 2, 3]
    assert len(first_attempt_times) == 3
    assert first_attempt_times[0] is not None
    assert first_attempt_times[1] == first_attempt_times[0]
    assert first_attempt_times[2] == first_attempt_times[0]
    assert result["foo"] == "other_node"  # Final result should be from other_node

    # Verify the sleep intervals
    call_args_list = [args[0][0] for args in mock_sleep.call_args_list]
    assert call_args_list == [0.01, 0.02]


def test_runtime_execution_info_defaults_without_retry():
    """Test execution_info defaults when no retry and no config are provided."""

    class State(TypedDict):
        foo: str

    captured = {}

    def node(state: State, runtime: Runtime):
        captured["node_attempt"] = runtime.execution_info.node_attempt
        captured["node_first_attempt_time"] = (
            runtime.execution_info.node_first_attempt_time
        )
        return {"foo": "ok"}

    graph = StateGraph(State).add_node("node", node).add_edge(START, "node").compile()

    result = graph.invoke({"foo": ""})

    assert result["foo"] == "ok"
    assert captured["node_attempt"] == 1
    assert isinstance(captured["node_first_attempt_time"], float)


def test_graph_with_jitter_retry_policy():
    """Test a graph with a RetryPolicy that uses jitter."""

    class State(TypedDict):
        foo: str

    attempt_count = 0

    def failing_node(state):
        nonlocal attempt_count
        attempt_count += 1
        if attempt_count < 2:  # Fail the first attempt
            raise ValueError("Intentional failure")
        return {"foo": "success"}

    # Create a retry policy with jitter enabled
    retry_policy = RetryPolicy(
        max_attempts=3,
        initial_interval=0.01,
        jitter=True,  # Enable jitter for randomized backoff
        retry_on=ValueError,
    )

    # Create and compile the graph
    graph = (
        StateGraph(State)
        .add_node("failing_node", failing_node, retry_policy=retry_policy)
        .add_edge(START, "failing_node")
        .compile()
    )

    # Test graph execution with mocked random and sleep
    with (
        patch("random.uniform", return_value=0.05) as mock_random,
        patch("time.sleep") as mock_sleep,
    ):
        result = graph.invoke({"foo": ""})

    # Verify retry behavior
    assert attempt_count == 2  # The node should have been tried twice
    assert result["foo"] == "success"

    # Verify jitter was applied
    mock_random.assert_called_with(0, 1)  # Jitter should use random.uniform(0, 1)
    mock_sleep.assert_called_with(0.01 + 0.05)  # Sleep should include jitter


def test_graph_with_multiple_retry_policies():
    """Test a graph with multiple retry policies for a node."""

    class State(TypedDict):
        foo: str
        error_type: str

    attempt_counts = {"value_error": 0, "key_error": 0}

    def failing_node(state):
        error_type = state["error_type"]

        if error_type == "value_error":
            attempt_counts["value_error"] += 1
            if attempt_counts["value_error"] < 2:
                raise ValueError("Value error")
        elif error_type == "key_error":
            attempt_counts["key_error"] += 1
            if attempt_counts["key_error"] < 3:
                raise KeyError("Key error")

        return {"foo": f"recovered_from_{error_type}"}

    # Create multiple retry policies
    value_error_policy = RetryPolicy(
        max_attempts=2,
        initial_interval=0.01,
        jitter=False,
        retry_on=ValueError,
    )

    key_error_policy = RetryPolicy(
        max_attempts=3,
        initial_interval=0.02,
        jitter=False,
        retry_on=KeyError,
    )

    # Create and compile the graph with a list of retry policies
    graph = (
        StateGraph(State)
        .add_node(
            "failing_node",
            failing_node,
            retry_policy=(value_error_policy, key_error_policy),
        )
        .add_edge(START, "failing_node")
        .compile()
    )

    # Test ValueError scenario
    with patch("time.sleep"):
        result_value_error = graph.invoke({"foo": "", "error_type": "value_error"})

    assert attempt_counts["value_error"] == 2
    assert result_value_error["foo"] == "recovered_from_value_error"

    # Reset attempt counts
    attempt_counts = {"value_error": 0, "key_error": 0}

    # Test KeyError scenario
    with patch("time.sleep"):
        result_key_error = graph.invoke({"foo": "", "error_type": "key_error"})

    assert attempt_counts["key_error"] == 3
    assert result_key_error["foo"] == "recovered_from_key_error"


def test_graph_with_max_attempts_exceeded():
    """Test a graph where max_attempts is exceeded."""

    class State(TypedDict):
        foo: str

    def always_failing_node(state):
        raise ValueError("Always fails")

    # Create a retry policy with limited attempts
    retry_policy = RetryPolicy(
        max_attempts=2,
        initial_interval=0.01,
        jitter=False,
        retry_on=ValueError,
    )

    # Create and compile the graph
    graph = (
        StateGraph(State)
        .add_node("always_failing", always_failing_node, retry_policy=retry_policy)
        .add_edge(START, "always_failing")
        .compile()
    )

    # Test graph execution
    with (
        patch("time.sleep") as mock_sleep,
        pytest.raises(ValueError, match="Always fails"),
    ):
        graph.invoke({"foo": ""})

    mock_sleep.assert_called_with(0.01)


def test_execution_info_identity_fields_populated_on_retry():
    """Test that thread_id, task_id, run_id, etc. are populated in execution_info during retries."""

    class State(TypedDict):
        foo: str

    attempt_count = 0
    captured_infos: list[dict] = []

    def failing_node(state: State, runtime: Runtime):
        nonlocal attempt_count
        attempt_count += 1
        info = runtime.execution_info
        captured_infos.append(
            {
                "thread_id": info.thread_id,
                "run_id": info.run_id,
                "node_attempt": info.node_attempt,
                "node_first_attempt_time": info.node_first_attempt_time,
                "checkpoint_ns": info.checkpoint_ns,
            }
        )
        if attempt_count < 2:
            raise ValueError("Intentional failure")
        return {"foo": "success"}

    retry_policy = RetryPolicy(
        max_attempts=3,
        initial_interval=0.01,
        jitter=False,
        retry_on=ValueError,
    )

    graph = (
        StateGraph(State)
        .add_node("failing_node", failing_node, retry_policy=retry_policy)
        .add_edge(START, "failing_node")
        .compile(checkpointer=MemorySaver())
    )

    with patch("time.sleep"):
        result = graph.invoke(
            {"foo": ""},
            config={"configurable": {"thread_id": "retry-thread"}},
        )

    assert result["foo"] == "success"
    assert len(captured_infos) == 2

    # Both attempts should have the same thread_id and first_attempt_time
    assert captured_infos[0]["thread_id"] == "retry-thread"
    assert captured_infos[1]["thread_id"] == "retry-thread"
    assert (
        captured_infos[0]["node_first_attempt_time"]
        == captured_infos[1]["node_first_attempt_time"]
    )

    # node_attempt should increment
    assert captured_infos[0]["node_attempt"] == 1
    assert captured_infos[1]["node_attempt"] == 2


def test_ensure_execution_info_noop_when_already_set():
    """Test that _ensure_execution_info is a no-op when execution_info exists."""
    existing_info = ExecutionInfo(
        checkpoint_id="cp-1", checkpoint_ns="ns-1", task_id="task-1"
    )
    runtime = DEFAULT_RUNTIME.override(execution_info=existing_info)
    config = {CONF: {CONFIG_KEY_THREAD_ID: "thread-1"}}
    task = Mock(id="task-2")

    result = _ensure_execution_info(runtime, config, task)
    assert result is runtime
    assert result.execution_info is existing_info


def test_ensure_execution_info_creates_from_config():
    """Test that _ensure_execution_info creates ExecutionInfo from config when missing."""
    runtime = DEFAULT_RUNTIME.override(execution_info=None)
    config = {
        "run_id": "run-123",
        CONF: {
            CONFIG_KEY_CHECKPOINT_ID: "cp-42",
            CONFIG_KEY_CHECKPOINT_NS: "ns-42",
            CONFIG_KEY_TASK_ID: "task-42",
            CONFIG_KEY_THREAD_ID: "thread-42",
        },
    }
    task = Mock(id="fallback-task-id")

    result = _ensure_execution_info(runtime, config, task)
    assert result.execution_info is not None
    assert result.execution_info.checkpoint_id == "cp-42"
    assert result.execution_info.checkpoint_ns == "ns-42"
    assert result.execution_info.task_id == "task-42"
    assert result.execution_info.thread_id == "thread-42"
    assert result.execution_info.run_id == "run-123"


def test_ensure_execution_info_falls_back_to_task_id():
    """Test that _ensure_execution_info uses task.id when CONFIG_KEY_TASK_ID is missing."""
    runtime = DEFAULT_RUNTIME.override(execution_info=None)
    config = {CONF: {}}
    task = Mock(id="fallback-task-id")

    result = _ensure_execution_info(runtime, config, task)
    assert result.execution_info.task_id == "fallback-task-id"
    assert result.execution_info.checkpoint_id == ""
    assert result.execution_info.checkpoint_ns == ""


def test_run_with_retry_creates_execution_info_when_missing():
    """Test that run_with_retry works when runtime has no execution_info (distributed runtime scenario)."""
    captured_infos: list[ExecutionInfo] = []

    class FakeProc:
        def invoke(self, input, config):
            runtime = config[CONF][CONFIG_KEY_RUNTIME]
            captured_infos.append(runtime.execution_info)
            return input

    runtime = DEFAULT_RUNTIME.override(execution_info=None)
    config = {
        "run_id": "run-abc",
        CONF: {
            CONFIG_KEY_RUNTIME: runtime,
            CONFIG_KEY_CHECKPOINT_ID: "cp-99",
            CONFIG_KEY_CHECKPOINT_NS: "__start__:task123",
            CONFIG_KEY_TASK_ID: "task123",
            CONFIG_KEY_THREAD_ID: "thread-xyz",
        },
    }

    task = PregelExecutableTask(
        name="__start__",
        input={"messages": []},
        proc=FakeProc(),
        writes=deque(),
        config=config,
        triggers=["__start__"],
        retry_policy=[],
        cache_key=None,
        id="task123",
        path=("__pregel_pull", "__start__"),
    )

    run_with_retry(task, retry_policy=None)

    assert len(captured_infos) == 1
    info = captured_infos[0]
    assert info.checkpoint_id == "cp-99"
    assert info.checkpoint_ns == "__start__:task123"
    assert info.task_id == "task123"
    assert info.thread_id == "thread-xyz"
    assert info.run_id == "run-abc"
    assert info.node_attempt == 1
    assert info.node_first_attempt_time is not None


def _make_task(
    proc,
    *,
    timeout=None,
    retry_policy=(),
    name="timed",
    task_id="tid",
    writers=(),
):
    runtime = DEFAULT_RUNTIME.override(execution_info=None)
    writes = deque()
    config = {
        "run_id": "run-x",
        CONF: {
            CONFIG_KEY_RUNTIME: runtime,
            CONFIG_KEY_CHECKPOINT_ID: "cp",
            CONFIG_KEY_CHECKPOINT_NS: f"{name}:{task_id}",
            CONFIG_KEY_SEND: writes.extend,
            CONFIG_KEY_TASK_ID: task_id,
            CONFIG_KEY_THREAD_ID: "thr",
        },
    }
    return PregelExecutableTask(
        name=name,
        input=None,
        proc=proc,
        writes=writes,
        config=config,
        triggers=[name],
        retry_policy=retry_policy,
        cache_key=None,
        id=task_id,
        path=("__pregel_pull", name),
        writers=writers,
        timeout=coerce_timeout_policy(timeout),
    )


def _idle_timeout(value: float | timedelta) -> TimeoutPolicy:
    return TimeoutPolicy(idle_timeout=value)


def test_coerce_timeout_policy_scalar_is_run_timeout():
    assert coerce_timeout_policy(None) is None
    policy = coerce_timeout_policy(timedelta(milliseconds=250))
    assert policy == TimeoutPolicy(run_timeout=0.25)
    assert Send("node", None, timeout=timedelta(milliseconds=250)).timeout == policy

    idle_policy = coerce_timeout_policy(TimeoutPolicy(idle_timeout=1.5))
    assert idle_policy == TimeoutPolicy(idle_timeout=1.5)

    with pytest.raises(ValueError, match="run_timeout must be greater than 0"):
        coerce_timeout_policy(0)


def test_coerce_timeout_policy_returns_same_instance_for_already_coerced():
    policy = coerce_timeout_policy(TimeoutPolicy(run_timeout=1.0, idle_timeout=2.0))
    assert coerce_timeout_policy(policy) is policy
    assert TimeoutPolicy.coerce(policy) is policy


def test_send_timeout_round_trips_through_msgpack_serde():
    serde = JsonPlusSerializer(allowed_msgpack_modules=None)
    packet = Send(
        "worker",
        {"x": 1},
        timeout=TimeoutPolicy(run_timeout=1, idle_timeout=2),
    )

    assert serde.loads_typed(serde.dumps_typed(packet)) == packet


def test_send_without_timeout_round_trips_through_msgpack_serde():
    serde = JsonPlusSerializer(allowed_msgpack_modules=None)
    packet = Send("worker", {"x": 1})

    assert serde.loads_typed(serde.dumps_typed(packet)) == packet


def test_run_with_retry_rejects_sync_timeout_without_starting_proc():
    started = False

    class Proc:
        def invoke(self, input, config):
            nonlocal started
            started = True
            return input

    task = _make_task(Proc(), timeout=_idle_timeout(0.05), name="sync")

    with pytest.raises(ValueError, match="only supported for async nodes"):
        run_with_retry(task, retry_policy=None)
    assert not started


def test_run_with_retry_without_timeout_runs_sync_directly():
    class FastProc:
        def invoke(self, input, config):
            return "ok"

    task = _make_task(FastProc(), timeout=None)
    assert run_with_retry(task, retry_policy=None) == "ok"


def test_idle_timeout_guard_call_does_not_hold_scope_lock():
    scope = _TimedAttemptScope()

    def call():
        assert not scope._lock.locked()
        return "ok"

    assert scope._guard_call(call)() == "ok"


def test_idle_timeout_guard_stream_does_not_hold_scope_lock():
    scope = _TimedAttemptScope()

    def stream(chunk):
        assert not scope._lock.locked()
        assert chunk == ((), "custom", "ok")

    scope._guard_stream(StreamProtocol(stream, {"custom"}))(((), "custom", "ok"))


def test_idle_timeout_guard_stream_writer_does_not_hold_scope_lock():
    scope = _TimedAttemptScope()

    def stream_writer(chunk):
        assert not scope._lock.locked()
        assert chunk == "ok"

    scope._guard_stream_writer(stream_writer)("ok")


@pytest.mark.anyio
async def test_arun_with_retry_timeout_ok_when_fast():
    class FastProc:
        async def ainvoke(self, input, config):
            return "ok"

    task = _make_task(FastProc(), timeout=_idle_timeout(1.0))
    assert await arun_with_retry(task, retry_policy=None) == "ok"


@pytest.mark.anyio
async def test_arun_with_retry_timeout_retries_when_retry_on_timeout():
    calls: list[float] = []

    class FlakyProc:
        async def ainvoke(self, input, config):
            calls.append(time.monotonic())
            if len(calls) < 2:
                await asyncio.sleep(0.5)
                return "late"
            return "ok"

    policy = RetryPolicy(
        max_attempts=3,
        initial_interval=0.0,
        jitter=False,
        retry_on=NodeTimeoutError,
    )
    task = _make_task(FlakyProc(), timeout=_idle_timeout(0.05), retry_policy=(policy,))
    assert await arun_with_retry(task, retry_policy=None) == "ok"
    assert len(calls) == 2


@pytest.mark.anyio
async def test_entrypoint_timeout_allows_pre_timeout_child_task_to_run():
    child_started = threading.Event()

    @task()
    def child(value: int) -> int:
        child_started.set()
        return value + 1

    @entrypoint(timeout=TimeoutPolicy(idle_timeout=0.05))
    async def parent(value: int) -> int:
        child(value)
        await asyncio.sleep(0.2)
        return value

    with pytest.raises(NodeTimeoutError):
        await parent.ainvoke(1)
    assert child_started.wait(timeout=1.0)


@pytest.mark.anyio
async def test_arun_with_retry_timeout_accepts_timedelta():
    class SlowProc:
        async def ainvoke(self, input, config):
            await asyncio.sleep(0.5)
            return input

    task = _make_task(SlowProc(), timeout=_idle_timeout(timedelta(milliseconds=50)))
    with pytest.raises(NodeTimeoutError):
        await arun_with_retry(task, retry_policy=None)


@pytest.mark.anyio
async def test_arun_with_retry_timeout_fires_async():
    class SlowProc:
        async def ainvoke(self, input, config):
            await asyncio.sleep(1.0)
            return input

    task = _make_task(SlowProc(), timeout=_idle_timeout(0.05), name="aslow")
    with pytest.raises(NodeTimeoutError) as excinfo:
        await arun_with_retry(task, retry_policy=None)
    assert excinfo.value.node == "aslow"
    assert excinfo.value.idle_timeout == 0.05


@pytest.mark.anyio
async def test_arun_with_retry_run_timeout_is_not_refreshed_by_heartbeat():
    class HeartbeatingProc:
        async def ainvoke(self, input, config):
            runtime = config[CONF][CONFIG_KEY_RUNTIME]
            while True:
                runtime.heartbeat()
                await asyncio.sleep(0.01)

    task = _make_task(HeartbeatingProc(), timeout=0.05, name="run-timeout")
    with pytest.raises(NodeTimeoutError) as excinfo:
        await arun_with_retry(task, retry_policy=None)
    assert excinfo.value.kind == "run"
    assert excinfo.value.run_timeout == 0.05
    assert excinfo.value.idle_timeout is None


@pytest.mark.anyio
async def test_node_timeout_error_carries_both_configured_timeouts():
    """Both `idle_timeout` and `run_timeout` reflect the configured policy
    even when only one of them fires."""

    class SlowProc:
        async def ainvoke(self, input, config):
            await asyncio.sleep(1.0)

    task = _make_task(
        SlowProc(),
        timeout=TimeoutPolicy(run_timeout=0.05, idle_timeout=0.5),
        name="both",
    )
    with pytest.raises(NodeTimeoutError) as excinfo:
        await arun_with_retry(task, retry_policy=None)
    assert excinfo.value.kind == "run"
    assert excinfo.value.run_timeout == 0.05
    assert excinfo.value.idle_timeout == 0.5
    # `timeout` is the one that fired.
    assert excinfo.value.timeout == 0.05


@pytest.mark.anyio
async def test_arun_with_retry_does_not_swallow_proc_asyncio_timeout():
    calls = 0

    class InnerTimeoutProc:
        async def ainvoke(self, input, config):
            nonlocal calls
            calls += 1
            raise asyncio.TimeoutError("inner")

    # `retry_on=NodeTimeoutError` + `calls == 1` is the load-bearing assertion:
    # if the proc's TimeoutError were misclassified as NodeTimeoutError it
    # would be retried, and `calls` would be 2.
    policy = RetryPolicy(
        max_attempts=2,
        initial_interval=0.0,
        jitter=False,
        retry_on=NodeTimeoutError,
    )
    task = _make_task(
        InnerTimeoutProc(),
        timeout=_idle_timeout(1.0),
        retry_policy=(policy,),
        name="parent",
    )
    with pytest.raises(asyncio.TimeoutError, match="inner"):
        await arun_with_retry(task, retry_policy=None)
    assert calls == 1


@pytest.mark.anyio
async def test_arun_with_retry_does_not_swallow_proc_node_timeout():
    child_timeout = NodeTimeoutError("child", 0.2, kind="idle", idle_timeout=0.1)

    class ChildTimeoutProc:
        async def ainvoke(self, input, config):
            raise child_timeout

    task = _make_task(ChildTimeoutProc(), timeout=_idle_timeout(1.0), name="parent")
    with pytest.raises(NodeTimeoutError) as excinfo:
        await arun_with_retry(task, retry_policy=None)
    assert excinfo.value is child_timeout
    assert excinfo.value.node == "child"


@pytest.mark.anyio
async def test_arun_with_retry_idle_timeout_resets_on_stream_event():
    events = []

    class StreamingProc:
        async def ainvoke(self, input, config):
            for _ in range(3):
                await asyncio.sleep(0.08)
                config[CONF][CONFIG_KEY_STREAM](((), "custom", "tick"))
            return "ok"

    task = _make_task(StreamingProc(), timeout=_idle_timeout(0.2), name="streaming")
    task.config[CONF][CONFIG_KEY_STREAM] = StreamProtocol(events.append, {"custom"})
    assert await arun_with_retry(task, retry_policy=None) == "ok"
    assert len(events) == 3


@pytest.mark.anyio
async def test_arun_with_retry_idle_timeout_resets_on_runtime_stream_writer():
    events = []

    class WriterProc:
        async def ainvoke(self, input, config):
            runtime = config[CONF][CONFIG_KEY_RUNTIME]
            for _ in range(3):
                await asyncio.sleep(0.08)
                runtime.stream_writer("tick")
            return "ok"

    task = _make_task(WriterProc(), timeout=_idle_timeout(0.2), name="writer")
    runtime = task.config[CONF][CONFIG_KEY_RUNTIME]
    task.config[CONF][CONFIG_KEY_RUNTIME] = runtime.override(
        stream_writer=events.append
    )
    assert await arun_with_retry(task, retry_policy=None) == "ok"
    assert events == ["tick", "tick", "tick"]


@pytest.mark.anyio
async def test_astream_with_retry_idle_timeout_resets_on_yielded_chunks():
    class StreamingProc:
        async def astream(self, input, config):
            for i in range(3):
                await asyncio.sleep(0.08)
                yield i

    task = _make_task(StreamingProc(), timeout=_idle_timeout(0.2), name="astream")
    await arun_with_retry(task, retry_policy=None, stream=True)


class _HandlerEmittingProc:
    """Proc that fires `on_llm_new_token` on every handler attached to its config."""

    def __init__(self, iterations: int = 1, sleep_s: float = 0.0) -> None:
        self.iterations = iterations
        self.sleep_s = sleep_s

    async def ainvoke(self, input, config):
        run_id = uuid4()
        for _ in range(self.iterations):
            if self.sleep_s:
                await asyncio.sleep(self.sleep_s)
            for handler in config["callbacks"]:
                handler.on_llm_new_token("tok", run_id=run_id)
        return "ok"


@pytest.mark.anyio
async def test_arun_with_retry_idle_timeout_resets_on_runtime_heartbeat():
    class HeartbeatProc:
        async def ainvoke(self, input, config):
            runtime = config[CONF][CONFIG_KEY_RUNTIME]
            for _ in range(3):
                await asyncio.sleep(0.08)
                runtime.heartbeat()
            return "ok"

    task = _make_task(HeartbeatProc(), timeout=_idle_timeout(0.15), name="heartbeat")
    assert await arun_with_retry(task, retry_policy=None) == "ok"


@pytest.mark.anyio
async def test_arun_with_retry_heartbeat_refresh_mode_ignores_stream_events():
    events = []

    class StreamingProc:
        async def ainvoke(self, input, config):
            while True:
                await asyncio.sleep(0.01)
                config[CONF][CONFIG_KEY_STREAM](((), "custom", "tick"))

    task = _make_task(
        StreamingProc(),
        timeout=TimeoutPolicy(idle_timeout=0.05, refresh_on="heartbeat"),
        name="heartbeat-only",
    )
    task.config[CONF][CONFIG_KEY_STREAM] = StreamProtocol(events.append, {"custom"})
    with pytest.raises(NodeTimeoutError) as excinfo:
        await arun_with_retry(task, retry_policy=None)
    assert excinfo.value.kind == "idle"
    assert events


@pytest.mark.anyio
async def test_arun_with_retry_heartbeat_refresh_mode_accepts_heartbeat():
    class HeartbeatProc:
        async def ainvoke(self, input, config):
            runtime = config[CONF][CONFIG_KEY_RUNTIME]
            for _ in range(3):
                await asyncio.sleep(0.03)
                runtime.heartbeat()
            return "ok"

    task = _make_task(
        HeartbeatProc(),
        timeout=TimeoutPolicy(idle_timeout=0.08, refresh_on="heartbeat"),
        name="heartbeat-only",
    )
    assert await arun_with_retry(task, retry_policy=None) == "ok"


def test_runtime_heartbeat_outside_idle_attempt_is_no_op():
    DEFAULT_RUNTIME.heartbeat()


@pytest.mark.anyio
async def test_arun_with_retry_idle_timeout_resets_on_callback_event():
    task = _make_task(
        _HandlerEmittingProc(iterations=3, sleep_s=0.08),
        timeout=_idle_timeout(0.15),
        name="cb",
    )
    assert await arun_with_retry(task, retry_policy=None) == "ok"


@pytest.mark.anyio
async def test_arun_with_retry_idle_timeout_preserves_existing_callbacks():
    seen: list[str] = []

    class RecordingHandler(BaseCallbackHandler):
        run_inline = True

        def on_llm_new_token(self, token, *, run_id, **kwargs):
            seen.append(token)

    task = _make_task(_HandlerEmittingProc(), timeout=_idle_timeout(0.5), name="cb-pre")
    task.config["callbacks"] = [RecordingHandler()]
    assert await arun_with_retry(task, retry_policy=None) == "ok"
    assert seen == ["tok"]


@pytest.mark.anyio
async def test_arun_with_retry_timeout_discards_stale_executor_writes():
    release_first_attempt = threading.Event()

    class FlakyAsyncProc:
        def __init__(self) -> None:
            self.calls = 0

        async def ainvoke(self, input, config):
            self.calls += 1
            if self.calls == 1:

                def late_write() -> str:
                    release_first_attempt.wait(timeout=1.0)
                    config[CONF][CONFIG_KEY_SEND]([("value", "stale")])
                    return "late"

                return await asyncio.to_thread(late_write)
            release_first_attempt.set()
            config[CONF][CONFIG_KEY_SEND]([("value", "fresh")])
            return "ok"

    policy = RetryPolicy(
        max_attempts=2,
        initial_interval=0.0,
        jitter=False,
        retry_on=NodeTimeoutError,
    )
    task = _make_task(
        FlakyAsyncProc(), timeout=_idle_timeout(0.05), retry_policy=(policy,)
    )
    assert await arun_with_retry(task, retry_policy=None) == "ok"
    await asyncio.sleep(0.05)
    assert task.writes == deque([("value", "fresh")])


@pytest.mark.anyio
async def test_arun_with_retry_timeout_discards_pre_timeout_writes():
    class SlowAsyncWriterProc:
        async def ainvoke(self, input, config):
            config[CONF][CONFIG_KEY_SEND]([("value", "stale-before-idle-timeout")])
            await asyncio.sleep(0.2)
            return "late"

    task = _make_task(
        SlowAsyncWriterProc(), timeout=_idle_timeout(0.05), name="aslow-writer"
    )
    with pytest.raises(NodeTimeoutError):
        await arun_with_retry(task, retry_policy=None)
    assert task.writes == deque()


@pytest.mark.anyio
async def test_astream_with_retry_timeout_discards_pre_timeout_writes():
    class SlowStreamWriterProc:
        async def astream(self, input, config):
            config[CONF][CONFIG_KEY_SEND]([("value", "stale-before-idle-timeout")])
            await asyncio.sleep(0.2)
            if False:
                yield None

    task = _make_task(
        SlowStreamWriterProc(), timeout=_idle_timeout(0.05), name="astream-writer"
    )
    with pytest.raises(NodeTimeoutError):
        await arun_with_retry(task, retry_policy=None, stream=True)
    assert task.writes == deque()


@pytest.mark.anyio
async def test_arun_with_retry_timeout_cannot_be_swallowed():
    class StubbornProc:
        async def ainvoke(self, input, config):
            try:
                await asyncio.sleep(1.0)
            except asyncio.CancelledError:
                config[CONF][CONFIG_KEY_SEND]([("value", "stale")])
                await asyncio.sleep(0)
                return "late"
            return "ok"

    task = _make_task(StubbornProc(), timeout=_idle_timeout(0.05), name="stubborn")
    with pytest.raises(NodeTimeoutError) as excinfo:
        await arun_with_retry(task, retry_policy=None)
    assert excinfo.value.node == "stubborn"
    await asyncio.sleep(0.05)
    assert task.writes == deque()


@pytest.mark.anyio
async def test_astream_with_retry_timeout_cannot_be_swallowed():
    class StubbornStreamProc:
        async def astream(self, input, config):
            try:
                await asyncio.sleep(1.0)
            except asyncio.CancelledError:
                config[CONF][CONFIG_KEY_SEND]([("value", "stale")])
                await asyncio.sleep(0)
                if False:
                    yield None
                return
            yield "ok"

    task = _make_task(
        StubbornStreamProc(), timeout=_idle_timeout(0.05), name="stubborn-stream"
    )
    with pytest.raises(NodeTimeoutError) as excinfo:
        await arun_with_retry(task, retry_policy=None, stream=True)
    assert excinfo.value.node == "stubborn-stream"
    await asyncio.sleep(0.05)
    assert task.writes == deque()


class _TimeoutState(TypedDict):
    x: int


def test_timeout_validation_is_eager_across_apis():
    with pytest.raises(ValueError, match="greater than 0"):
        task(timeout=0)

    with pytest.raises(ValueError, match="greater than 0"):
        entrypoint(timeout=0)

    with pytest.raises(ValueError, match="greater than 0"):
        NodeBuilder().set_timeout(0)

    with pytest.raises(ValueError, match="greater than 0"):
        PregelNode(channels="x", triggers=["x"], timeout=0)

    with pytest.raises(ValueError, match="greater than 0"):
        Send("slow", {}, timeout=0)

    builder = StateGraph(_TimeoutState)
    with pytest.raises(ValueError, match="greater than 0"):
        builder.add_node("slow", lambda state: state, timeout=0)


def test_timeout_rejects_sync_functional_apis_at_declaration_time():
    with pytest.raises(ValueError, match="only supported for async nodes"):

        @task(timeout=TimeoutPolicy(idle_timeout=0.05))
        def sync_task(value: int) -> int:
            return value

    with pytest.raises(ValueError, match="only supported for async nodes"):

        @entrypoint(timeout=TimeoutPolicy(idle_timeout=0.05))
        def sync_entrypoint(value: int) -> int:
            return value


def test_state_graph_compile_rejects_sync_node_timeout():
    def slow(state: _TimeoutState) -> _TimeoutState:
        return {"x": state["x"] + 1}

    builder = StateGraph(_TimeoutState)
    builder.add_node("slow", slow, timeout=TimeoutPolicy(idle_timeout=0.05))
    builder.add_edge(START, "slow")
    builder.add_edge("slow", END)

    with pytest.raises(ValueError, match="only supported for async nodes"):
        builder.compile()


def test_pregel_validate_rejects_sync_writer_timeout():
    async def bound(value: int) -> int:
        return value + 1

    def sync_writer(value: int) -> int:
        return value

    with pytest.raises(ValueError, match="only supported for async nodes"):
        Pregel(
            nodes={
                "slow": PregelNode(
                    channels="input",
                    triggers=["input"],
                    bound=RunnableLambda(bound),
                    writers=[RunnableLambda(sync_writer)],
                    timeout=TimeoutPolicy(run_timeout=1),
                )
            },
            channels={
                "input": EphemeralValue(int),
                "output": LastValue(int),
            },
            input_channels="input",
            output_channels="output",
        )


def test_pregel_validate_rejects_wrapped_sync_runnable_lambda_timeout():
    def slow(value: int) -> int:
        return value + 1

    with pytest.raises(ValueError, match="only supported for async nodes"):
        Pregel(
            nodes={
                "slow": (
                    NodeBuilder()
                    .subscribe_only("input")
                    .do(RunnableLambda(slow).with_config(tags=["wrapped"]))
                    .set_timeout(TimeoutPolicy(idle_timeout=0.05))
                    .write_to("output")
                )
            },
            channels={
                "input": EphemeralValue(int),
                "output": LastValue(int),
            },
            input_channels="input",
            output_channels="output",
        )


def test_pregel_validate_accepts_wrapped_async_runnable_lambda_timeout():
    async def slow(value: int) -> int:
        return value + 1

    Pregel(
        nodes={
            "slow": (
                NodeBuilder()
                .subscribe_only("input")
                .do(RunnableLambda(slow).with_config(tags=["wrapped"]))
                .set_timeout(TimeoutPolicy(idle_timeout=0.05))
                .write_to("output")
            )
        },
        channels={
            "input": EphemeralValue(int),
            "output": LastValue(int),
        },
        input_channels="input",
        output_channels="output",
    )


def test_pregel_validate_rejects_parallel_sync_branch_timeout():
    def sync_branch(value: int) -> int:
        return value + 1

    async def async_branch(value: int) -> int:
        return value + 1

    with pytest.raises(ValueError, match="only supported for async nodes"):
        Pregel(
            nodes={
                "parallel": (
                    NodeBuilder()
                    .subscribe_only("input")
                    .do(
                        RunnableParallel(
                            sync=RunnableLambda(sync_branch),
                            async_=RunnableLambda(async_branch),
                        )
                    )
                    .set_timeout(TimeoutPolicy(idle_timeout=0.05))
                    .write_to("output")
                )
            },
            channels={
                "input": EphemeralValue(int),
                "output": LastValue(dict),
            },
            input_channels="input",
            output_channels="output",
        )


def test_pregel_validate_rejects_sync_node_timeout():
    def slow(value: int) -> int:
        return value + 1

    with pytest.raises(ValueError, match="only supported for async nodes"):
        Pregel(
            nodes={
                "slow": (
                    NodeBuilder()
                    .subscribe_only("input")
                    .do(slow)
                    .set_timeout(TimeoutPolicy(idle_timeout=0.05))
                    .write_to("output")
                )
            },
            channels={
                "input": EphemeralValue(int),
                "output": LastValue(int),
            },
            input_channels="input",
            output_channels="output",
        )


@pytest.mark.anyio
async def test_pregel_validate_accepts_async_runnable_lambda_timeout():
    async def slow(value: int) -> int:
        await asyncio.sleep(0.2)
        return value + 1

    graph = Pregel(
        nodes={
            "slow": (
                NodeBuilder()
                .subscribe_only("input")
                .do(RunnableLambda(slow))
                .set_timeout(TimeoutPolicy(idle_timeout=0.05))
                .write_to("output")
            )
        },
        channels={
            "input": EphemeralValue(int),
            "output": LastValue(int),
        },
        input_channels="input",
        output_channels="output",
    )
    with pytest.raises(NodeTimeoutError):
        await graph.ainvoke(1)


@pytest.mark.anyio
async def test_pregel_validate_accepts_runnable_callable_with_sync_and_async_timeout():
    def sync(value: int) -> int:
        return value + 1

    async def async_(value: int) -> int:
        await asyncio.sleep(0.2)
        return value + 1

    graph = Pregel(
        nodes={
            "slow": (
                NodeBuilder()
                .subscribe_only("input")
                .do(RunnableCallable(sync, async_))
                .set_timeout(TimeoutPolicy(idle_timeout=0.05))
                .write_to("output")
            )
        },
        channels={
            "input": EphemeralValue(int),
            "output": LastValue(int),
        },
        input_channels="input",
        output_channels="output",
    )
    with pytest.raises(NodeTimeoutError):
        await graph.ainvoke(1)


@pytest.mark.anyio
async def test_state_graph_add_node_timeout_e2e():
    async def slow(state: _TimeoutState) -> _TimeoutState:
        await asyncio.sleep(1.0)
        return {"x": state["x"] + 1}

    builder = StateGraph(_TimeoutState)
    builder.add_node("slow", slow, timeout=TimeoutPolicy(idle_timeout=0.05))
    builder.add_edge(START, "slow")
    builder.add_edge("slow", END)
    graph = builder.compile()
    with pytest.raises(NodeTimeoutError):
        await graph.ainvoke({"x": 1})


@pytest.mark.anyio
async def test_send_timeout_overrides_target_node_timeout():
    async def slow(state: _TimeoutState) -> _TimeoutState:
        await asyncio.sleep(0.2)
        return {"x": state["x"] + 1}

    def route(state: _TimeoutState) -> list[Send]:
        return [Send("slow", state, timeout=TimeoutPolicy(idle_timeout=0.05))]

    builder = StateGraph(_TimeoutState)
    builder.add_node("slow", slow, timeout=TimeoutPolicy(idle_timeout=1.0))
    builder.add_conditional_edges(START, route)
    builder.add_edge("slow", END)
    graph = builder.compile()

    with pytest.raises(NodeTimeoutError) as excinfo:
        await graph.ainvoke({"x": 1})
    assert excinfo.value.node == "slow"
    assert excinfo.value.kind == "idle"
    assert excinfo.value.idle_timeout == 0.05


@pytest.mark.anyio
async def test_state_graph_add_node_timeout_composes_with_retry():
    """add_node(..., timeout=TimeoutPolicy(...)) retries then succeeds."""

    attempts: list[int] = []

    async def flaky(state: _TimeoutState) -> _TimeoutState:
        attempts.append(len(attempts))
        if len(attempts) < 2:
            await asyncio.sleep(1.0)
        return {"x": state["x"] + 1}

    builder = StateGraph(_TimeoutState)
    builder.add_node(
        "flaky",
        flaky,
        timeout=TimeoutPolicy(idle_timeout=0.3),
        retry_policy=RetryPolicy(
            max_attempts=3,
            initial_interval=0.0,
            jitter=False,
            retry_on=NodeTimeoutError,
        ),
    )
    builder.add_edge(START, "flaky")
    builder.add_edge("flaky", END)
    graph = builder.compile()
    result = await graph.ainvoke({"x": 0})
    assert result == {"x": 1}
    assert len(attempts) == 2


@NEEDS_CONTEXTVARS
@pytest.mark.anyio
async def test_task_decorator_timeout_e2e():
    @task(timeout=TimeoutPolicy(idle_timeout=0.05))
    async def slow_task(x: int) -> int:
        await asyncio.sleep(0.2)
        return x + 1

    @entrypoint()
    async def workflow(x: int) -> int:
        return await slow_task(x)

    with pytest.raises(NodeTimeoutError):
        await workflow.ainvoke(1)


@NEEDS_CONTEXTVARS
@pytest.mark.anyio
async def test_task_decorator_preserves_user_idle_timeout_kwarg():
    @task(timeout=TimeoutPolicy(idle_timeout=1.0))
    async def echo_idle_timeout(*, idle_timeout: int) -> int:
        await asyncio.sleep(0)
        return idle_timeout

    @entrypoint()
    async def workflow(x: int) -> int:
        return await echo_idle_timeout(idle_timeout=x)

    assert await workflow.ainvoke(5) == 5


@NEEDS_CONTEXTVARS
@pytest.mark.anyio
async def test_task_decorator_preserves_user_timeout_kwarg():
    @task(timeout=1.0)
    async def echo_timeout(*, timeout: int) -> int:
        await asyncio.sleep(0)
        return timeout

    @entrypoint()
    async def workflow(x: int) -> int:
        return await echo_timeout(timeout=x)

    assert await workflow.ainvoke(5) == 5


@pytest.mark.anyio
async def test_entrypoint_timeout_e2e():
    @entrypoint(timeout=TimeoutPolicy(idle_timeout=0.05))
    async def slow_workflow(x: int) -> int:
        await asyncio.sleep(0.2)
        return x

    with pytest.raises(NodeTimeoutError):
        await slow_workflow.ainvoke(1)


class _MessageStreamState(TypedDict):
    messages: Annotated[list[BaseMessage], add_messages]


class _SlowStreamingChatModel(GenericFakeChatModel):
    async def _astream(
        self,
        messages: list[BaseMessage],
        stop: list[str] | None = None,
        run_manager: AsyncCallbackManagerForLLMRun | None = None,
        **kwargs: Any,
    ) -> AsyncIterator[ChatGenerationChunk]:
        for i in range(3):
            await asyncio.sleep(0.08)
            chunk = ChatGenerationChunk(
                message=AIMessageChunk(
                    content=str(i),
                    chunk_position="last" if i == 2 else None,
                )
            )
            if run_manager:
                await run_manager.on_llm_new_token(str(i), chunk=chunk)
            yield chunk

    def _generate(
        self,
        messages: list[BaseMessage],
        stop: list[str] | None = None,
        run_manager: Any | None = None,
        **kwargs: Any,
    ) -> ChatResult:
        return ChatResult(generations=[ChatGeneration(message=AIMessage(content=""))])


@pytest.mark.anyio
async def test_idle_timeout_resets_on_message_stream_callbacks():
    model = _SlowStreamingChatModel(messages=iter([]))

    async def call_model(state: _MessageStreamState) -> _MessageStreamState:
        response = await model.ainvoke(state["messages"])
        return {"messages": [response]}

    builder = StateGraph(_MessageStreamState)
    builder.add_node(
        "call_model",
        call_model,
        timeout=TimeoutPolicy(idle_timeout=0.15),
    )
    builder.add_edge(START, "call_model")
    builder.add_edge("call_model", END)
    graph = builder.compile()

    chunks: list[str] = []
    async for chunk, _metadata in graph.astream(
        {"messages": [HumanMessage(content="hi")]},
        stream_mode="messages",
    ):
        chunks.append(chunk.content)
    assert chunks == ["0", "1", "2"]


@pytest.mark.anyio
async def test_node_builder_timeout_e2e():
    async def slow(value: int) -> int:
        await asyncio.sleep(0.2)
        return value + 1

    graph = Pregel(
        nodes={
            "slow": (
                NodeBuilder()
                .subscribe_only("input")
                .do(slow)
                .set_timeout(TimeoutPolicy(idle_timeout=0.05))
                .write_to("output")
            )
        },
        channels={
            "input": EphemeralValue(int),
            "output": LastValue(int),
        },
        input_channels="input",
        output_channels="output",
    )
    with pytest.raises(NodeTimeoutError):
        await graph.ainvoke(1)


@pytest.mark.anyio
async def test_arun_with_retry_timeout_observer_tracks_attempts():
    events: list = []

    class FlakyProc:
        async def ainvoke(self, input, config):
            runtime = config[CONF][CONFIG_KEY_RUNTIME]
            if runtime.execution_info.node_attempt == 1:
                await asyncio.sleep(0.2)
            return "ok"

    policy = RetryPolicy(
        max_attempts=2,
        initial_interval=0.0,
        jitter=False,
        retry_on=NodeTimeoutError,
    )
    task = _make_task(
        FlakyProc(),
        timeout=_idle_timeout(0.05),
        retry_policy=(policy,),
        name="flaky",
    )
    task.config[CONF][CONFIG_KEY_TIMED_ATTEMPT_OBSERVER] = events.append
    assert await arun_with_retry(task, retry_policy=None) == "ok"

    starts = [event for event in events if event.event == "start"]
    finishes = [event for event in events if event.event == "finish"]
    assert [event.context.attempt for event in starts] == [1, 2]
    assert [event.context.attempt for event in finishes] == [1, 2]
    assert [event.status for event in finishes] == ["error", "success"]
    assert starts[0].context.idle_timeout_secs == 0.05
    assert starts[0].context.task_name == "flaky"
    assert isinstance(starts[0].context.started_at, datetime)
    assert isinstance(finishes[0].finished_at, datetime)


@pytest.mark.anyio
async def test_arun_with_retry_timeout_observer_emits_progress_on_heartbeat():
    events: list = []

    # `_TimedAttemptScope.__init__` sets `_last_progress` to `time.monotonic()`,
    # but the watchdog itself doesn't start running until after `wrap_config`
    # and task scheduling — under CI load that gap can be large enough to eat
    # the entire idle window before the task body's first await even runs. We
    # defend against that by:
    #   1. Using a generous idle_timeout so scheduling slack stays well within it.
    #   2. Calling `runtime.heartbeat()` BEFORE the first sleep, which resets
    #      `_last_progress` to "now" the moment the task body actually starts.
    idle_timeout_s = 1.0

    class HeartbeatProc:
        async def ainvoke(self, input, config):
            runtime = config[CONF][CONFIG_KEY_RUNTIME]
            runtime.heartbeat()  # reset the idle clock at task-body entry
            for _ in range(8):
                await asyncio.sleep(0.05)
                runtime.heartbeat()
            return "ok"

    task = _make_task(
        HeartbeatProc(), timeout=_idle_timeout(idle_timeout_s), name="heartbeat"
    )
    task.config[CONF][CONFIG_KEY_TIMED_ATTEMPT_OBSERVER] = events.append
    assert await arun_with_retry(task, retry_policy=None) == "ok"

    by_event = [ev.event for ev in events]
    assert by_event[0] == "start"
    assert by_event[-1] == "finish"
    progress = [ev for ev in events if ev.event == "progress"]
    assert progress, "expected at least one progress event from heartbeat"
    # Rate limit is `idle_timeout / 4` = 0.25s; with the task running for
    # ~400ms we expect 1–2 progress events (well below the 9 heartbeats).
    assert len(progress) <= len(by_event)
    for ev in progress:
        assert ev.context.task_name == "heartbeat"
        assert ev.context.attempt == 1
        assert ev.context.idle_timeout_secs == idle_timeout_s
        assert isinstance(ev.progress_at, datetime)


@pytest.mark.anyio
async def test_arun_with_retry_timeout_observer_treats_parent_command_as_non_error():
    events: list = []

    class ParentProc:
        async def ainvoke(self, input, config):
            raise ParentCommand(Command(graph=Command.PARENT))

    task = _make_task(ParentProc(), timeout=_idle_timeout(0.05), name="parent")
    task.config[CONF][CONFIG_KEY_TIMED_ATTEMPT_OBSERVER] = events.append
    with pytest.raises(ParentCommand):
        await arun_with_retry(task, retry_policy=None)

    finish = next(event for event in events if event.event == "finish")
    assert finish.status == "success"
    assert finish.error_type is None
    assert finish.error_message is None


@pytest.mark.anyio
async def test_arun_with_retry_timeout_observer_finishes_when_parent_writer_errors():
    events: list = []

    class ParentProc:
        async def ainvoke(self, input, config):
            raise ParentCommand(Command(graph="parent", update={"value": "updated"}))

    class FailingWriter:
        def invoke(self, input, config):
            raise ValueError("writer failed")

    task = _make_task(
        ParentProc(),
        timeout=_idle_timeout(0.05),
        name="parent",
        writers=(FailingWriter(),),
    )
    task.config[CONF][CONFIG_KEY_TIMED_ATTEMPT_OBSERVER] = events.append
    with pytest.raises(ValueError, match="writer failed"):
        await arun_with_retry(task, retry_policy=None)

    finish = next(event for event in events if event.event == "finish")
    assert finish.status == "error"
    assert finish.error_type == "ValueError"
    assert finish.error_message == "writer failed"


@pytest.mark.anyio
async def test_arun_with_retry_timeout_observer_treats_bubble_up_as_non_error():
    events: list = []

    class BubbleProc:
        async def ainvoke(self, input, config):
            raise GraphInterrupt(())

    task = _make_task(BubbleProc(), timeout=_idle_timeout(0.05), name="bubble")
    task.config[CONF][CONFIG_KEY_TIMED_ATTEMPT_OBSERVER] = events.append
    with pytest.raises(GraphInterrupt):
        await arun_with_retry(task, retry_policy=None)

    finish = next(event for event in events if event.event == "finish")
    assert finish.status == "success"
    assert finish.error_type is None
    assert finish.error_message is None


# ---------------------------------------------------------------------------
# Watcher invariant: any timeout that retry/error_handler can recover from
# MUST emit `finish=error` BEFORE the in-process recovery work happens. The
# external watchdog (langgraph-api) relies on this so it only kills a worker
# when no `finish` arrives within the deadline. The tests below pin down the
# three recovery paths so a refactor that moves `_finish_timed_attempt` past
# an `await` (or past the final `raise`) trips CI.
# ---------------------------------------------------------------------------


@pytest.mark.anyio
async def test_arun_with_retry_observer_emits_finish_before_retry_backoff():
    """`finish=error` of attempt N must arrive before the retry backoff sleep."""
    timeline: list[tuple[float, Any]] = []

    class TimingOutOnceProc:
        def __init__(self) -> None:
            self.calls = 0

        async def ainvoke(self, input, config):
            self.calls += 1
            if self.calls == 1:
                await asyncio.sleep(1.0)
            return "ok"

    backoff = 0.25
    policy = RetryPolicy(
        max_attempts=2,
        initial_interval=backoff,
        backoff_factor=1.0,
        max_interval=backoff,
        jitter=False,
        retry_on=NodeTimeoutError,
    )
    task = _make_task(
        TimingOutOnceProc(),
        timeout=_idle_timeout(0.05),
        retry_policy=(policy,),
        name="backoff_watcher",
    )
    task.config[CONF][CONFIG_KEY_TIMED_ATTEMPT_OBSERVER] = lambda ev: timeline.append(
        (time.monotonic(), ev)
    )

    assert await arun_with_retry(task, retry_policy=None) == "ok"

    starts = [(t, ev) for t, ev in timeline if ev.event == "start"]
    finishes = [(t, ev) for t, ev in timeline if ev.event == "finish"]
    assert [ev.context.attempt for _, ev in starts] == [1, 2]
    assert [ev.status for _, ev in finishes] == ["error", "success"]

    first_finish_t = finishes[0][0]
    second_start_t = starts[1][0]

    # The watcher relies on this gap: `finish=error` for attempt 1 must arrive
    # before `arun_with_retry` enters `await asyncio.sleep(backoff)`. We give a
    # generous slack to keep this stable on slow CI; the structural invariant
    # is "finish lands first", not "the gap equals exactly backoff".
    assert second_start_t - first_finish_t >= backoff * 0.5, (
        f"finish=error appears to be emitted after retry backoff sleep; "
        f"gap was {second_start_t - first_finish_t:.3f}s, expected >= {backoff * 0.5:.3f}s"
    )


@pytest.mark.anyio
async def test_state_graph_observer_emits_finish_before_error_handler_start():
    """Original task's `finish=error` must arrive before the error_handler task's `start`."""

    class State(TypedDict):
        foo: str

    async def slow_node(state: State) -> State:
        await asyncio.sleep(1.0)
        return {"foo": "should-not-happen"}

    async def handler_node(state: State, error: NodeError) -> State:
        return {"foo": "handled"}

    events: list = []
    graph = (
        StateGraph(State)
        .add_node(
            "slow",
            slow_node,
            timeout=TimeoutPolicy(idle_timeout=0.05),
            error_handler=handler_node,
        )
        .add_edge(START, "slow")
        .compile()
    )

    result = await graph.ainvoke(
        {"foo": ""},
        config={
            "configurable": {CONFIG_KEY_TIMED_ATTEMPT_OBSERVER: events.append},
        },
    )
    assert result["foo"] == "handled"

    # Filter to events from the failing node only — the handler node has no
    # timeout configured here, so it doesn't appear in the observer stream.
    slow_events = [ev for ev in events if ev.context.task_name == "slow"]
    starts = [ev for ev in slow_events if ev.event == "start"]
    finishes = [ev for ev in slow_events if ev.event == "finish"]
    assert len(starts) == 1
    assert len(finishes) == 1
    assert finishes[0].status == "error"
    assert finishes[0].error_type == "NodeTimeoutError"
    # The slow task's finish-error event must precede every event for any
    # follow-up task in the same observer stream.
    slow_finish_index = events.index(finishes[0])
    for ev in events[slow_finish_index + 1 :]:
        assert ev.context.task_name == "slow" or ev.event == "start", (
            f"unexpected event {ev.event} for {ev.context.task_name} "
            f"after slow's finish=error"
        )


@pytest.mark.anyio
async def test_arun_with_retry_observer_emits_finish_before_final_raise_on_exhaustion():
    """When retry exhausts and the timeout propagates, the final `finish=error` must
    be emitted before `arun_with_retry` re-raises."""
    events: list = []

    class AlwaysTimingOutProc:
        async def ainvoke(self, input, config):
            await asyncio.sleep(1.0)
            return "never"

    policy = RetryPolicy(
        max_attempts=2,
        initial_interval=0.0,
        jitter=False,
        retry_on=NodeTimeoutError,
    )
    task = _make_task(
        AlwaysTimingOutProc(),
        timeout=_idle_timeout(0.05),
        retry_policy=(policy,),
        name="never_succeeds",
    )
    task.config[CONF][CONFIG_KEY_TIMED_ATTEMPT_OBSERVER] = events.append

    with pytest.raises(NodeTimeoutError):
        await arun_with_retry(task, retry_policy=None)

    starts = [ev for ev in events if ev.event == "start"]
    finishes = [ev for ev in events if ev.event == "finish"]
    assert [ev.context.attempt for ev in starts] == [1, 2]
    assert [ev.context.attempt for ev in finishes] == [1, 2]
    assert [ev.status for ev in finishes] == ["error", "error"]
    assert all(ev.error_type == "NodeTimeoutError" for ev in finishes)
    # Both finish events were observed BEFORE arun_with_retry raised, otherwise
    # the `with pytest.raises` block would have exited before `events` got
    # populated with the second finish.


@pytest.mark.anyio
async def test_sync_sleep_in_async_node_bypasses_timeout_and_emits_finish_success():
    """Sync `time.sleep` inside an async node blocks the event loop so the
    in-process watchdog cannot fire. We document the resulting behavior here:

    1. `NodeTimeoutError` is NOT raised, even though the sync sleep exceeds
       `idle_timeout`.
    2. The node's normal return value flows through.
    3. `finish=success` is emitted to the observer.

    This is the canonical case where the in-process timeout is defeated and
    the only safety net is the external watcher (langgraph-api), which
    SIGKILLs the worker when no `finish` arrives within its deadline. The
    catch is that with a *short* sync sleep the event loop unblocks before
    the watcher's deadline expires, so the watcher legitimately does not
    kill — meaning the configured `idle_timeout` is silently honored at the
    process level only when the block is long enough to outlast the
    watcher's grace.

    This is the documented "Cooperative cancellation" caveat on
    `TimeoutPolicy`. The test pins the behavior so any future change that
    starts raising `NodeTimeoutError` for sync-blocked async nodes (or stops
    emitting `finish=success`) is caught.
    """
    events: list = []

    class SyncSleepingProc:
        async def ainvoke(self, input, config):
            time.sleep(0.1)
            return "completed_despite_timeout"

    task = _make_task(
        SyncSleepingProc(),
        timeout=_idle_timeout(0.05),
        name="sync_sleeper",
    )
    task.config[CONF][CONFIG_KEY_TIMED_ATTEMPT_OBSERVER] = events.append

    result = await arun_with_retry(task, retry_policy=None)

    assert result == "completed_despite_timeout"
    starts = [ev for ev in events if ev.event == "start"]
    finishes = [ev for ev in events if ev.event == "finish"]
    assert len(starts) == 1
    assert len(finishes) == 1
    assert finishes[0].status == "success"
    assert finishes[0].error_type is None


def test_graph_error_handler_runs_after_retry_exhaustion():
    class State(TypedDict):
        foo: str

    attempts = 0
    captured: dict[str, object] = {}

    def always_failing_node(state: State) -> State:
        nonlocal attempts
        attempts += 1
        raise ValueError("Always fails")

    def err_handler_node(state: State, error: NodeError) -> Command:
        captured["from_node_name"] = error.node
        captured["from_node_error"] = error.error
        return Command(update={"foo": "handled"}, goto="after_handler")

    def after_handler(state: State) -> State:
        return {"foo": f"{state['foo']}_after"}

    retry_policy = RetryPolicy(
        max_attempts=2,
        initial_interval=0.01,
        jitter=False,
        retry_on=ValueError,
    )

    graph = (
        StateGraph(State)
        .add_node(
            "always_failing",
            always_failing_node,
            retry_policy=retry_policy,
            error_handler=err_handler_node,
        )
        .add_node("after_handler", after_handler)
        .add_edge(START, "always_failing")
        .compile()
    )

    with patch("time.sleep"):
        result = graph.invoke({"foo": ""})

    assert attempts == 2
    assert result["foo"] == "handled_after"
    assert captured["from_node_name"] == "always_failing"
    assert isinstance(captured["from_node_error"], BaseException)


def test_graph_error_handler_can_route_with_command():
    class State(TypedDict):
        foo: str

    attempts = 0

    def always_failing_node(state: State) -> State:
        nonlocal attempts
        attempts += 1
        raise ValueError("Always fails")

    def err_handler_node(state: State) -> Command:
        return Command(update={"foo": "handled"}, goto="next_node")

    def next_node(state: State) -> State:
        return {"foo": f"{state['foo']}_next"}

    retry_policy = RetryPolicy(
        max_attempts=1,
        initial_interval=0.01,
        jitter=False,
        retry_on=ValueError,
    )

    graph = (
        StateGraph(State)
        .add_node(
            "always_failing",
            always_failing_node,
            retry_policy=retry_policy,
            error_handler=err_handler_node,
        )
        .add_node("next_node", next_node)
        .add_edge(START, "always_failing")
        .compile()
    )

    result = graph.invoke({"foo": ""})
    assert attempts == 1
    assert result["foo"] == "handled_next"


def test_graph_error_handler_failure_fails_run():
    class State(TypedDict):
        foo: str

    def always_failing_node(state: State) -> State:
        raise ValueError("Always fails")

    def err_handler_node(state: State) -> State:
        raise RuntimeError("handler failed")

    graph = (
        StateGraph(State)
        .add_node("always_failing", always_failing_node, error_handler=err_handler_node)
        .add_edge(START, "always_failing")
        .compile()
    )

    with pytest.raises(RuntimeError, match="handler failed"):
        graph.invoke({"foo": ""})


def test_graph_error_handler_handles_subgraph_internal_failure():
    class SubState(TypedDict):
        foo: str

    class ParentState(TypedDict):
        foo: str

    parent_handler_called = False
    captured: dict[str, object] = {}

    def sub_fail_node(state: SubState) -> SubState:
        raise ValueError("subgraph boom")

    def parent_handler(state: ParentState, error: NodeError) -> ParentState:
        nonlocal parent_handler_called
        parent_handler_called = True
        captured["from_node_name"] = error.node
        captured["from_node_error"] = error.error
        return {"foo": "handled_by_parent"}

    subgraph = (
        StateGraph(SubState)
        .add_node("sub_fail_node", sub_fail_node)
        .add_edge(START, "sub_fail_node")
        .compile()
    )

    parent_graph = (
        StateGraph(ParentState)
        .add_node("subgraph_node", subgraph, error_handler=parent_handler)
        .add_edge(START, "subgraph_node")
        .compile()
    )

    result = parent_graph.invoke({"foo": ""})
    assert result["foo"] == "handled_by_parent"
    assert parent_handler_called is True
    assert captured["from_node_name"] == "subgraph_node"
    assert isinstance(captured["from_node_error"], BaseException)


def test_graph_error_handler_error_context_survives_checkpoint_resume():
    class State(TypedDict):
        foo: str

    captured: dict[str, object] = {}

    def always_failing_node(state: State) -> State:
        raise RuntimeError("failed before handler")

    def err_handler_node(state: State, error: NodeError) -> State:
        captured["from_node_name"] = error.node
        captured["from_node_error"] = error.error
        return {"foo": "handled_after_resume"}

    checkpointer = InMemorySaver()
    config = {"configurable": {"thread_id": "graph-error-resume"}}
    graph = (
        StateGraph(State)
        .add_node("always_failing", always_failing_node, error_handler=err_handler_node)
        .add_edge(START, "always_failing")
        .compile(
            checkpointer=checkpointer,
            interrupt_before=["__error_handler__always_failing"],
        )
    )

    # First run pauses before handler, after failure context is checkpointed.
    graph.invoke({"foo": ""}, config)
    # Resume should execute handler and recover serialized error context.
    result = graph.invoke(None, config)

    assert result["foo"] == "handled_after_resume"
    assert captured["from_node_name"] == "always_failing"
    assert isinstance(captured["from_node_error"], BaseException)


def test_graph_error_handler_does_not_swallow_interrupt_concurrent():
    """When a graph error handler is configured and a node calls interrupt()
    concurrently with other nodes, the interrupt must still be raised — not
    silently swallowed."""
    from langgraph.types import interrupt

    class State(TypedDict):
        foo: str

    def node_a(state: State) -> State:
        # This node uses interrupt() which raises GraphInterrupt
        val = interrupt("need human input")
        return {"foo": f"a_{val}"}

    def node_b(state: State) -> State:
        return {}

    def err_handler(state: State) -> State:
        return {"foo": "handled"}

    checkpointer = InMemorySaver()
    graph = (
        StateGraph(State)
        .add_node("node_a", node_a, error_handler=err_handler)
        .add_node("node_b", node_b)
        # Fan-out: both node_a and node_b run concurrently
        .add_edge(START, "node_a")
        .add_edge(START, "node_b")
        .compile(checkpointer=checkpointer)
    )

    config = {"configurable": {"thread_id": "test-interrupt-concurrent"}}

    # First invoke should pause at the interrupt, not silently complete
    graph.invoke({"foo": ""}, config)

    # The graph should have an interrupt pending
    state = graph.get_state(config)
    assert len(state.tasks) > 0

    # There should be a pending interrupt from node_a
    interrupts = [t for t in state.tasks if hasattr(t, "interrupts") and t.interrupts]
    assert len(interrupts) > 0, (
        "GraphInterrupt was swallowed — interrupt() in node_a "
        "should have paused execution"
    )


def test_node_error_handlers_route_to_matching_handler():
    class State(TypedDict):
        route: str
        foo: Annotated[list[str], operator.add]

    def route_node(state: State) -> State:
        return {"foo": []}

    def choose_node(state: State) -> str:
        return state["route"]

    def fail_a(state: State) -> State:
        raise ValueError("a failed")

    def fail_b(state: State) -> State:
        raise RuntimeError("b failed")

    def handler_a(state: State, error: NodeError) -> State:
        assert error.node == "fail_a"
        return {"foo": ["handled_a"]}

    def handler_b(state: State, error: NodeError) -> State:
        assert error.node == "fail_b"
        return {"foo": ["handled_b"]}

    graph = (
        StateGraph(State)
        .add_node("route_node", route_node)
        .add_node("fail_a", fail_a, error_handler=handler_a)
        .add_node("fail_b", fail_b, error_handler=handler_b)
        .add_edge(START, "route_node")
        .add_conditional_edges("route_node", choose_node, path_map=["fail_a", "fail_b"])
        .compile()
    )

    result_a = graph.invoke({"route": "fail_a", "foo": []})
    result_b = graph.invoke({"route": "fail_b", "foo": []})
    assert result_a["foo"] == ["handled_a"]
    assert result_b["foo"] == ["handled_b"]


def test_node_without_error_handler_still_fails_run():
    class State(TypedDict):
        foo: str

    def fail_without_handler(state: State) -> State:
        raise ValueError("no handler")

    graph = (
        StateGraph(State)
        .add_node("fail_without_handler", fail_without_handler)
        .add_edge(START, "fail_without_handler")
        .compile()
    )

    with pytest.raises(ValueError, match="no handler"):
        graph.invoke({"foo": ""})


# ---------------------------------------------------------------------------
# set_node_defaults()
# ---------------------------------------------------------------------------


def test_set_node_defaults_error_handler_catches_all_nodes():
    class State(TypedDict):
        route: str
        foo: Annotated[list[str], operator.add]

    def route_node(state: State) -> Command:
        return Command(goto=state["route"])

    def fail_a(state: State) -> State:
        raise RuntimeError("a failed")

    def fail_b(state: State) -> State:
        raise RuntimeError("b failed")

    captured: dict[str, list[str]] = {"nodes": []}

    def default_handler(state: State, error: NodeError) -> State:
        captured["nodes"].append(error.node)
        return {"foo": [f"handled_{error.node}"]}

    graph = (
        StateGraph(State)
        .set_node_defaults(error_handler=default_handler)
        .add_node("route_node", route_node)
        .add_node("fail_a", fail_a)
        .add_node("fail_b", fail_b)
        .add_edge(START, "route_node")
        .add_conditional_edges(
            "route_node", lambda s: s["route"], path_map=["fail_a", "fail_b"]
        )
        .compile()
    )

    result_a = graph.invoke({"route": "fail_a", "foo": []})
    result_b = graph.invoke({"route": "fail_b", "foo": []})
    assert result_a["foo"] == ["handled_fail_a"]
    assert result_b["foo"] == ["handled_fail_b"]
    assert "fail_a" in captured["nodes"]
    assert "fail_b" in captured["nodes"]


def test_set_node_defaults_error_handler_overridden_by_node_handler():
    class State(TypedDict):
        route: str
        foo: Annotated[list[str], operator.add]

    def route_node(state: State) -> Command:
        return Command(goto=state["route"])

    def fail_a(state: State) -> State:
        raise RuntimeError("a failed")

    def fail_b(state: State) -> State:
        raise RuntimeError("b failed")

    captured: dict[str, list[str]] = {"handler": []}

    def node_handler(state: State, error: NodeError) -> State:
        captured["handler"].append(f"node:{error.node}")
        return {"foo": [f"node_handled_{error.node}"]}

    def default_handler(state: State, error: NodeError) -> State:
        captured["handler"].append(f"default:{error.node}")
        return {"foo": [f"default_handled_{error.node}"]}

    graph = (
        StateGraph(State)
        .set_node_defaults(error_handler=default_handler)
        .add_node("route_node", route_node)
        .add_node("fail_a", fail_a, error_handler=node_handler)
        .add_node("fail_b", fail_b)
        .add_edge(START, "route_node")
        .add_conditional_edges(
            "route_node", lambda s: s["route"], path_map=["fail_a", "fail_b"]
        )
        .compile()
    )

    result_a = graph.invoke({"route": "fail_a", "foo": []})
    assert result_a["foo"] == ["node_handled_fail_a"]
    assert "node:fail_a" in captured["handler"]
    assert "default:fail_a" not in captured["handler"]

    result_b = graph.invoke({"route": "fail_b", "foo": []})
    assert result_b["foo"] == ["default_handled_fail_b"]
    assert "default:fail_b" in captured["handler"]


def test_set_node_defaults_error_handler_skips_per_node_handler_nodes():
    """If a per-node error handler itself raises, the default handler must NOT
    catch it -- the run should fail."""

    class State(TypedDict):
        foo: str

    def always_failing(state: State) -> State:
        raise RuntimeError("node boom")

    def broken_handler(state: State, error: NodeError) -> State:
        raise RuntimeError("handler boom")

    def default_handler(state: State, error: NodeError) -> State:
        return {"foo": "default recovered"}

    graph = (
        StateGraph(State)
        .set_node_defaults(error_handler=default_handler)
        .add_node("always_failing", always_failing, error_handler=broken_handler)
        .add_edge(START, "always_failing")
        .compile()
    )

    with pytest.raises(RuntimeError, match="handler boom"):
        graph.invoke({"foo": ""})


def test_set_node_defaults_error_handler_failure_fails_run():
    """When the default handler itself raises, the run fails (no infinite
    recursion, no double-routing)."""

    class State(TypedDict):
        foo: str

    def always_failing(state: State) -> State:
        raise RuntimeError("node boom")

    def broken_default_handler(state: State, error: NodeError) -> State:
        raise RuntimeError("default handler boom")

    graph = (
        StateGraph(State)
        .set_node_defaults(error_handler=broken_default_handler)
        .add_node("always_failing", always_failing)
        .add_edge(START, "always_failing")
        .compile()
    )

    with pytest.raises(RuntimeError, match="default handler boom"):
        graph.invoke({"foo": ""})


def test_set_node_defaults_error_handler_receives_runnable_config():
    class State(TypedDict):
        foo: str

    def always_failing(state: State) -> State:
        raise RuntimeError("boom")

    captured: dict[str, Any] = {}

    def default_handler(
        state: State, error: NodeError, config: RunnableConfig
    ) -> State:
        captured["thread_id"] = config["configurable"].get("thread_id")
        return {"foo": "handled"}

    checkpointer = MemorySaver()
    graph = (
        StateGraph(State)
        .set_node_defaults(error_handler=default_handler)
        .add_node("always_failing", always_failing)
        .add_edge(START, "always_failing")
        .compile(checkpointer=checkpointer)
    )

    thread_id = str(uuid4())
    result = graph.invoke(
        {"foo": ""}, config={"configurable": {"thread_id": thread_id}}
    )
    assert result["foo"] == "handled"
    assert captured["thread_id"] == thread_id


def test_set_node_defaults_error_handler_collides_with_user_node():
    class State(TypedDict):
        foo: str

    def default_handler(state: State, error: NodeError) -> State:
        return {"foo": "handled"}

    builder = (
        StateGraph(State)
        .set_node_defaults(error_handler=default_handler)
        .add_node("__default_error_handler__", lambda s: s)
        .add_edge(START, "__default_error_handler__")
    )

    with pytest.raises(ValueError, match="__default_error_handler__"):
        builder.compile()


def test_set_node_defaults_retry_policy():
    class State(TypedDict):
        foo: str

    attempts = 0

    def flaky_node(state: State) -> State:
        nonlocal attempts
        attempts += 1
        if attempts < 3:
            raise ValueError("not yet")
        return {"foo": "ok"}

    graph = (
        StateGraph(State)
        .set_node_defaults(
            retry_policy=RetryPolicy(
                max_attempts=3, initial_interval=0.01, jitter=False, retry_on=ValueError
            )
        )
        .add_node("flaky", flaky_node)
        .add_edge(START, "flaky")
        .compile()
    )

    with patch("time.sleep"):
        result = graph.invoke({"foo": ""})

    assert result["foo"] == "ok"
    assert attempts == 3


def test_set_node_defaults_retry_policy_per_node_wins():
    class State(TypedDict):
        foo: str

    attempts = 0

    def flaky_node(state: State) -> State:
        nonlocal attempts
        attempts += 1
        if attempts < 2:
            raise ValueError("not yet")
        return {"foo": "ok"}

    graph = (
        StateGraph(State)
        .set_node_defaults(
            retry_policy=RetryPolicy(
                max_attempts=1, initial_interval=0.01, jitter=False, retry_on=ValueError
            )
        )
        .add_node(
            "flaky",
            flaky_node,
            retry_policy=RetryPolicy(
                max_attempts=3,
                initial_interval=0.01,
                jitter=False,
                retry_on=ValueError,
            ),
        )
        .add_edge(START, "flaky")
        .compile()
    )

    with patch("time.sleep"):
        result = graph.invoke({"foo": ""})

    assert result["foo"] == "ok"
    assert attempts == 2


@pytest.mark.anyio
async def test_set_node_defaults_timeout():
    class State(TypedDict):
        foo: str

    async def slow_node(state: State) -> State:
        await asyncio.sleep(10)
        return {"foo": "should-not-happen"}

    graph = (
        StateGraph(State)
        .set_node_defaults(timeout=TimeoutPolicy(run_timeout=0.05))
        .add_node("slow", slow_node)
        .add_edge(START, "slow")
        .compile()
    )

    from langgraph.errors import NodeTimeoutError

    with pytest.raises(NodeTimeoutError):
        await graph.ainvoke({"foo": ""})


@pytest.mark.anyio
async def test_set_node_defaults_timeout_per_node_wins():
    """Per-node timeout overrides the default; a generous per-node timeout
    allows a node to complete even when the builder default is very short."""

    class State(TypedDict):
        foo: str

    async def quick_node(state: State) -> State:
        await asyncio.sleep(0.05)
        return {"foo": "done"}

    graph = (
        StateGraph(State)
        .set_node_defaults(timeout=TimeoutPolicy(run_timeout=0.01))
        .add_node("quick", quick_node, timeout=TimeoutPolicy(run_timeout=5.0))
        .add_edge(START, "quick")
        .compile()
    )

    result = await graph.ainvoke({"foo": ""})
    assert result["foo"] == "done"


def test_set_node_defaults_chaining():
    """set_node_defaults() is chainable and can be called in any order relative to add_node."""

    class State(TypedDict):
        foo: str

    def always_failing(state: State) -> State:
        raise RuntimeError("boom")

    def handler(state: State, error: NodeError) -> State:
        return {"foo": "handled"}

    graph = (
        StateGraph(State)
        .add_node("a", always_failing)
        .add_edge(START, "a")
        .set_node_defaults(
            retry_policy=RetryPolicy(
                max_attempts=1, initial_interval=0.01, jitter=False
            ),
            error_handler=handler,
        )
        .compile()
    )

    result = graph.invoke({"foo": ""})
    assert result["foo"] == "handled"


def test_set_node_defaults_combined_retry_and_error_handler():
    """Retries are exhausted first, then the error handler runs."""

    class State(TypedDict):
        foo: str

    attempts = 0
    captured: dict[str, Any] = {}

    def always_failing(state: State) -> State:
        nonlocal attempts
        attempts += 1
        raise ValueError("Always fails")

    def handler(state: State, error: NodeError) -> State:
        captured["error"] = str(error.error)
        return {"foo": "handled"}

    graph = (
        StateGraph(State)
        .set_node_defaults(
            retry_policy=RetryPolicy(
                max_attempts=2,
                initial_interval=0.01,
                jitter=False,
                retry_on=ValueError,
            ),
            error_handler=handler,
        )
        .add_node("fail", always_failing)
        .add_edge(START, "fail")
        .compile()
    )

    with patch("time.sleep"):
        result = graph.invoke({"foo": ""})

    assert result["foo"] == "handled"
    assert attempts == 2
    assert captured["error"] == "Always fails"


def test_error_handler_resumes_after_crash():
    """If the error handler crashes, resuming should re-schedule the handler
    (not re-execute the original failed node)."""

    class State(TypedDict):
        foo: str

    call_count = {"node": 0, "handler": 0}
    captured_errors: list[NodeError] = []

    def failing_node(state: State) -> State:
        call_count["node"] += 1
        raise RuntimeError("boom")

    handler_should_fail = [True]

    def handler(state: State, error: NodeError) -> State:
        call_count["handler"] += 1
        captured_errors.append(error)
        if handler_should_fail[0]:
            raise RuntimeError("handler crash")
        return {"foo": "recovered"}

    checkpointer = MemorySaver()
    graph = (
        StateGraph(State)
        .set_node_defaults(error_handler=handler)
        .add_node("fail", failing_node)
        .add_edge(START, "fail")
        .compile(checkpointer=checkpointer)
    )

    config = {"configurable": {"thread_id": "t1"}}

    # First invoke: node fails -> handler runs -> handler crashes -> run fails
    with pytest.raises(RuntimeError, match="handler crash"):
        graph.invoke({"foo": ""}, config)

    assert call_count["node"] == 1
    assert call_count["handler"] == 1
    assert captured_errors[0].node == "fail"
    assert isinstance(captured_errors[0].error, RuntimeError)
    assert str(captured_errors[0].error) == "boom"

    # Resume: handler should run again, NOT the original node
    handler_should_fail[0] = False
    result = graph.invoke(None, config)

    assert result["foo"] == "recovered"
    assert call_count["node"] == 1  # NOT re-executed
    assert call_count["handler"] == 2  # ran again on resume
    # on resume the error was round-tripped through the checkpointer, so it
    # may be deserialized as a string representation rather than the original
    # exception type — verify the node name and that the error content matches.
    assert captured_errors[1].node == "fail"
    assert "boom" in str(captured_errors[1].error)


def test_error_handler_resumes_after_crash_multiple_nodes():
    """When multiple nodes fail in the same superstep and all have error handlers:
    - error handlers start running while other nodes may still be in-flight
    - resuming re-schedules each handler (not re-executes the original nodes)
    """

    class State(TypedDict):
        results: Annotated[list[str], operator.add]

    call_count = {"a": 0, "b": 0, "handler_a": 0, "handler_b": 0}
    handler_a_started = threading.Event()

    def node_a(state: State) -> State:
        call_count["a"] += 1
        raise RuntimeError("a failed")

    def node_b(state: State) -> State:
        call_count["b"] += 1
        # Block until handler_a has started — proves the error handler runs
        # concurrently with in-flight nodes in the same superstep.
        assert handler_a_started.wait(timeout=5), "handler_a never started"
        raise RuntimeError("b failed")

    handler_should_fail = [True]

    def handler_a(state: State, error: NodeError) -> State:
        call_count["handler_a"] += 1
        assert error.node == "a"
        assert "a failed" in str(error.error)
        handler_a_started.set()
        if handler_should_fail[0]:
            raise RuntimeError("handler_a crash")
        return {"results": [f"recovered_a:{error.node}"]}

    def handler_b(state: State, error: NodeError) -> State:
        call_count["handler_b"] += 1
        assert error.node == "b"
        assert "b failed" in str(error.error)
        if handler_should_fail[0]:
            raise RuntimeError("handler_b crash")
        return {"results": [f"recovered_b:{error.node}"]}

    checkpointer = MemorySaver()
    graph = (
        StateGraph(State)
        .add_node("a", node_a, error_handler=handler_a)
        .add_node("b", node_b, error_handler=handler_b)
        .add_edge(START, "a")
        .add_edge(START, "b")
        .compile(checkpointer=checkpointer)
    )

    config = {"configurable": {"thread_id": "t1"}}

    # First invoke: node_a fails immediately -> handler_a starts (sets event) ->
    # node_b unblocks and fails -> handler_b starts -> both handlers crash
    with pytest.raises(RuntimeError):
        graph.invoke({"results": []}, config)

    assert call_count["a"] == 1
    assert call_count["b"] == 1
    assert call_count["handler_a"] == 1
    assert call_count["handler_b"] == 1

    # Resume: both handlers should run again, NOT the original nodes
    handler_should_fail[0] = False
    handler_a_started.clear()
    result = graph.invoke(None, config)

    assert call_count["a"] == 1  # NOT re-executed
    assert call_count["b"] == 1  # NOT re-executed
    assert call_count["handler_a"] == 2  # ran again on resume
    assert call_count["handler_b"] == 2  # ran again on resume
    assert "recovered_a:a" in result["results"]
    assert "recovered_b:b" in result["results"]


@NEEDS_TASK_CANCELLING
@pytest.mark.anyio
async def test_arun_with_retry_user_raised_cancelled_becomes_node_cancelled():
    class UserCancelsProc:
        async def ainvoke(self, input, config):
            raise asyncio.CancelledError("nope")

    task = _make_task(UserCancelsProc(), name="user-cancel")

    with pytest.raises(NodeCancelledError) as excinfo:
        await arun_with_retry(task, retry_policy=None)
    assert excinfo.value.node == "user-cancel"
    # original CancelledError chained for debugging
    assert isinstance(excinfo.value.__cause__, asyncio.CancelledError)


@NEEDS_TASK_CANCELLING
@pytest.mark.anyio
async def test_arun_with_retry_user_raised_cancelled_with_timeout_policy():
    """The timeout path runs the node in a child task; the conversion must
    still trigger for user-raised ``CancelledError``."""

    class UserCancelsProc:
        async def ainvoke(self, input, config):
            raise asyncio.CancelledError

    task = _make_task(
        UserCancelsProc(), timeout=_idle_timeout(1.0), name="user-cancel-timed"
    )

    with pytest.raises(NodeCancelledError) as excinfo:
        await arun_with_retry(task, retry_policy=None)
    assert excinfo.value.node == "user-cancel-timed"


def test_run_with_retry_sync_node_raising_cancelled_becomes_node_cancelled():
    class SyncUserCancelsProc:
        def invoke(self, input, config):
            raise asyncio.CancelledError("sync nope")

    task = _make_task(SyncUserCancelsProc(), timeout=None, name="sync-user-cancel")

    with pytest.raises(NodeCancelledError) as excinfo:
        run_with_retry(task, retry_policy=None)
    assert excinfo.value.node == "sync-user-cancel"
    assert isinstance(excinfo.value.__cause__, asyncio.CancelledError)


@NEEDS_TASK_CANCELLING
@pytest.mark.anyio
async def test_arun_with_retry_external_cancel_propagates_as_cancelled():
    """When the asyncio task running ``arun_with_retry`` is cancelled from the
    outside, the cancellation must still propagate as
    ``asyncio.CancelledError``. Converting it to ``NodeCancelledError`` would
    break the runner's ability to cancel sibling tasks during cleanup."""

    started = asyncio.Event()
    observed: list[BaseException] = []

    class SlowProc:
        async def ainvoke(self, input, config):
            started.set()
            await asyncio.sleep(10.0)
            return "never"

    task = _make_task(SlowProc(), timeout=None, name="external-cancel")

    async def runner():
        try:
            await arun_with_retry(task, retry_policy=None)
        except BaseException as exc:
            observed.append(exc)
            raise

    bg = asyncio.create_task(runner())
    await started.wait()
    bg.cancel()
    # We expect the cancellation to surface to us as well; swallow it here so
    # the test runner's own task isn't poisoned by the cancel.
    with contextlib.suppress(asyncio.CancelledError):
        await bg
    assert observed, "runner did not observe any exception"
    # Framework cancellation must remain a CancelledError, not be rewritten as
    # NodeCancelledError.
    assert isinstance(observed[0], asyncio.CancelledError)
    assert not isinstance(observed[0], NodeCancelledError)


@NEEDS_TASK_CANCELLING
@pytest.mark.anyio
async def test_pregel_user_raised_cancellederror_fails_run():
    """End-to-end: a two-branch graph where one branch raises
    ``asyncio.CancelledError`` must fail the run instead of returning
    a partial-success state. This is the LSD-1507 customer scenario."""

    class _S(TypedDict, total=False):
        vals: Annotated[list[str], operator.add]

    async def ok(state: _S) -> _S:
        return {"vals": ["ok"]}

    async def boom(state: _S) -> _S:
        raise asyncio.CancelledError("user-raised in node body")

    graph = (
        StateGraph(_S)
        .add_node("ok", ok)
        .add_node("boom", boom)
        .add_edge(START, "ok")
        .add_edge(START, "boom")
        .add_edge("ok", END)
        .add_edge("boom", END)
        .compile()
    )

    with pytest.raises(NodeCancelledError) as excinfo:
        await graph.ainvoke({"vals": []})
    assert excinfo.value.node == "boom"
