aboutsummaryrefslogtreecommitdiffstats
path: root/idatui/_sync.py
blob: b7da33831e8f098c4203a189f1fabb210d349c3b (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
"""Settle/wait helpers shared by the pilot tests and the live RPC driver.

The single source of truth for "the UI has finished reacting to what I just did".
Both the headless test harness and the in-process RPC server need the exact same
guarantee before they read state back, so it lives here once.

Two yield strategies feed the same poll loop: under a Pilot (tests) we yield with
``pilot.pause`` (which also drains the screen); live (RPC) we yield with
``asyncio.sleep`` and drain explicitly via a throwaway ``Pilot(app)``.
"""
from __future__ import annotations

import asyncio
from typing import Awaitable, Callable, Optional

from textual.pilot import Pilot


async def wait_for(
    pred: Callable[[], bool],
    tick: Callable[[float], Awaitable[None]],
    timeout: float = 20.0,
    step: float = 0.02,
) -> bool:
    """Poll ``pred`` until true or ``timeout`` elapses.

    ``tick(step)`` yields control between polls: ``pilot.pause`` in tests,
    ``asyncio.sleep`` in the live app. Returns whether ``pred`` became true.
    """
    waited = 0.0
    while waited < timeout:
        if pred():
            return True
        await tick(step)
        waited += step
    return False


async def drain(app, timeout: float = 15.0) -> None:
    """Wait for the message pump and every widget to process queued events.

    Reuses Textual's own ``Pilot._wait_for_screen`` (it only needs ``app``), so
    it works against a live app with no real Pilot attached.
    """
    try:
        await Pilot(app)._wait_for_screen(timeout=timeout)
    except Exception:  # noqa: BLE001 — best-effort; never let settle explode
        pass


async def workers_idle(app, timeout: float = 20.0) -> None:
    """Wait (bounded) for all threaded ``@work`` workers to finish.

    Safe because the app has no perpetual Textual workers — the keepalive is a
    client-side thread, not a worker. A timeout guards against a wedged worker.
    """
    try:
        await asyncio.wait_for(app.workers.wait_for_complete(), timeout)
    except Exception:  # noqa: BLE001 — timeout or manager churn; fall through
        pass


async def settle(
    app,
    pred: Optional[Callable[[], bool]] = None,
    *,
    timeout: float = 20.0,
    step: float = 0.02,
    rounds: int = 3,
) -> bool:
    """Block until the app is quiescent, then optionally until ``pred`` holds.

    A round = drain the pump, wait for workers, repeat — because a completing
    worker can post a message that spawns the next worker (nav -> decompile).
    With a ``pred`` (the reliable signal, e.g. ``dec.loaded_ea == ea``) we return
    as soon as it holds; without one we return once no workers remain.
    """
    for _ in range(max(1, rounds)):
        await drain(app, min(timeout, 15.0))
        await workers_idle(app, timeout)
        if pred is not None:
            if pred():
                await drain(app, min(timeout, 15.0))
                return True
        elif len(app.workers) == 0:
            await drain(app, min(timeout, 15.0))
            return True
    await drain(app, min(timeout, 15.0))
    if pred is not None:
        return await wait_for(pred, asyncio.sleep, timeout, step)
    return True