diff options
| author | blasty <blasty@local> | 2026-08-10 00:14:51 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-08-10 00:14:51 +0200 |
| commit | ccf4616a0ace008af427234358ee256e537bfa02 (patch) | |
| tree | 7e850556cd97005bb21fdc89a208d8c905cf60f8 /idatui/remote_tools.py | |
| parent | remote_tools: delete a shadowed duplicate hiding an lru_cache landmine (diff) | |
| download | ida-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.
Diffstat (limited to 'idatui/remote_tools.py')
| -rw-r--r-- | idatui/remote_tools.py | 23 |
1 files changed, 18 insertions, 5 deletions
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 |
