diff options
Diffstat (limited to 'idatui')
| -rw-r--r-- | idatui/app.py | 14 | ||||
| -rw-r--r-- | idatui/codemode_client.py | 2 | ||||
| -rw-r--r-- | idatui/domain.py | 53 | ||||
| -rw-r--r-- | idatui/remote_tools.py | 23 |
4 files changed, 71 insertions, 21 deletions
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 |
