aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-08-10 00:14:51 +0200
committerblasty <blasty@local>2026-08-10 00:14:51 +0200
commitccf4616a0ace008af427234358ee256e537bfa02 (patch)
tree7e850556cd97005bb21fdc89a208d8c905cf60f8
parentremote_tools: delete a shadowed duplicate hiding an lru_cache landmine (diff)
downloadida-tui-ccf4616a0ace008af427234358ee256e537bfa02.tar.gz
ida-tui-ccf4616a0ace008af427234358ee256e537bfa02.tar.xz
ida-tui-ccf4616a0ace008af427234358ee256e537bfa02.zip
Skeleton pages: stop rendering 227k rows to count them (3x boot)
ListingView._grow streams the entire segment in the background for one reason: to learn how many rows it has, so the scrollbar and paging are right. It did that by rendering every row in full -- 227,500 rows of a 1.2MB bash, 911 backend calls, 9.3 seconds -- essentially none of which is ever looked at. generate_disasm_line is 22x the cost of the walk around it, so heads() gains text=False: a SKELETON page with the same rows at the same addresses with the same kinds and sizes, and no rendered text. Measured identical structurally (rows, addresses, kinds, sizes and cursor all match a real page) which is what makes one swappable for the other later. It also skips the digest (nothing to go stale) and lets the client skip the bulk opcode read, so a page costs ONE round trip instead of two. Client side is deliberately tiny, because the machinery already existed: a skeleton page is just a page whose text is stale. It is marked with a sentinel generation no _text_gen can equal, and the FIRST read of it goes through the same _ensure_text/_ensure_page path a rename uses -- which already refetches a page by address, verifies the structure still lines up and splices it in. Two staleness gates learn to fire for _skeleton as well as _renamed; that is the whole integration. bash boot: 911 calls / 9.26s -> 456 calls / 3.12s, 3.0x. The trade is that a page you actually display is fetched twice (3.3ms + 9.4ms vs 9.4ms), paid only for what is shown. _prime still loads real pages, so the viewport you land on is never a skeleton. The failure mode is BLANK ROWS, not an exception, and nothing in the suite scrolled far enough to see one: _prime renders the first ~1000 rows for real, so a test that pages down a few screens passes against a completely broken implementation. The new scenario reads deep rows through both the model and the render path, and asserts materialising changes neither the row count nor the walk. Verified by reverting the two gates: it fails with text=''. Full gate: 1050 passed.
-rw-r--r--experiments/profile_remote.py1
-rw-r--r--idatui/app.py14
-rw-r--r--idatui/codemode_client.py2
-rw-r--r--idatui/domain.py53
-rw-r--r--idatui/remote_tools.py23
-rw-r--r--tests/test_scenarios.py57
6 files changed, 129 insertions, 21 deletions
diff --git a/experiments/profile_remote.py b/experiments/profile_remote.py
index f35bac2..ca673db 100644
--- a/experiments/profile_remote.py
+++ b/experiments/profile_remote.py
@@ -47,6 +47,7 @@ CALLS = {
# One full listing page, exactly as the background grower asks for it.
"heads": 'heads(addr=a["addr"], count=500, annotate=True)',
"heads_plain": 'heads(addr=a["addr"], count=500, annotate=False)',
+ "heads_skeleton": 'heads(addr=a["addr"], count=500, annotate=True, text=False)',
"decompile": 'decompile(a["addr"])',
"disasm": 'disasm(a["addr"], 500)',
}
diff --git a/idatui/app.py b/idatui/app.py
index e193c65..a806d65 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -1262,13 +1262,23 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
@work(thread=True, exclusive=True, group="listing-grow")
def _grow(self) -> None:
"""Stream the rest of the segment's heads in the background, growing the
- virtual size as they land so the scrollbar/paging catch up."""
+ virtual size as they land so the scrollbar/paging catch up.
+
+ SKELETON pages: this loop only exists to find out how many rows the
+ segment has, and it used to render every one of them to do it -- 227k
+ rows for a 1.2MB bash, ~9s, essentially all never displayed. A skeleton
+ page has the same rows at the same addresses and no text, is 2.8x
+ cheaper and costs one round trip instead of two. The first read of one
+ materialises it through the same path a rename uses, so only what is
+ actually shown ever gets rendered. _prime (the viewport) still loads
+ real pages, so what you are looking at is never a skeleton.
+ """
model = self.model
if model is None:
return
since = 0
while not model.complete:
- if model.load_next_page() == 0:
+ if model.load_next_page(text=False) == 0:
break
if self.model is not model: # a new load() replaced us
return
diff --git a/idatui/codemode_client.py b/idatui/codemode_client.py
index 5ee2780..d0d1937 100644
--- a/idatui/codemode_client.py
+++ b/idatui/codemode_client.py
@@ -1209,7 +1209,7 @@ _HEADS = _remote_op(
'heads(addr=a["addr"], count=int(a.get("count", 200)),'
' offset=int(a.get("offset", 0)), end=a.get("end", ""),'
' back=bool(a.get("back", False)), annotate=bool(a.get("annotate", False)),'
- ' expect=a.get("expect", ""))')
+ ' expect=a.get("expect", ""), text=bool(a.get("text", True)))')
# The graph view's only backend call. Blocks are address RANGES, never text:
diff --git a/idatui/domain.py b/idatui/domain.py
index d65c473..903611f 100644
--- a/idatui/domain.py
+++ b/idatui/domain.py
@@ -683,6 +683,9 @@ class ListingModel:
"""
PAGE = 500 # viewport-scale heads per Code Mode execution
+ #: Generation marker for a skeleton (text-less) page. Never equals a real
+ #: _text_gen, which counts up from 0, so such a page always reads as stale.
+ _SKELETON_GEN = -1
def __init__(self, program: "Program", seg_start: int, seg_end: int,
name: str | None = None):
@@ -709,6 +712,10 @@ class ListingModel:
#: Whether a rename has ever staled this model. Until one has, every
#: read takes exactly the path it always did.
self._renamed = False
+ #: Whether any page was loaded as a text-less skeleton. Same effect as
+ #: _renamed -- reads have to check the per-head generation -- so the two
+ #: are ORed at every gate rather than duplicating the machinery.
+ self._skeleton = False
#: One entry per loaded PAGE: where its heads start, the address it was
#: fetched from, the digest it came back with, and how many rows it
#: held. A stale-text refresh re-asks for exactly that page, so it can
@@ -735,7 +742,7 @@ class ListingModel:
# containing a huge coalesced undefined run doesn't pull megabytes.
_OP_SPAN_CAP = 1 << 16
- def _build_page(self, rows: list) -> list[Head]:
+ def _build_page(self, rows: list, raw: bool = True) -> 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.
@@ -753,7 +760,10 @@ class ListingModel:
lo = ea
hi = ea + int(r["size"])
data = None
- if 0 <= lo < hi and hi - lo <= self._OP_SPAN_CAP:
+ # A skeleton page shows no text, so it needs no opcode bytes -- and
+ # skipping them drops the SECOND round trip a page costs (heads is
+ # always followed by a bulk read_raw over the code extent).
+ if raw and 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
@@ -782,26 +792,42 @@ class ListingModel:
with self._lock:
return self._max_raw
- def load_next_page(self) -> int:
- """Load one more page of heads; returns how many were added."""
- return self._load_next_page()
+ def load_next_page(self, text: bool = True) -> int:
+ """Load one more page of heads; returns how many were added.
- def _load_next_page(self) -> int:
+ ``text=False`` loads a SKELETON page: the same rows at the same
+ addresses with the same sizes and kinds, but no rendered disassembly
+ and no opcode bytes -- 2.8x cheaper, and one round trip instead of two.
+
+ That is all the background grower needs. It exists to discover how many
+ rows the segment has so the scrollbar and paging are right, and it
+ renders 227k rows of a 1.2MB bash to do it, essentially all of which are
+ never looked at. A skeleton page is marked text-stale, so the FIRST read
+ of one goes through exactly the same ``_ensure_text`` path a rename uses
+ and materialises it, one page per round trip, only for what is shown.
+ """
+ return self._load_next_page(text)
+
+ def _load_next_page(self, text: bool = True) -> int:
with self._load_lock:
- return self._load_next_page_locked()
+ return self._load_next_page_locked(text)
- def _load_next_page_locked(self) -> int:
+ def _load_next_page_locked(self, text: bool = True) -> int:
with self._lock:
if self._done or self._next is None:
return 0
frm = self._next
payload = self._prog.client.invoke(
- "heads", addr=hex(frm), count=self.PAGE, annotate=True)
+ "heads", addr=hex(frm), count=self.PAGE, annotate=True, text=text)
rows = payload.get("heads", []) if isinstance(payload, dict) else []
cur = payload.get("cursor", {}) if isinstance(payload, dict) else {}
- page = self._build_page(rows)
+ page = self._build_page(rows, raw=text)
with self._lock:
- gen = self._text_gen
+ # A sentinel generation no _text_gen can ever equal, so the page
+ # reads as stale until something asks for it and refreshes it.
+ gen = self._text_gen if text else self._SKELETON_GEN
+ if not text:
+ self._skeleton = True
self._page_head.append(len(self._heads))
self._page_addr.append(frm)
self._page_digest.append(payload.get("digest")
@@ -1104,7 +1130,8 @@ class ListingModel:
j, off = self._phys(i)
if j < 0:
return None
- stale = self._renamed and self._head_gen[j] != self._text_gen
+ stale = ((self._renamed or self._skeleton)
+ and self._head_gen[j] != self._text_gen)
if not stale:
span = self._span(self._heads[j])
h = self._heads[j]
@@ -1132,7 +1159,7 @@ class ListingModel:
# _renamed stays set once a rename has happened; _ensure_text then
# does the precise, range-limited staleness check. Before the first
# rename this is one boolean and the read is exactly as it was.
- dirty = self._renamed
+ dirty = self._renamed or self._skeleton
if dirty:
j0 = max(self._phys(max(start, 0))[0], 0)
j1 = self._phys(max(min(self._rows, start + count) - 1, 0))[0] + 1
diff --git a/idatui/remote_tools.py b/idatui/remote_tools.py
index dca8f14..1f66193 100644
--- a/idatui/remote_tools.py
+++ b/idatui/remote_tools.py
@@ -102,13 +102,20 @@ def compact_whitespace(line: str) -> str:
# used to sit here, shadowed by the real ones below. If you find one again:
# keep the copy carrying @lru_cache. Deleting that one instead is a silent
# ~2.7x regression on every listing row (10.4us -> 3.9us is the cache).
-def _idatui_head_row(ea, flags=None):
+def _idatui_head_row(ea, flags=None, text=True):
"""One flat-listing row for the head at ``ea``: kind (code/data/unknown),
byte size, rendered text, and any symbol name.
``flags`` lets a caller that already asked for them say so -- the walk in
``heads`` used to fetch them three times per head (here, in _is_unknown from
_advance, and again from _rows_for).
+
+ ``text=False`` builds a SKELETON row: address, kind, size and name, but no
+ rendered text and no colour spans. generate_disasm_line is 22x the cost of
+ the walk around it, and a caller that only needs to know how many rows a
+ segment has -- which is what sizing the scrollbar needs -- should not pay
+ it. The row COUNT and the addresses are identical either way, which is what
+ makes a skeleton page swappable for a real one later.
"""
f = ida_bytes.get_flags(ea) if flags is None else flags
@@ -118,8 +125,11 @@ def _idatui_head_row(ea, flags=None):
kind = "data"
else:
kind = "unknown"
- line = ida_lines.generate_disasm_line(ea, 0)
- text, spans, ops = _idatui_line_parts(line) if line else ("", None, None)
+ if text:
+ line = ida_lines.generate_disasm_line(ea, 0)
+ text, spans, ops = _idatui_line_parts(line) if line else ("", None, None)
+ else:
+ text, spans, ops = "", None, None
row = {
"ea": hex(ea),
"kind": kind,
@@ -492,6 +502,7 @@ def heads(
back: Annotated[bool, "Walk backwards: return the count heads ENDING just before addr, in forward order"] = False,
annotate: Annotated[bool, "Emit IDA-style function boundary banner rows (kind sep/funchdr)"] = False,
expect: Annotated[str, "Digest a caller already holds: the rows are omitted when they still hash to it"] = "",
+ text: Annotated[bool, "Render each row's disassembly text (default true). False = a skeleton page: same rows, same addresses, no text"] = True,
) -> dict:
"""Walk item heads from ``addr`` as a flat listing: every head is rendered
(code OR data OR undefined) via generate_disasm_line and stepped with
@@ -578,7 +589,7 @@ def heads(
out = []
if at_start:
out.extend(_idatui_func_header_rows(e))
- row = _idatui_head_row(e, f)
+ row = _idatui_head_row(e, f, text)
if at_start:
row = dict(row)
row["name"] = None # the name is shown on the proc header line
@@ -612,7 +623,9 @@ def heads(
rows.extend(_rows_for(ea, f)) # a struct head expands into member rows
ea = _advance(ea, f)
cursor = {"next": hex(ea)} if more else {"done": True}
- dig = _idatui_rows_digest(rows)
+ # A skeleton page has no text to go stale, so there is nothing to digest --
+ # and the digest is only ever used to skip re-sending text.
+ dig = _idatui_rows_digest(rows) if text else None
out = {"addr": str(addr), "cursor": cursor, "digest": dig, "count": len(rows)}
# ``expect`` says "I already hold a page that hashed to this". The rows are
# built either way -- generate_disasm_line is the floor and there is no way
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index 038cac5..780459a 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -412,6 +412,63 @@ async def s_auto_land(c: Ctx):
c.check("auto-land is idempotent (guarded)", app._cur is prev)
+@scenario("skeleton_pages")
+async def s_skeleton_pages(c: Ctx):
+ """The background grower loads text-less pages; reading one must fill it in.
+
+ _grow streams the whole segment only to learn how many rows it has, so it
+ asks for skeleton pages (same rows, same addresses, no rendered text) --
+ 3x cheaper and one round trip instead of two. The first read of such a page
+ has to materialise it through the same path a rename uses.
+
+ The failure mode if that path breaks is BLANK ROWS deep in the listing, not
+ an exception, and nothing else in this suite scrolls far enough to see it:
+ _prime renders the first ~1000 rows for real, so a test that only pages down
+ a few screens passes against a completely broken implementation.
+ """
+ app = c.app
+ await c.open_biggest("listing")
+ lv = app.query_one(ListingView)
+ model = lv.model
+ if model is None:
+ c.check("listing model exists", False)
+ return
+ # Let the grower finish so the tail of the segment is definitely skeleton.
+ await c.wait(lambda: model.complete, 30)
+ c.check("the grower completes", model.complete, f"rows={len(model)}")
+ if not model.complete or len(model) < 1200:
+ # A target smaller than _prime's horizon has no skeleton pages at all,
+ # so there is nothing to check rather than something broken.
+ c.check("segment is big enough to have skeleton pages", True,
+ f"skipped: only {len(model)} rows, _prime renders ~1000")
+ return
+ c.check("pages were loaded as skeletons", model._skeleton is True)
+
+ # Well past _prime's horizon, and the very last row.
+ deep = max(1200, len(model) - 40)
+ for row in (1200, len(model) // 2, deep):
+ h = model.get(row)
+ c.check(f"row {row} of a skeleton page has real text",
+ h is not None and bool((h.text or "").strip()),
+ f"ea={getattr(h, 'ea', None)} text={getattr(h, 'text', None)!r}")
+
+ # And through the render path the user actually sees, not just the model.
+ lv.cursor = deep
+ lv.refresh()
+ await c.pause(0.1)
+ painted = lv._line_plain(deep)
+ c.check("a deep row RENDERS with text",
+ bool(painted and painted.strip()), f"painted={painted!r}")
+
+ # Materialising must not change the row count or move any address: the
+ # skeleton's structure is what the scrollbar was sized from.
+ before = len(model)
+ model.get(deep)
+ c.check("materialising a page does not change the row count",
+ len(model) == before, f"{before} -> {len(model)}")
+ c.check("the walk was not disturbed", not model.stale_structure)
+
+
@scenario("palette_paging")
async def s_palette_paging(c: Ctx):
"""PgUp/PgDn move the palette list by a viewport, with the Input focused.