aboutsummaryrefslogtreecommitdiffstats
path: root/idatui/diag.py
blob: b45a72d0707290e0d31dccfd05db17a80ad5c94c (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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
"""Where swallowed errors go.

A TUI must not die because one background load failed, so this codebase catches
broadly -- around fifty ``except Exception`` sites, two dozen of which resolve
to ``pass``. That is the right policy and it has one bad consequence: with
forty-odd ``@work(thread=True)`` workers, a failure in a background load leaves
no trace at all. The view just stays empty, and there is nothing to read
afterwards because the app owns the screen.

``kittygfx`` already solved this for itself with ``$IDATUI_KITTY_LOG``. This is
the same idea for everything else:

* ``$IDATUI_LOG=/tmp/x.log`` writes every swallowed error to a file. Unset (the
  default) it costs an ``os.environ`` lookup and nothing else.
* The last few are kept in memory regardless, so ``drive diag`` can ask a live
  app "what went wrong recently?" -- which is the question you actually have
  when a driver reports success and the pane shows nothing.

Use it where an exception would otherwise vanish::

    with swallow("decomp_map(%#x)" % ea):
        self._apply_split_map(ea, self.program.decomp_map(ea))

NOT for expected control flow. ``query_one`` raising because a modal owns the
screen is normal and happens constantly; wrapping that would bury the real
entries in noise. The test for whether it belongs here is "would I want to see
this after the fact?".
"""
from __future__ import annotations

import contextlib
import os
import threading
import time
import traceback
from collections import deque

#: Bounded on purpose: this is a debugging aid inside a long-running TUI, not an
#: audit log. Old entries are worth less than the memory.
_MAX = 50
_ring: deque[dict] = deque(maxlen=_MAX)
_lock = threading.Lock()


def _logfile() -> str | None:
    """Read the env var per call, not once at import.

    The pilot suite and the RPC tests set it after importing the app, and a
    cached value would silently disable the thing being tested.
    """
    return os.environ.get("IDATUI_LOG") or None


def log(msg: str) -> None:
    """Append a line to ``$IDATUI_LOG``. No-op when it isn't set."""
    path = _logfile()
    if not path:
        return
    try:
        with open(path, "a", encoding="utf-8") as fh:
            fh.write(f"{time.strftime('%H:%M:%S')} {msg}\n")
    except OSError:
        pass          # a broken log path must never break the app


def note(what: str, exc: BaseException) -> None:
    """Record a swallowed exception: in the ring always, in the log if enabled."""
    entry = {
        "when": time.time(),
        "what": what,
        "error": f"{type(exc).__name__}: {exc}",
        "where": _origin(exc),
        "thread": threading.current_thread().name,
    }
    with _lock:
        _ring.append(entry)
    log(f"[swallowed] {what}: {entry['error']}  ({entry['where']})")
    if _logfile():
        log("".join(traceback.format_exception(
            type(exc), exc, exc.__traceback__)).rstrip())


def _origin(exc: BaseException) -> str:
    """file:line where it was actually raised (the deepest frame we have)."""
    tb = exc.__traceback__
    last = None
    while tb is not None:
        last = tb
        tb = tb.tb_next
    if last is None:
        return "?"
    f = last.tb_frame
    return f"{os.path.basename(f.f_code.co_filename)}:{last.tb_lineno}"


@contextlib.contextmanager
def swallow(what: str, *, reraise: tuple = ()):
    """Run a block, record anything it raises, and carry on.

    ``reraise`` lets a caller keep the exceptions it genuinely handles -- most
    usefully ``IDAConnectionError``, which the app turns into a reconnect and
    must not have eaten here.
    """
    try:
        yield
    except reraise:
        raise
    except Exception as e:  # noqa: BLE001 -- the whole point
        note(what, e)


def recent(n: int = 10) -> list[dict]:
    """The last ``n`` swallowed errors, newest last."""
    with _lock:
        items = list(_ring)
    return items[-n:] if n > 0 else items


def clear() -> None:
    with _lock:
        _ring.clear()