diff options
| author | blasty <blasty@local> | 2026-08-07 00:33:12 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-08-07 00:33:28 +0200 |
| commit | 2b0ae8df463edb597007186688ee557c18f1ac18 (patch) | |
| tree | 006045dd73f4049d98e20a8c0830406490445f08 /idatui/diag.py | |
| parent | launch: the sweep could delete the file it was asked to open (diff) | |
| download | ida-tui-2b0ae8df463edb597007186688ee557c18f1ac18.tar.gz ida-tui-2b0ae8df463edb597007186688ee557c18f1ac18.tar.xz ida-tui-2b0ae8df463edb597007186688ee557c18f1ac18.zip | |
diag: somewhere for swallowed errors to go
A TUI must not die because one background load failed, so this codebase catches
broadly -- ~50 `except Exception` sites, two dozen resolving to `pass`. Right
policy, one bad consequence: with 44 `@work(thread=True)` workers, a failure in
a background load leaves no trace whatsoever. The view 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. idatui/diag.py
is the same idea for everything else: $IDATUI_LOG writes every swallowed error
plus its traceback to a file, and the last 50 are kept in memory regardless so a
driver can ask a live app what went wrong. Unset, it costs an environ lookup.
Wired in where losing the error changes a DECISION rather than just a pixel:
* rename: a resolve() that throws renames as DATA instead of as a function.
* name: a function_of() that throws means we never learn the address is a
function start, so the index keeps the old name and every readback says the
rename didn't happen.
* retype: a resolve() that throws retypes the ENCLOSING function instead.
* decompile: a failed full-body fetch silently returns CLIPPED pseudocode.
* trail: a failed decomp_map stops the pseudocode being painted, silently.
Deliberately NOT wired into the query_one guards -- a modal owning the screen is
normal and constant, and logging it would bury the real entries in noise.
New RPC verb `diag {n?, clear?}`, documented in docs/RPC.md: the answer to "the
verb reported success and the pane shows nothing".
Also a flake, same shape as the others: follow_xrefs waited on the nav depth but
asserted on _cur, and a follow pushes the source entry BEFORE opening the
target -- so the check could run in between and see the function it jumped
from. About one run in ten. It waits on the postcondition it asserts now; three
clean full runs since.
833 checks; --fast is 344 in 3.5s.
Diffstat (limited to 'idatui/diag.py')
| -rw-r--r-- | idatui/diag.py | 121 |
1 files changed, 121 insertions, 0 deletions
diff --git a/idatui/diag.py b/idatui/diag.py new file mode 100644 index 0000000..b45a72d --- /dev/null +++ b/idatui/diag.py @@ -0,0 +1,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() |
