aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <peter@haxx.in>2026-08-07 01:55:31 +0200
committerblasty <peter@haxx.in>2026-08-07 01:55:31 +0200
commit86441bd2838358f34544934887a2b092b316a03d (patch)
treed5e3a377d5587ff6474ed19594414dea310ade86
parent_idatui_spans: one capturing re.split over the tag pairs instead of finditer+... (diff)
downloadida-tui-86441bd2838358f34544934887a2b092b316a03d.tar.gz
ida-tui-86441bd2838358f34544934887a2b092b316a03d.tar.xz
ida-tui-86441bd2838358f34544934887a2b092b316a03d.zip
Re-apply #5 (lru_cache on the per-line render + Heads built with their opcode bytes already attached) with the graph_minimap scenario's racy SETUP made deterministic: clear _graph_sticky before the second navigation so Space is known to be entering the graph, not leaving it. No assertion changed.
Result: {"status":"keep","total_ms":22980.2,"lg_boot_ms":738.2,"lg_decomp_ms":2401.8,"lg_graph_ms":944.1,"lg_hex_ms":920.6,"lg_index_ms":75.2,"lg_listing_cold_ms":538.5,"lg_listing_warm_ms":411.1,"lg_nav_ms":6813.9,"lg_palette_ms":4.9,"lg_render_ms":221.8,"lg_search_ms":5630.1,"pure_graph_ms":240.7,"sm_boot_ms":537.5,"sm_decomp_ms":595.1,"sm_graph_ms":715.7,"sm_hex_ms":858.8,"sm_index_ms":0,"sm_listing_cold_ms":263.3,"sm_listing_warm_ms":265.3,"sm_nav_ms":335.2,"sm_palette_ms":0.3,"sm_render_ms":271.4,"sm_search_ms":196.5,"fails":0}
-rw-r--r--idatui/domain.py85
-rw-r--r--server/patch_server.py58
-rw-r--r--tests/test_scenarios.py10
3 files changed, 102 insertions, 51 deletions
diff --git a/idatui/domain.py b/idatui/domain.py
index b496638..1c7883f 100644
--- a/idatui/domain.py
+++ b/idatui/domain.py
@@ -123,19 +123,22 @@ class Head:
return None
@classmethod
- def from_raw(cls, d: dict) -> "Head":
+ def from_raw(cls, d: dict, raw: bytes | None = None) -> "Head":
sp = d.get("spans")
ops = d.get("ops")
+ # ``tuple(map(tuple, ...))`` rather than a per-item genexpr with str()/
+ # int() coercion: this runs once per listing row (hundreds of thousands
+ # on a real binary) and the worker's own tool already emits [str, str]
+ # and [int, int, int]. The coercion was re-proving that on every row.
return cls(
ea=_as_int(d["ea"]),
kind=d.get("kind", "unknown"),
size=int(d.get("size", 0) or 0),
text=d.get("text", ""),
name=d.get("name"),
- spans=(tuple((str(k), str(t)) for k, t in sp)
- if isinstance(sp, list) and sp else None),
- ops=(tuple((int(a), int(b), int(n)) for a, b, n in ops)
- if isinstance(ops, list) and ops else None),
+ raw=raw,
+ spans=tuple(map(tuple, sp)) if sp else None,
+ ops=tuple(map(tuple, ops)) if ops else None,
)
@@ -654,30 +657,48 @@ class ListingModel:
# containing a huge coalesced undefined run doesn't pull megabytes.
_OP_SPAN_CAP = 1 << 16
- def _attach_opcode_bytes(self, page: list[Head]) -> list[Head]:
- """Fill ``raw`` (opcode bytes) for the code heads in ``page`` via one
- bulk read over their extent (variable-length safe)."""
- code = [h for h in page if h.kind == "code" and h.size > 0]
- if not code:
- return page
- lo = code[0].ea
- hi = code[-1].ea + code[-1].size
- if hi - lo <= 0 or hi - lo > self._OP_SPAN_CAP:
- return page
- data = self._prog.read_bytes(lo, hi - lo)
+ def _build_page(self, rows: list) -> list[Head]:
+ """Turn the tool's raw rows into ``Head``s with their opcode bytes
+ already attached, via one bulk read over the code extent.
+
+ The bytes are read BEFORE the Heads are built rather than patched in
+ afterwards: ``dataclasses.replace`` re-runs ``__init__`` with every
+ field, so filling ``raw`` after the fact meant constructing each code
+ head twice -- once per listing row, on the path a jump-to-address walks
+ hundreds of thousands of times.
+ """
+ lo = hi = -1
+ for r in rows:
+ if r.get("kind") == "code" and r.get("size"):
+ ea = _as_int(r["ea"])
+ if lo < 0:
+ lo = ea
+ hi = ea + int(r["size"])
+ data = None
+ if 0 <= lo < hi and hi - lo <= self._OP_SPAN_CAP:
+ try:
+ data = self._prog.read_bytes(lo, hi - lo)
+ except Exception: # noqa: BLE001 -- opcode bytes are decoration
+ data = None
+ page: list[Head] = []
biggest = self._max_raw
- out = []
- for h in page:
- if h.kind == "code" and h.size > 0:
- off = h.ea - lo
- b = bytes(data[off:off + h.size])
- biggest = max(biggest, len(b))
- out.append(replace(h, raw=b))
- else:
- out.append(h)
- with self._lock:
- self._max_raw = biggest
- return out
+ for r in rows:
+ raw = None
+ if data is not None and r.get("kind") == "code":
+ size = int(r.get("size") or 0)
+ if size > 0:
+ off = _as_int(r["ea"]) - lo
+ raw = bytes(data[off:off + size])
+ if len(raw) > biggest:
+ biggest = len(raw)
+ try:
+ page.append(Head.from_raw(r, raw))
+ except (KeyError, ValueError, TypeError):
+ continue
+ if biggest != self._max_raw:
+ with self._lock:
+ self._max_raw = biggest
+ return page
def max_raw_len(self) -> int:
with self._lock:
@@ -700,13 +721,7 @@ class ListingModel:
"heads", addr=hex(frm), count=self.PAGE, annotate=True)
rows = payload.get("heads", []) if isinstance(payload, dict) else []
cur = payload.get("cursor", {}) if isinstance(payload, dict) else {}
- page = []
- for r in rows:
- try:
- page.append(Head.from_raw(r))
- except (KeyError, ValueError, TypeError):
- continue
- page = self._attach_opcode_bytes(page)
+ page = self._build_page(rows)
with self._lock:
for h in page:
# Banner/label rows (function headers, separators, code labels)
diff --git a/server/patch_server.py b/server/patch_server.py
index b75b120..a1ec1ef 100644
--- a/server/patch_server.py
+++ b/server/patch_server.py
@@ -297,33 +297,59 @@ def _idatui_head_row(ea):
else:
kind = "unknown"
line = ida_lines.generate_disasm_line(ea, 0)
- text = ida_lines.tag_remove(line) if line else ""
- text = " ".join(text.split()) # collapse IDA's column padding
+ text, spans, ops = _idatui_line_parts(line) if line else ("", None, None)
row = {
"ea": hex(ea),
"kind": kind,
"size": int(ida_bytes.get_item_size(ea)),
"text": text,
}
- if line:
- # Keep IDA's own token classification for syntax highlighting. Built from
- # the SAME line as `text`, then whitespace-collapsed identically so the
- # two never disagree about what the row says.
- spans, ops = _idatui_spans(line)
- joined = "".join(t for _k, t in spans)
- if " ".join(joined.split()) == text:
- row["spans"] = spans
- # Where each operand sits in `text`. Comes out of the same tag walk
- # (free), and is what lets the client show WHICH literal a keypress
- # would reformat before you press it.
- if ops:
- row["ops"] = ops
+ if spans is not None:
+ row["spans"] = spans
+ # Where each operand sits in `text`. Comes out of the same tag walk
+ # (free), and is what lets the client show WHICH literal a keypress
+ # would reformat before you press it.
+ if ops:
+ row["ops"] = ops
nm = ida_name.get_ea_name(ea)
if nm:
row["name"] = nm
return row
+import functools as _idatui_functools
+
+
+@_idatui_functools.lru_cache(maxsize=16384)
+def _idatui_line_parts(line):
+ """``(text, spans, ops)`` for one tagged disassembly line -- memoised.
+
+ A function of the tagged line and nothing else, so the same line always
+ gives the same answer: a rename changes the line, which changes the key.
+ And listings repeat themselves hard -- 196k lines of bash are 53k distinct
+ ones, so a 16k-entry cache serves ~70% of them and takes the per-line cost
+ from 10.4us to 3.9us. This is the most expensive thing the backend does per
+ listing row, and a jump to an address near the end of a big binary walks
+ hundreds of thousands of them.
+
+ ``spans`` is None when the tag walk and the plain text disagree about what
+ the line says (then the text wins and the row renders unhighlighted).
+
+ The returned lists are SHARED between every row that has the same line;
+ treat them as read-only. Pickle notices the sharing too, so a page of
+ repetitive disassembly also serialises smaller.
+ """
+ import ida_lines
+ text = " ".join(ida_lines.tag_remove(line).split()) # collapse the padding
+ spans, ops = _idatui_spans(line)
+ # Built from the SAME line as `text`, then whitespace-collapsed identically,
+ # so the two can never disagree about what the row says.
+ joined = "".join([t for _k, t in spans])
+ if " ".join(joined.split()) != text:
+ return (text, None, None)
+ return (text, spans, ops)
+
+
#: IDA colour tag -> the semantic kind the TUI styles. IDA already classifies
#: every token in a disassembly line, for every processor it supports, so there
#: is nothing to lex: generate_disasm_line emits \x01<tag>text\x02<tag> and the
@@ -407,7 +433,7 @@ def _idatui_spans(line):
# the most expensive thing the `heads` tool did, and a line is ~54
# characters but only ~13 tags -- everything between two tags is already
# exactly one span's worth of text.
- _IDATUI_CTL = _re.compile("([\\x01\\x02\\x03][\\s\\S])")
+ _IDATUI_CTL = _re.compile("([\\x01\\x02\\x03](?s:.))")
tags, opnds = _IDATUI_TAGS, _IDATUI_OPND_TAGS
on, off, esc = "\x01", "\x02", "\x03"
addr_tag = chr(getattr(ida_lines, "COLOR_ADDR", 0x28))
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index a36ea80..9b2cc7e 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -3101,7 +3101,17 @@ async def s_graph_minimap(c: Ctx):
# a big graph, so the overview actually maps to somewhere far away
big = c.find_func(lambda f: f.size > 0x300) or fn
+ # _open_graph left graph mode STICKY, and a sticky navigation schedules the
+ # next function's graph by itself -- so whether the Space below ENTERS the
+ # graph or LEAVES it depended on whether that async load landed first. This
+ # scenario is about the minimap, not about sticky mode (graph_sticky covers
+ # that), so drop stickiness and press Space from a known state. Without
+ # this the whole scenario passes or fails on a coin toss: make the backend
+ # fast enough that the reload wins and every minimap click lands on a
+ # widget that is no longer on screen.
+ app._graph_sticky = False
await c.open(big.addr, "listing")
+ await c.wait(lambda: app._active == "listing", 10)
c.lst.focus()
await c.press("space")
await c.wait(lambda: app._active == "graph" and gv.lay is not None, 60)