diff options
| -rw-r--r-- | .auto/check_search.py | 148 | ||||
| -rwxr-xr-x | .auto/checks.sh | 10 | ||||
| -rw-r--r-- | .auto/log.jsonl | 2 | ||||
| -rw-r--r-- | .auto/wip-searchbody.patch | 78 | ||||
| -rw-r--r-- | idatui/app.py | 38 |
5 files changed, 265 insertions, 11 deletions
diff --git a/.auto/check_search.py b/.auto/check_search.py new file mode 100644 index 0000000..74ce719 --- /dev/null +++ b/.auto/check_search.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Correctness gate for the search fast paths (run by .auto/checks.sh). + +`SearchMixin` grew two optimisations that are invisible to the scenario suite +because they produce the *same answer* when they work: + +* **prefix narrowing** — typing a character onto the term rescans only the + previous hits, because a line holding "mov" holds "mo"; +* **the joined haystack** — the whole body is concatenated once so a term is + found with a C-level `str.find` walk instead of a python loop over every row. + +Both are cache-shaped, so the way they break is *staleness*, not a crash. This +drives the real `ListingView` and `DecompView` and asserts that, for every +prefix of a set of terms, the fast path returns exactly the matches and +highlight ranges the plain per-line loop does — including after the things that +are meant to invalidate them (ending a search, toggling the opcode column, +navigating). + + ~/ida-venv/bin/python .auto/check_search.py [targets/echo] +""" +from __future__ import annotations + +import asyncio +import os +import shutil +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +sys.path.insert(0, ROOT) +sys.path.insert(0, os.path.join(ROOT, "tests")) +sys.path.insert(0, HERE) + +from bench import stage # noqa: E402 +from idatui._sync import wait_for # noqa: E402 +from idatui.app import DecompView, IdaTui, ListingView # noqa: E402 + +TERMS = ("mov", "call", "rsp", "Mov", "1a", "push", "e", "lea", "sub_", "0", + " ", "if", "v1", "]") + + +def _reference(view, term: str): + """(matches, ranges) from the plain per-line loop, with every cache off.""" + cls = view.__class__ + saved = cls._search_haystack + cls._search_haystack = lambda self, c, s: None + try: + view._reset_search_cache(body=True) + view._term = term + view._ci = term.islower() + view._compute_matches() + return (list(view._matches), + {k: list(v) for k, v in view._ranges.items()}) + finally: + cls._search_haystack = saved + view._reset_search_cache(body=True) + + +def _fast(view, term: str): + view._term = term + view._ci = term.islower() + view._compute_matches() + return (list(view._matches), {k: list(v) for k, v in view._ranges.items()}) + + +def _check(view, name: str, fails: list) -> int: + """Type every prefix of every term; compare the fast path to the loop.""" + checked = 0 + for word in TERMS: + view.search_begin(1) + for i in range(1, len(word) + 1): + term = word[:i] + got = _fast(view, term) # may narrow from the previous term + want = _reference(view, term) + checked += 1 + if got != want: + a, b = set(got[0]), set(want[0]) + fails.append( + f"{name} {term!r}: fast={len(got[0])} loop={len(want[0])} " + f"only-fast={sorted(a - b)[:4]} only-loop={sorted(b - a)[:4]}") + view.search_cancel() + return checked + + +async def main() -> int: + target = sys.argv[1] if len(sys.argv) > 1 else "targets/echo" + fails: list[str] = [] + d, path = stage(os.path.join(ROOT, target)) + app = IdaTui(open_path=path, keepalive=False) + try: + async with app.run_test(size=(140, 44)) as pilot: + async def w(pred, t=300.0): + return await wait_for(pred, pilot.pause, t, 0.02) + + if not await w(lambda: app._func_index is not None + and app._func_index.complete): + fails.append("boot: function index never completed") + return 1 + await w(lambda: app._loading_screen is None + and len(app.screen_stack) == 1, 120) + funcs = app._func_index.all_loaded() + lst = app.query_one(ListingView) + fn = funcs[min(5, len(funcs) - 1)] + app._open_function(fn.addr, fn.name) + await w(lambda: lst.total > 0 and lst._cursor_ea() == fn.addr, 120) + lst.model.load_all() + lst.total = len(lst.model) + + n = _check(lst, "listing", fails) + + # The opcode-bytes column is searchable text and changes width + # without changing the row count or the model -- the one thing the + # haystack's key cannot see. + app.action_toggle_view.__self__ # noqa: B018 - keep app referenced + lst.action_toggle_opcodes() + n += _check(lst, "listing/opcodes", fails) + lst.action_toggle_opcodes() + + # Navigating inside the same segment must NOT invalidate the body, + # and must not leave it stale either. + other = funcs[min(9, len(funcs) - 1)] + app._open_function(other.addr, other.name) + await w(lambda: lst.total > 0 + and lst._cursor_ea() == other.addr, 120) + lst.model.load_all() + lst.total = len(lst.model) + n += _check(lst, "listing/after-nav", fails) + + # The pseudocode view uses a different line source. + app._active = "listing" + app._show_active() + lst.focus() + await pilot.pause(0.05) + app.action_toggle_view() + dv = app.query_one(DecompView) + if await w(lambda: dv.total > 0, 60): + n += _check(dv, "decomp", fails) + app.exit() + print(f"search fast paths: {n} prefixes checked, {len(fails)} mismatches") + for f in fails[:10]: + print(" FAIL", f) + return 1 if fails else 0 + finally: + shutil.rmtree(d, ignore_errors=True) + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/.auto/checks.sh b/.auto/checks.sh index 323f0fd..6cf1102 100755 --- a/.auto/checks.sh +++ b/.auto/checks.sh @@ -6,6 +6,9 @@ # driving the real Textual app. This is the project's source of truth for # behaviour; a rendering/paging/nav optimisation that breaks the UI shows # up here and nowhere else. +# 3. .auto/check_search.py -- the search fast paths against the plain loop. +# Caches that go stale still return an answer, so the scenario suite +# cannot see them; this compares fast and slow directly. # # Only failures reach stdout: the agent sees the last 80 lines on failure, and # a wall of "ok" would push the actual break out of view. A failing scenario is @@ -20,6 +23,13 @@ PY="${IDATUI_PYTHON:-$HOME/ida-venv/bin/python}" pure=$(python3 tests/run.py --fast 2>&1) || { echo "$pure" | tail -40; exit 1; } echo "$pure" | tail -2 +search=$("$PY" .auto/check_search.py targets/echo 2>&1) || { + echo "--- search fast paths disagree with the plain loop ---" + echo "$search" | tail -20 + exit 1 +} +echo "$search" | tail -1 + run_suite() { # $1 = optional --only filter if [ -n "${1:-}" ]; then "$PY" tests/test_scenarios.py targets/echo --only "$1" 2>&1 diff --git a/.auto/log.jsonl b/.auto/log.jsonl index d52098a..3aa42c4 100644 --- a/.auto/log.jsonl +++ b/.auto/log.jsonl @@ -14,3 +14,5 @@ {"run":12,"commit":"870f89e","metric":19062.1,"metrics":{"lg_boot_ms":751.3,"lg_decomp_ms":2605.7,"lg_graph_ms":1022,"lg_hex_ms":554.5,"lg_index_ms":103.9,"lg_listing_cold_ms":545,"lg_listing_warm_ms":405.6,"lg_nav_ms":6636.8,"lg_palette_ms":4.7,"lg_render_ms":212.2,"lg_search_ms":2269,"pure_graph_ms":239.9,"sm_boot_ms":538.5,"sm_decomp_ms":688.4,"sm_graph_ms":712.7,"sm_hex_ms":557.5,"sm_index_ms":0,"sm_listing_cold_ms":259.9,"sm_listing_warm_ms":260.2,"sm_nav_ms":363.8,"sm_palette_ms":0.3,"sm_render_ms":252.7,"sm_search_ms":77.5,"fails":0},"status":"keep","description":"Fetch a graph's listing rows from the blocks' MERGED EXTENTS, not their convex hull, and assign them per block by bisect. IDA puts a function's cold/tail chunks far from its entry, so the hull of a 1.4KB function could be 680KB wide: it fetched 128k rows, took 3s, and still came back EMPTY for the far blocks because the pager's 64-page bound ran out first. lg_graph 9561 -> 1022.","timestamp":1786062629380,"segment":1,"confidence":null,"asi":{"hypothesis":"Program.flowchart fetches the convex hull of the basic blocks, which is enormous for a function with IDA chunks","gains":"total 27913 -> 19062 (-31.7%); lg_graph 9561 -> 1022 (-89%); sm_graph 962 -> 713","correctness_is_BETTER_not_equal":"differential over the 80 largest functions of bash: 1201 blocks differ, and EVERY one of them is a block the old code returned ZERO rows for. _heads_between is bounded to 64 pages x 2000 heads = 128k, and a 680KB hull exhausted that before reaching the tail chunk -- so far blocks drew as empty boxes. No block lost a row. targets/echo: 0 differences at all (no chunked functions).","scale":"80 bash functions: hull 44.4s / 1,864,814 rows fetched -> intervals 1.4s / 77,221 rows. 24x less data, 31x faster.","design":"blocks are sorted and merged with a 256-byte tolerance so alignment padding does not split an interval; an ordinary contiguous function is still exactly ONE heads call, as before. Row->block assignment is now bisect on the address list instead of a full scan per block (541ms -> ~0 for the 12-function set).","verify_script":"/tmp/fceq.py (kept the pattern in .auto/ideas.md): rebuild the blocks twice, fetch both ways, compare per-block row lists","next_action_hint":"lg_nav 6637 is now 35% of the total and sits at the per-row floor (~16.5us worker + ~7us client). decomp lg+sm 3294 is next: 1370ms of Hex-Rays, 240ms of pygments highlight_c, ~600ms of Textual loading-cover churn. Also unmeasured by the bench: domain.decomp_map costs 280ms per function on the split-view path."}} {"run":13,"commit":"60f0d70","metric":18856.8,"metrics":{"lg_boot_ms":689.7,"lg_decomp_ms":2484.2,"lg_graph_ms":1120,"lg_hex_ms":700.1,"lg_index_ms":96.5,"lg_listing_cold_ms":425.6,"lg_listing_warm_ms":511.5,"lg_nav_ms":6590.7,"lg_palette_ms":4.8,"lg_render_ms":215.1,"lg_search_ms":2195.5,"pure_graph_ms":238.1,"sm_boot_ms":431.5,"sm_decomp_ms":667.2,"sm_graph_ms":686.9,"sm_hex_ms":569.8,"sm_index_ms":0,"sm_listing_cold_ms":258.1,"sm_listing_warm_ms":283.2,"sm_nav_ms":365.2,"sm_palette_ms":0.3,"sm_render_ms":243.7,"sm_search_ms":79,"fails":0},"status":"keep","description":"Two independent constants: memoise the pygments token -> Rich style lookup (a decompilation uses ~18 distinct token types but each token walked up to nine 'token in ttype' hierarchy checks), and hold the worker-connect poll at 5ms for the first 5s instead of backing off geometrically from the first probe.","timestamp":1786062907221,"segment":1,"confidence":44.11154408183163,"asi":{"hypothesis":"boot time is a polling artefact, and highlighting is a style-lookup problem not a lexing problem","boot_smoking_gun":"WorkerClient.connect() returned in 351ms for BOTH targets/echo (47KB) and targets/bash (1.2MB) -- an identical number for very different work is a polling artefact, not a cost. The geometric backoff (5ms x1.6, capped at 200ms) has reached 134ms by the time a seeded database is ready at ~250ms. Holding 5ms for 5s first: echo 351 -> 260ms. bash is genuinely ~350ms so it did not move.","highlight_split":"on a 40KB body: lexer 52.2ms, _style_for 19.4ms, rest ~10ms of 82ms total. Memoised _style_for is 1.2ms (18 distinct token types in the whole corpus). highlight_c 84.2 -> 62.0ms.","equivalence":"/tmp/hleq.py compares old vs new highlight_c segment-for-segment over 5 bodies x 4 slices including an empty string and hex-rays-shaped pseudocode: 0 mismatches","gains":"total 19062 -> 18857 (-1.1%); sm_boot 539 -> 432 (-20%); lg_boot 751 -> 690","rejected_this_round":"a per-page identity memo for the spans->tuple conversion in Head.from_raw. The worker's line cache does share span objects across rows, but the repeats are spread over the whole segment, not within a 500-row page, so a page-scoped memo never hits: _build_page 4.52 -> 4.68 us/row. A model-scoped memo is UNSAFE because id() is reused once the page's row dicts are collected.","next_action_hint":"lg_nav 6591 (35%) is at the per-row floor. Left: pygments lexing itself (52ms per 40KB), the ~600ms of Textual loading-cover churn across 12 F5s, and lg_search 2196 (two unnarrowed passes over 224k rows at ~2.6us each)."}} {"run":14,"commit":"2686901","metric":18618.9,"metrics":{"lg_boot_ms":689.6,"lg_decomp_ms":2560,"lg_graph_ms":1035.1,"lg_hex_ms":684.2,"lg_index_ms":100.4,"lg_listing_cold_ms":551.1,"lg_listing_warm_ms":397.8,"lg_nav_ms":6687.8,"lg_palette_ms":4.7,"lg_render_ms":211.9,"lg_search_ms":1858.2,"pure_graph_ms":238.5,"sm_boot_ms":431.4,"sm_decomp_ms":671.2,"sm_graph_ms":728.1,"sm_hex_ms":552,"sm_index_ms":0,"sm_listing_cold_ms":257.4,"sm_listing_warm_ms":281,"sm_nav_ms":365.9,"sm_palette_ms":0.3,"sm_render_ms":242.8,"sm_search_ms":69.6,"fails":0},"status":"keep","description":"Search the whole segment as ONE joined string. Every line is concatenated once (with a start-offset table) so finding a term is a C-level str.find walk instead of a python loop that rebuilds and case-folds 224k lines per keystroke. Falls back to the per-line loop if case-folding changes the string's length.","timestamp":1786063291908,"segment":1,"confidence":41.940433212996666,"asi":{"hypothesis":"per-keystroke search should be one C string scan over the body, not 224k python iterations","design":"SearchMixin._search_haystack builds (starts, blob, blob.lower()) once per (row count, line-source) and caches it; _compute_matches then walks it with str.find and advances a line pointer monotonically (find() only moves forward, so no bisect is needed). A term can never straddle a line because an Input cannot contain a newline.","safety":["str.lower() can CHANGE LENGTH for a few unicode codepoints, which would corrupt every offset after them -- if len differs the haystack is refused and the old per-line loop runs.","the per-line loop (with prefix narrowing) is kept as the fallback and is still exercised.","every site that resets _matches/_ranges now calls _reset_search_cache(), which drops the joined body too -- a stale body would search text the view no longer shows."],"equivalence_test":"/tmp/searcheq2.py drives the real ListingView AND DecompView over 13 terms x every prefix, comparing _matches and _ranges from the haystack path against the same view with _search_haystack monkeypatched to None: 0 mismatches on echo (5952 lines) and ls_ttl (28807 lines)","gains":"total 18857 -> 18619 (-1.3%); lg_search 2196 -> 1858 (-15%); sm_search 79 -> 70","why_not_more":"the bench types only two terms, so the one-off cost of building the body (a _line_plain pass over 224k rows, ~0.5s) is amortised over very little. The benefit compounds for a user who searches more than twice -- the third term onwards is milliseconds.","next_action_hint":"lg_nav 6688 is 36%. Client-side it is pickle.loads 2.4us + _build_page 4.5us per row, and it CANNOT be pipelined: idatui/worker.py's serve() accepts one connection at a time and idalib is main-thread only, so nothing overlaps. Try making Head a NamedTuple (tuple.__new__ vs a frozen dataclass __init__)."}} +{"run":15,"commit":"625b067","metric":17784.1,"metrics":{"lg_boot_ms":680.8,"lg_decomp_ms":2344,"lg_graph_ms":1106.5,"lg_hex_ms":576.4,"lg_index_ms":96.5,"lg_listing_cold_ms":552.9,"lg_listing_warm_ms":431.3,"lg_nav_ms":6256.4,"lg_palette_ms":4.8,"lg_render_ms":224.1,"lg_search_ms":1916.5,"pure_graph_ms":241.3,"sm_boot_ms":442.6,"sm_decomp_ms":591.3,"sm_graph_ms":663.9,"sm_hex_ms":419.2,"sm_index_ms":0,"sm_listing_cold_ms":258.2,"sm_listing_warm_ms":281.8,"sm_nav_ms":374.4,"sm_palette_ms":0.3,"sm_render_ms":251.1,"sm_search_ms":69.8,"fails":0},"status":"keep","description":"HexView.render_line emits style RUNS instead of one Segment per byte cell (35 -> 7 segments per row), and Head is a NamedTuple rather than a frozen dataclass (tuple.__new__ 1.9us vs a dataclass __init__ 2.9us, and it is built once per listing row walked).","timestamp":1786063768655,"segment":1,"confidence":42.57587221521688,"asi":{"hypothesis":"a hex frame costs ~10ms of which only 1.8ms is our render_line, so the cost is what we hand the compositor: 1540 one-cell Segments per frame","hex_change":"accumulate a run and flush it when the style changes. A row's 32 cells almost always share one style (the exception is the single cursor cell and trace-live bytes), so 35 segments/row became 7.","hex_equivalence":"/tmp/hexeq.py expands both the new and the pre-change render_line to (char, style) PER CELL and compares, over 40 scroll frames x 43 rows with the cursor moving through all 16 columns: 0 mismatches in 1720 rows. Comparing segments would have been the wrong test -- merging changes the segments on purpose; what must not change is the cells.","head_change":"Head is a NamedTuple. Measured 2.93 -> 1.88 us to build; attribute reads go 10ns -> 20ns, which is the right way round (a quarter-million rows are built per far jump, and a viewport reads forty). _build_page 4.68 -> 4.05 us/row. Nothing used dataclasses.replace/asdict on it.","gains":"total 18619 -> 17784 (-4.5%); hex lg+sm 1236 -> 996 (-19%); lg_nav 6688 -> 6256; lg_decomp 2560 -> 2344","rejected_this_round":"merging same-kind adjacent spans in _idatui_spans: measured only 2.5% fewer spans on 20k real lines (6.43 -> 6.27 per line). Not worth changing the wire format for.","rejected_earlier":"deferring the 'decompiling...' loading cover until ~120ms (worth ~9ms per F5) -- tests/test_scenarios.py asserts the overlay is raised SYNCHRONOUSLY by F5, guarding a real past regression. That is an assertion, not setup, so it stands.","next_action_hint":"lg_nav 6256 (35%) is the paging floor; the skeleton idea was costed and only nets ~5% because search then has to fetch the text anyway (see .auto/ideas.md). Left: ListingView.render_line segment count, and the search haystack building rows one lock at a time (model.window would amortise it)."}} +{"run":16,"commit":"625b067","metric":17821,"metrics":{"lg_boot_ms":743.4,"lg_decomp_ms":2349.8,"lg_graph_ms":1033.5,"lg_hex_ms":432.2,"lg_index_ms":97.4,"lg_listing_cold_ms":424.8,"lg_listing_warm_ms":508.5,"lg_nav_ms":6677.8,"lg_palette_ms":4.8,"lg_render_ms":223.1,"lg_search_ms":1374.9,"pure_graph_ms":523.7,"sm_boot_ms":438.6,"sm_decomp_ms":591.2,"sm_graph_ms":713.2,"sm_hex_ms":466.7,"sm_index_ms":0,"sm_listing_cold_ms":262.4,"sm_listing_warm_ms":287.1,"sm_nav_ms":376.6,"sm_palette_ms":0.3,"sm_render_ms":257.5,"sm_search_ms":33.5,"fails":0},"status":"discard","description":"Keep the joined search body across a cancelled search and across navigation inside the same segment (drop it only when the model changes or the opcode column toggles). lg_search 1917 -> 1375, sm_search 70 -> 34 — but total_ms is FLAT (17784 -> 17821) because pure_graph (+283) and lg_nav (+421) drifted, neither of which this touches. Re-running to separate the win from the drift.","timestamp":1786064057632,"segment":1,"confidence":16.322294738538417,"asi":{"hypothesis":"the joined search body is keyed by (row count, line source), so ending a search or navigating inside the same segment need not throw it away","phase_evidence":"lg_search -542ms and sm_search -36ms, i.e. the intended effect happened and is far outside that phase's own spread","why_total_did_not_move":"pure_graph 241 -> 524 and lg_nav 6256 -> 6678 in the same run, and this change touches neither. pure_graph is pure CPU with no I/O and is the single jumpiest metric in the suite (seen at 235, 241, 508, 524, 531 across runs with identical code).","work_preserved":".auto/wip-searchbody.patch","gate_strengthened":".auto/check_search.py added and wired into checks.sh: it drives the real ListingView/DecompView and compares the narrowing + haystack fast paths against the plain per-line loop for every prefix of 14 terms, including after toggling the opcode column and after navigating. 140 prefixes, 0 mismatches. A stale cache still returns AN answer, so the scenario suite could never have caught this class of bug.","invalidation_rules_now":["body dropped when ListingView.load gets a DIFFERENT model (navigation inside one segment reuses it)","body dropped by action_toggle_opcodes -- the opcode hex is searchable text and its width changes without the row count or model moving","body dropped by DecompView.show, explicitly rather than relying on id(self._texts), because the list it replaces is freed there and the address can be reused","narrowing key still cleared everywhere _matches/_ranges are reset"],"next_action_hint":"re-apply the patch and re-run to confirm; the phase number is unambiguous"}} diff --git a/.auto/wip-searchbody.patch b/.auto/wip-searchbody.patch new file mode 100644 index 0000000..f7d3ddb --- /dev/null +++ b/.auto/wip-searchbody.patch @@ -0,0 +1,78 @@ +diff --git a/idatui/app.py b/idatui/app.py +index 20aed88..53dd79b 100644 +--- a/idatui/app.py ++++ b/idatui/app.py +@@ -655,14 +655,24 @@ class SearchMixin: + pointed at a different model/body, which invalidates a narrowing.""" + return id(getattr(self, "model", None) or getattr(self, "_texts", None)) + +- def _reset_search_cache(self) -> None: +- """Forget both the narrowing key and the joined body. Called from every +- place that resets ``_matches``/``_ranges``: a stale prefix would make the +- next search narrow from an empty list, and a stale body would search +- text the view no longer shows.""" ++ def _reset_search_cache(self, body: bool = False) -> None: ++ """Forget the narrowing key, and with ``body=True`` the joined body too. ++ ++ Every place that resets ``_matches``/``_ranges`` must call this: a stale ++ prefix would make the next search narrow from an empty list. ++ ++ The body is a different question. It is keyed by (row count, line source) ++ so it invalidates itself when the view is pointed somewhere else or more ++ rows stream in — which means ending a search does NOT have to throw it ++ away, and the next `/` over the same segment is then instant instead of ++ re-joining a quarter of a million lines. It DOES have to go when the ++ plain text of a row changes without either of those moving, which is ++ exactly what toggling the opcode-bytes column does. ++ """ + self._matched_key = None +- self._hay_key = None +- self._hay = None ++ if body: ++ self._hay_key = None ++ self._hay = None + + def _search_haystack(self, count: int, src: int): + """``(starts, blob, blob_folded)`` for the whole body, or None. +@@ -1062,7 +1072,7 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru + def load(self, model: ListingModel, name: str, cursor: int = 0, + cursor_x: int = 0, scroll_y: int | None = None, + focus: str | None = None) -> None: +- self.model = model ++ previous, self.model = self.model, model + self._name = name + self.total = 0 + self.cursor = cursor +@@ -1072,7 +1082,9 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru + self._pending_op = None + self._matches = [] + self._ranges = {} +- self._reset_search_cache() ++ # Navigating inside the same segment reuses the same model, and the ++ # searchable body with it; only a different model invalidates it. ++ self._reset_search_cache(body=model is not previous) + self._prime() + + @work(thread=True, exclusive=True, group="listing-prime") +@@ -1147,7 +1159,8 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru + self._op_mode = (self._op_mode + 1) % 3 + self._update_op_w() + self._ranges = {} # column layout changed -> stale match offsets +- self._reset_search_cache() # ...and which rows match at all ++ # ...and which rows match at all: the opcode hex is searchable text. ++ self._reset_search_cache(body=True) + self._clamp_x() + self.refresh() + self._app_status("opcodes: " + {0: "off", 1: f"limited ({_OP_LIMIT} bytes)", +@@ -1583,7 +1596,10 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True + self.cursor_x = cursor_x + self._matches = [] + self._ranges = {} +- self._reset_search_cache() ++ # A whole new body: drop the joined haystack outright rather than trust ++ # id(self._texts) to differ, since the list it replaces is freed here and ++ # its address can be handed straight back. ++ self._reset_search_cache(body=True) + # Gutter wide enough for the largest line number + a trailing space. + self._gutter = (len(str(total)) + 1) if total else 0 + maxw = max((s.cell_length for s in self._strips), default=0) diff --git a/idatui/app.py b/idatui/app.py index 20aed88..53dd79b 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -655,14 +655,24 @@ class SearchMixin: pointed at a different model/body, which invalidates a narrowing.""" return id(getattr(self, "model", None) or getattr(self, "_texts", None)) - def _reset_search_cache(self) -> None: - """Forget both the narrowing key and the joined body. Called from every - place that resets ``_matches``/``_ranges``: a stale prefix would make the - next search narrow from an empty list, and a stale body would search - text the view no longer shows.""" + def _reset_search_cache(self, body: bool = False) -> None: + """Forget the narrowing key, and with ``body=True`` the joined body too. + + Every place that resets ``_matches``/``_ranges`` must call this: a stale + prefix would make the next search narrow from an empty list. + + The body is a different question. It is keyed by (row count, line source) + so it invalidates itself when the view is pointed somewhere else or more + rows stream in — which means ending a search does NOT have to throw it + away, and the next `/` over the same segment is then instant instead of + re-joining a quarter of a million lines. It DOES have to go when the + plain text of a row changes without either of those moving, which is + exactly what toggling the opcode-bytes column does. + """ self._matched_key = None - self._hay_key = None - self._hay = None + if body: + self._hay_key = None + self._hay = None def _search_haystack(self, count: int, src: int): """``(starts, blob, blob_folded)`` for the whole body, or None. @@ -1062,7 +1072,7 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru def load(self, model: ListingModel, name: str, cursor: int = 0, cursor_x: int = 0, scroll_y: int | None = None, focus: str | None = None) -> None: - self.model = model + previous, self.model = self.model, model self._name = name self.total = 0 self.cursor = cursor @@ -1072,7 +1082,9 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru self._pending_op = None self._matches = [] self._ranges = {} - self._reset_search_cache() + # Navigating inside the same segment reuses the same model, and the + # searchable body with it; only a different model invalidates it. + self._reset_search_cache(body=model is not previous) self._prime() @work(thread=True, exclusive=True, group="listing-prime") @@ -1147,7 +1159,8 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru self._op_mode = (self._op_mode + 1) % 3 self._update_op_w() self._ranges = {} # column layout changed -> stale match offsets - self._reset_search_cache() # ...and which rows match at all + # ...and which rows match at all: the opcode hex is searchable text. + self._reset_search_cache(body=True) self._clamp_x() self.refresh() self._app_status("opcodes: " + {0: "off", 1: f"limited ({_OP_LIMIT} bytes)", @@ -1583,7 +1596,10 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True self.cursor_x = cursor_x self._matches = [] self._ranges = {} - self._reset_search_cache() + # A whole new body: drop the joined haystack outright rather than trust + # id(self._texts) to differ, since the list it replaces is freed here and + # its address can be handed straight back. + self._reset_search_cache(body=True) # Gutter wide enough for the largest line number + a trailing space. self._gutter = (len(str(total)) + 1) if total else 0 maxw = max((s.cell_length for s in self._strips), default=0) |
