diff --git a/idatui/domain.py b/idatui/domain.py index 1f8263d..9cf539f 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -673,6 +673,14 @@ 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 + #: Where each loaded page starts, so a stale-text refresh can ask the + #: worker "is this page still what I have?" over exactly the extent the + #: worker itself produced. Parallel lists: first head index, the address + #: it was fetched from, the digest it came back with, and its row count. + self._page_head: list[int] = [] + self._page_addr: list[int] = [] + self._page_digest: list[object] = [] + self._page_rows: list[int] = [] #: Set if a text refresh came back with a different head sequence, which #: means something DID move the walk. Program.listing() throws the model #: away when it sees this, so the next read rebuilds from scratch. @@ -758,6 +766,11 @@ class ListingModel: page = self._build_page(rows) with self._lock: gen = self._text_gen + self._page_head.append(len(self._heads)) + self._page_addr.append(frm) + self._page_digest.append(payload.get("digest") + if isinstance(payload, dict) else None) + self._page_rows.append(len(rows)) for h in page: # Banner/label rows (function headers, separators, code labels) # are display-only; don't index them so navigation lands on the @@ -924,13 +937,65 @@ class ListingModel: search body asks for thousands at once) would come back short, fail the sequence check, and condemn the model to a rebuild it did not need. """ - blk = self.TEXT_BLOCK with self._lock: n = len(self._heads) - start = (max(j0, 0) // blk) * blk - while start < min(j1, n): - self._ensure_text_block(start, min(start + blk, n)) - start += blk + j0 = max(j0, 0) + j1 = min(j1, n) + while j0 < j1: + j0 = self._ensure_text_from(j0, j1, n) + + def _ensure_text_from(self, j0: int, j1: int, n: int) -> int: + """Freshen from head ``j0`` and return where to carry on. + + Tries the page ``j0`` falls in first: the worker can say whether that + page still renders exactly as it did, for the cost of the render alone + -- no rows on the wire, none unpickled, no Heads rebuilt. After a rename + nearly every page comes back identical, and that is 40% of what asking + for it again would have cost. Falls back to the block refetch when the + page has genuinely changed (or predates the digest). + """ + with self._lock: + p = bisect.bisect_right(self._page_head, j0) - 1 + usable = (0 <= p < len(self._page_head) + and self._page_digest[p] is not None) + if usable: + p_lo = self._page_head[p] + p_hi = (self._page_head[p + 1] if p + 1 < len(self._page_head) + else len(self._heads)) + gen = self._text_gen + fresh = all(self._head_gen[k] == gen for k in range(p_lo, p_hi)) + addr, want_dig = self._page_addr[p], self._page_digest[p] + want_rows = self._page_rows[p] + if usable and fresh: + return p_hi + if usable and self._verify_page(p, p_lo, p_hi, addr, want_dig, + want_rows, gen): + return p_hi + blk = self.TEXT_BLOCK + end = min(j0 + blk, j1 if usable else n) + self._ensure_text_block(j0, max(end, j0 + 1)) + return max(end, j0 + 1) + + def _verify_page(self, p: int, p_lo: int, p_hi: int, addr: int, + want_dig: object, want_rows: int, gen: int) -> bool: + """Ask whether page ``p`` still renders as it did; mark it fresh if so.""" + try: + payload = self._prog.client.call( + "heads", addr=hex(addr), count=self.PAGE, annotate=True, + digest=True) + except Exception: # noqa: BLE001 -- an older worker has no digest mode + return False + if not isinstance(payload, dict): + return False + got = payload.get("digest") + if got is None or got != want_dig or payload.get("count") != want_rows: + return False + with self._lock: + if self._text_gen != gen or len(self._heads) < p_hi: + return False + for k in range(p_lo, p_hi): + self._head_gen[k] = gen + return True def _ensure_text_block(self, j0: int, j1: int) -> None: """Re-render one block, snapped out to whole ADDRESS groups. diff --git a/server/patch_server.py b/server/patch_server.py index f3cbe35..a34a4a9 100644 --- a/server/patch_server.py +++ b/server/patch_server.py @@ -576,6 +576,28 @@ def _idatui_spans(line): return [[k, t] for k, t, _o in out], trimmed +def _idatui_rows_digest(rows): + """A value that changes whenever any of ``rows`` would render differently. + + Covers everything a client keeps off a row: address, kind, size, the plain + text, the symbol name and the colour spans (which is what makes it exact + rather than a heuristic -- two lines can collapse to the same text and still + be coloured differently). + + Uses the interpreter's own ``hash``, deliberately. It never has to mean + anything outside this process: the client stores what a page hashed to when + it loaded it and hands the same number back to ask whether the page still + hashes to that. One worker, one process, one hash seed. + """ + acc = 0 + for r in rows: + sp = r.get("spans") + acc = hash((acc, r.get("ea"), r.get("kind"), r.get("size"), + r.get("text"), r.get("name"), + tuple(map(tuple, sp)) if sp else None)) + return acc + + def _idatui_unknown_row(ea, size): """One collapsed row for a run of ``size`` undefined bytes starting at ``ea``. A single byte is rendered normally (shows its value); a longer run @@ -661,6 +683,7 @@ def heads( end: Annotated[str, "Optional exclusive end address; default = segment end"] = "", 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, + digest: Annotated[bool, "Return only a digest+count of the rows, not the rows themselves"] = False, ) -> 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 @@ -767,7 +790,17 @@ def heads( rows.extend(_rows_for(ea)) # a struct head expands into member rows ea = _advance(ea) cursor = {"next": hex(ea)} if more else {"done": True} - return {"addr": str(addr), "heads": rows, "cursor": cursor} + out = {"addr": str(addr), "cursor": cursor, + "digest": _idatui_rows_digest(rows), "count": len(rows)} + # ``digest`` mode answers "is this page still exactly what you have?" without + # shipping it. The rows are built either way -- generate_disasm_line is the + # floor and there is no way to know a line is unchanged without rendering it + # -- but pickling several hundred rows with their colour spans, unpickling + # them and rebuilding Heads is about 40% of what a page costs, and after a + # rename almost every page comes back identical. + if not digest: + out["heads"] = rows + return out @tool