aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-07 02:00:06 +0200
committeruser <user@clank>2026-08-07 02:00:06 +0200
commit5045ba1747574165cc18de6b5f7f28a6e6baa711 (patch)
treea38e54f07d27e3bce66ec1a303277dc22e9edfd1
parentRe-apply #5 (lru_cache on the per-line render + Heads built with their opcode... (diff)
downloadida-tui-5045ba1747574165cc18de6b5f7f28a6e6baa711.tar.gz
ida-tui-5045ba1747574165cc18de6b5f7f28a6e6baa711.tar.xz
ida-tui-5045ba1747574165cc18de6b5f7f28a6e6baa711.zip
Incremental search narrows instead of rescanning. Typing a character onto the term can only remove lines (a line holding "mov" holds "mo"), so _compute_matches rescans the previous hit list when the term grew and nothing else moved. Keyed on (term, case-fold, row count, line-source id) so a listing still streaming rows in behind the search falls back to a full scan.
Result: {"status":"keep","total_ms":20835.7,"lg_boot_ms":754.4,"lg_decomp_ms":2593.7,"lg_graph_ms":929.5,"lg_hex_ms":1080.2,"lg_index_ms":76.1,"lg_listing_cold_ms":526.7,"lg_listing_warm_ms":410.1,"lg_nav_ms":6674.8,"lg_palette_ms":5,"lg_render_ms":225.2,"lg_search_ms":3310.3,"pure_graph_ms":239.3,"sm_boot_ms":537.9,"sm_decomp_ms":630.1,"sm_graph_ms":734.3,"sm_hex_ms":869.9,"sm_index_ms":0,"sm_listing_cold_ms":260.8,"sm_listing_warm_ms":262.3,"sm_nav_ms":334.8,"sm_palette_ms":0.3,"sm_render_ms":268.4,"sm_search_ms":111.6,"fails":0}
-rw-r--r--.auto/ideas.md32
-rw-r--r--.auto/log.jsonl1
-rw-r--r--idatui/app.py65
3 files changed, 84 insertions, 14 deletions
diff --git a/.auto/ideas.md b/.auto/ideas.md
new file mode 100644
index 0000000..8d049e3
--- /dev/null
+++ b/.auto/ideas.md
@@ -0,0 +1,32 @@
+# Ideas backlog
+
+## Perf (not yet tried)
+
+- **Skeleton walk for `ensure_ea`.** Navigation only needs the *row index* of an
+ address, yet `ListingModel` walks the segment loading fully-rendered rows. A
+ `skeleton=true` mode on the `heads` tool returning `(ea, size, kind)` and the
+ banner/label/member row COUNTS (which is what makes row indices line up)
+ would drop the walk from ~18 µs/row to maybe 4. The catch: whatever needs the
+ text later (search's `load_all`) then pays it instead, so the win is real only
+ if the text streams in the background while the user reads. Big change to
+ `ListingModel`; do it only after the cheaper things are exhausted.
+- **`_grow` should start from where the user is**, not run one linear sweep, so
+ a jump into the middle of a binary doesn't wait for everything before it.
+- **`heads` could return columnar arrays** instead of one dict per row
+ (`{"ea": [...], "kind": [...], "text": [...]}`). Fewer objects to pickle and
+ unpickle; `Head.from_raw` currently costs ~2.5 µs/row on the client.
+- **Persist the line cache.** `_idatui_line_parts` is warm only within one
+ worker; the same binary is reopened constantly during a session.
+- **`decompile` on adjacent functions** could be prefetched while the user
+ reads the current one.
+
+## Bugs noticed while optimising (not perf work)
+
+- **Sticky graph mode makes a keypress ambiguous.** With `_graph_sticky` on, a
+ navigation schedules the next function's graph asynchronously; until it lands
+ the app is in the listing. So `space` pressed right after a jump either enters
+ or leaves the graph depending on which won. `tests/test_scenarios.py`
+ `graph_minimap` was silently relying on losing that race (fixed in the
+ scenario's setup in experiment #6, but the app behaviour is still ambiguous).
+ A fix would be to enter graph mode immediately with a loading state when a
+ sticky navigation starts.
diff --git a/.auto/log.jsonl b/.auto/log.jsonl
index 58754b0..3960982 100644
--- a/.auto/log.jsonl
+++ b/.auto/log.jsonl
@@ -4,3 +4,4 @@
{"run":3,"commit":"93240e2","metric":26923.9,"metrics":{"lg_boot_ms":762.2,"lg_decomp_ms":2631.7,"lg_graph_ms":941.8,"lg_hex_ms":1052,"lg_index_ms":67.2,"lg_listing_cold_ms":440.6,"lg_listing_warm_ms":530.5,"lg_nav_ms":10598.3,"lg_palette_ms":4.6,"lg_render_ms":227.8,"lg_search_ms":5265.5,"pure_graph_ms":237.9,"sm_boot_ms":535.3,"sm_decomp_ms":631.8,"sm_graph_ms":702.1,"sm_hex_ms":841.1,"sm_index_ms":0,"sm_listing_cold_ms":268.2,"sm_listing_warm_ms":269.4,"sm_nav_ms":443.6,"sm_palette_ms":0.3,"sm_render_ms":277.8,"sm_search_ms":194.4,"fails":0},"status":"keep","description":"Stop ida-pro-mcp installing a sys.setprofile hook around every tool call. Its deadline mechanism profiles every python call/return so a pure-python tool loop can be interrupted; our tools are call-heavy, so it taxed the whole backend 3.3x. Worker now sets IDA_MCP_TOOL_TIMEOUT_SEC=0 and arms the deadline itself with one polling watchdog thread + ida_kernwin.set_cancelled() (the half that actually frees the IDA main thread). Also rewrote _idatui_spans to jump between colour tags instead of walking characters (byte-identical over 258k real lines).","timestamp":1786059117542,"segment":0,"confidence":173.264550264548,"asi":{"hypothesis":"the heads tool is not IDA-bound; the ida-pro-mcp sync wrapper's sys.setprofile deadline is the tax","evidence":"in-process A/B on targets/bash: heads(count=500,annotate) 92.2us/row with IDA_MCP_TOOL_TIMEOUT_SEC unset vs 27.5us/row with it 0; domain ListingModel._load_next_page 116 -> 37 us/row","where_it_lives":"site-packages/ida_pro_mcp/ida_mcp/sync.py sync_wrapper() installs profilefunc via sys.setprofile whenever timeout>0; default _DEFAULT_TOOL_TIMEOUT_SEC=60","what_we_kept":"the native half: threading watchdog + ida_kernwin.set_cancelled(), which is what decompile/auto_wait/find_bytes actually poll. Only the interruption of a pure-python loop inside a tool body is gone, and every idatui tool loop is bounded by its count argument (<=2000).","watchdog_design":"polling at 0.25s instead of signalling, because arm/disarm is on the path of EVERY worker call (a scroll is dozens); arming is two attribute writes + clr_cancelled()","spans_rewrite":"_idatui_spans now re.finditer's over the 3 control chars instead of walking characters. Verified byte-identical vs HEAD over 258k real disasm lines across echo/ls_ttl/bash/libcrypto (.auto/diff_spans.py). Standalone it measured 12.5 -> 13.6 us/line i.e. slightly SLOWER, so it is probably a small net loss that the setprofile win is hiding -- A/B it on its own next.","gains":"total 46572 -> 26924 (-42%); lg_nav 29107 -> 10598; lg_nav_worst 28274 -> 10170; sm_nav_worst 656 -> 224; lg_index 219 -> 67","work_unchanged":"nav_rows/decomp_ok/graph_ok/search_hits/render_cells all identical -> the speedup is not less work","next_action_hint":"1) A/B the _idatui_spans rewrite alone (suspect regression). 2) lg_nav is still 10.6s: ListingModel.ensure_ea is O(distance) at ~34us/row; a skeleton (ea,size,kind only) walk or a bigger PAGE would cut it further. 3) lg_search_ms 5265 is now the #2 term."}}
{"run":4,"commit":"b6a95ec","metric":26050.2,"metrics":{"lg_boot_ms":727.4,"lg_decomp_ms":2618.1,"lg_graph_ms":935.1,"lg_hex_ms":954.2,"lg_index_ms":67.4,"lg_listing_cold_ms":459.3,"lg_listing_warm_ms":545.7,"lg_nav_ms":9257.8,"lg_palette_ms":4.7,"lg_render_ms":238.8,"lg_search_ms":5472.5,"pure_graph_ms":530.7,"sm_boot_ms":534.6,"sm_decomp_ms":635.8,"sm_graph_ms":748.1,"sm_hex_ms":858.7,"sm_index_ms":0,"sm_listing_cold_ms":268.7,"sm_listing_warm_ms":269.2,"sm_nav_ms":441.6,"sm_palette_ms":0.3,"sm_render_ms":281.7,"sm_search_ms":200,"fails":0},"status":"keep","description":"_idatui_spans: one capturing re.split over the tag pairs instead of finditer+char-slicing, and collapse whitespace with ' '.join(txt.split()) instead of a regex sub. 13.15 -> 11.07 us/line (the previous finditer attempt was 14.4, i.e. SLOWER than the original char loop it replaced).","timestamp":1786059375617,"segment":0,"confidence":2.0769472107521656,"asi":{"hypothesis":"the span walker can beat the original char loop if the tokenisation is one C-level split and the whitespace collapse avoids re.sub","microbench_us_per_line":{"original_char_loop":13.15,"finditer_attempt":14.42,"re.split_version":11.07},"lesson":"re.finditer per tag is SLOWER than a plain character loop -- Match objects and .start() calls cost more than the ~54 trivial loop iterations they replace. A single capturing re.split that hands back [text, tag, text, ...] is what actually wins.","lesson2":"re.sub for whitespace collapse cost ~1us per call at ~6.5 calls/line; ' '.join(txt.split()) splits on exactly str.isspace() and is far cheaper. Leading/trailing space has to be re-attached by hand to keep cross-span runs collapsing the same way.","equivalence":"0 mismatches vs the pre-autoresearch implementation over 258k real disasm lines on echo/ls_ttl/bash/libcrypto (.auto/diff_spans.py --ref 2b0ae8d)","gains":"total 26924 -> 26050 (-3.2%); lg_nav 10598 -> 9258; lg_nav_worst 10170 -> 8869","work_unchanged":"every NOTES counter identical","next_action_hint":"lg_nav 9.3s and lg_search 5.5s are now the top two. For nav: ListingModel walks 500 heads/call at ~25us/row and the ROW TEXT is entirely wasted when the walk is only trying to reach an address -- a skeleton (ea,size,kind) mode on the heads tool would make ensure_ea nearly free. For search: _compute_matches/_line_plain over 224k rows."}}
{"run":5,"commit":"b6a95ec","metric":23259.6,"metrics":{"lg_boot_ms":752.7,"lg_decomp_ms":2475.3,"lg_graph_ms":963,"lg_hex_ms":950.4,"lg_index_ms":70.8,"lg_listing_cold_ms":533.2,"lg_listing_warm_ms":406.1,"lg_nav_ms":6862.6,"lg_palette_ms":4.7,"lg_render_ms":228.9,"lg_search_ms":5401.5,"pure_graph_ms":510.5,"sm_boot_ms":538.8,"sm_decomp_ms":666.7,"sm_graph_ms":715.7,"sm_hex_ms":856.8,"sm_index_ms":0,"sm_listing_cold_ms":260.3,"sm_listing_warm_ms":262.5,"sm_nav_ms":335.5,"sm_palette_ms":0.3,"sm_render_ms":270.8,"sm_search_ms":192.5,"fails":0},"status":"checks_failed","description":"Memoise per-line rendering in the worker (lru_cache on a new _idatui_line_parts) + build listing Heads with their opcode bytes already attached instead of dataclasses.replace-ing them in. total 26050 -> 23260, lg_nav 9258 -> 6863. Reverted: 3 graph_minimap checks fail -- but the cause is a RACE IN THE SCENARIO that the speedup wins, not a functional regression (proved below).","timestamp":1786060327739,"segment":0,"confidence":5.600496684223448,"asi":{"hypothesis":"cache the per-line render (tagged line -> text/spans/ops) in the worker, and stop double-constructing Heads client-side","change_A":"server/patch_server.py: new _idatui_line_parts(line) = (text, spans, ops), functools.lru_cache(16384). bash: 196618 listing lines are only 53363 distinct, so hit rate is ~70% and cost falls 10.4 -> 3.9 us/line. Bonus: pickle memoises the shared span lists so pages serialise smaller.","change_B":"idatui/domain.py: ListingModel._build_page reads the code extent FIRST and passes raw into Head.from_raw, replacing _attach_opcode_bytes' dataclasses.replace (which re-ran __init__ per code head). from_raw now uses tuple(map(tuple,...)) instead of a coercing genexpr.","measured":"cold ListingModel paging 35.7 -> 25.7 us/row; PAGE size (500/1000/2000) makes NO difference, do not bother tuning it","failure_root_cause":"tests/test_scenarios.py graph_minimap. _open_graph() leaves _graph_sticky=True; the scenario then does c.open(big,'listing') and presses space expecting to ENTER the graph. With sticky on, the navigation itself schedules _load_graph, and if that async load lands before the space press then space LEAVES graph mode instead -> the following 60s wait times out (scenario 1.9s -> 65.5s) and every minimap click lands on a hidden widget.","proof":"/tmp/mmrace.py drives the same steps and prints _active right before the space press: NEW code 'after open(big): active=graph', OLD code 'active=listing'. Bisected: stashing idatui/domain.py alone still fails, stashing server/patch_server.py alone passes -> it is purely the speedup winning the race, no behaviour changed.","equivalence_evidence":"diff_spans.py now compares _idatui_head_row too (whole row dict, not just spans): 0 mismatches over 118k lines on bash/echo/ls_ttl vs pre-autoresearch HEAD 2b0ae8d","work_preserved":".auto/wip-headcache.patch holds the reverted diff","next_action_hint":"re-apply the patch and make the graph_minimap SETUP deterministic (clear _graph_sticky before the second navigation). Assertions untouched; graph_sticky scenario already covers sticky behaviour. Record the amended tests/ rule in .auto/prompt.md."}}
+{"run":6,"commit":"cf45e11","metric":22980.2,"metrics":{"lg_boot_ms":738.2,"lg_decomp_ms":2401.8,"lg_graph_ms":944.1,"lg_hex_ms":920.6,"lg_index_ms":75.2,"lg_listing_cold_ms":538.5,"lg_listing_warm_ms":411.1,"lg_nav_ms":6813.9,"lg_palette_ms":4.9,"lg_render_ms":221.8,"lg_search_ms":5630.1,"pure_graph_ms":240.7,"sm_boot_ms":537.5,"sm_decomp_ms":595.1,"sm_graph_ms":715.7,"sm_hex_ms":858.8,"sm_index_ms":0,"sm_listing_cold_ms":263.3,"sm_listing_warm_ms":265.3,"sm_nav_ms":335.2,"sm_palette_ms":0.3,"sm_render_ms":271.4,"sm_search_ms":196.5,"fails":0},"status":"keep","description":"Re-apply #5 (lru_cache on the per-line render + Heads built with their opcode bytes already attached) with the graph_minimap scenario's racy SETUP made deterministic: clear _graph_sticky before the second navigation so Space is known to be entering the graph, not leaving it. No assertion changed.","timestamp":1786060531752,"segment":0,"confidence":7.006489167396753,"asi":{"hypothesis":"the graph_minimap failure in #5 was a racy scenario setup, not lost functionality","proof_a_bisect":"stashing idatui/domain.py alone still failed; stashing server/patch_server.py alone passed -> the flip is caused purely by the backend getting faster","proof_b_race":"/tmp/mmrace.py replays the scenario's steps outside the suite and prints _active just before the Space press: NEW 'active=graph', OLD 'active=listing'. Same steps, two states. With sticky on, the navigation itself schedules _load_graph; whether it lands before the keypress decides whether Space enters or leaves graph mode.","proof_c_both_ways":"the repaired scenario passes on the fast code AND on the stashed slow code (graph_minimap + graph_sticky, 15 passed 0 failed)","test_edit_scope":"two setup lines (app._graph_sticky = False; wait for _active == listing). Every c.check is byte-identical. graph_sticky scenario still covers sticky navigation.","real_bug_noted":"there IS a genuine UX wart underneath: with sticky graph mode on, a keypress right after a navigation means something different depending on whether the async graph reload has landed. Out of scope for perf work -> .auto/ideas.md","gains":"total 26050 -> 22980 (-11.8%); lg_nav 9258 -> 6814; lg_nav_worst 8869 -> 6613; sm_nav_worst 213 -> 128; cumulative vs baseline -50.7%","work_unchanged":"every NOTES counter identical to baseline","next_action_hint":"lg_search_ms 5630 is now the largest single term after lg_nav 6814. Search runs _compute_matches + _line_plain over 224k rows client-side; profile SearchMixin._compute_matches / ListingView._line_plain next."}}
diff --git a/idatui/app.py b/idatui/app.py
index 61f366e..0af47c0 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -629,6 +629,17 @@ class SearchMixin:
_matches: list[int]
_ranges: dict[int, list[tuple[int, int]]]
+ #: What ``_matches`` was last computed from: (term, case-fold, row count,
+ #: id(line source)). Lets an as-you-type search narrow the previous hits
+ #: instead of rescanning the segment; see :meth:`_compute_matches`. Any of
+ #: those changing under us discards it.
+ _matched_key: tuple | None = None
+
+ def _search_source_id(self) -> int:
+ """Identity of whatever supplies the line text. Changes when the view is
+ pointed at a different model/body, which invalidates a narrowing."""
+ return id(getattr(self, "model", None) or getattr(self, "_texts", None))
+
# --- hooks a subclass implements ---
def _search_line_count(self) -> int:
raise NotImplementedError
@@ -660,6 +671,7 @@ class SearchMixin:
self._term = ""
self._matches = []
self._ranges = {}
+ self._matched_key = None
self.cursor = getattr(self, "_search_origin", self.cursor)
self.cursor_x = getattr(self, "_search_origin_x", self.cursor_x)
self.refresh()
@@ -698,6 +710,7 @@ class SearchMixin:
self._term = ""
self._matches = []
self._ranges = {}
+ self._matched_key = None
self.cursor = getattr(self, "_search_origin", self.cursor)
self.cursor_x = getattr(self, "_search_origin_x", self.cursor_x)
self.scroll_to(y=max(self.cursor - self._visible_height() // 2, 0), animate=False)
@@ -715,26 +728,46 @@ class SearchMixin:
def _compute_matches(self) -> None:
term = self._term
- needle = term.lower() if getattr(self, "_ci", True) else term
+ ci = getattr(self, "_ci", True)
+ needle = term.lower() if ci else term
+ count = self._search_line_count()
+ src = self._search_source_id()
+ # Typing forward can only ever REMOVE lines: a line holding "mov" holds
+ # "mo". So when the term just grew (and nothing else moved -- same
+ # case-folding, same body, same number of rows) rescan only the previous
+ # hits. Search is driven a keystroke at a time, and a segment of bash is
+ # 224k rows; this is the difference between rescanning all of them per
+ # keypress and looking at a few thousand.
+ #
+ # The row count is part of the key because the listing streams in behind
+ # the search: rows that arrived after the last pass have never been
+ # looked at, and narrowing would silently never find them.
+ rows: object = range(count)
+ prev = self._matched_key
+ if (prev is not None and prev[2] == count and prev[3] == src
+ and prev[1] == ci and term.startswith(prev[0]) and prev[0]):
+ rows = self._matches
matches: list[int] = []
ranges: dict[int, list[tuple[int, int]]] = {}
- for i in range(self._search_line_count()):
- s = self._search_line_text(i)
+ text_of = self._search_line_text
+ n = len(term)
+ for i in rows:
+ s = text_of(i)
if not s:
continue
- hay = s.lower() if getattr(self, "_ci", True) else s
- pos, rs = 0, []
- while True:
- j = hay.find(needle, pos)
- if j < 0:
- break
- rs.append((j, j + len(term)))
- pos = j + len(term)
- if rs:
- matches.append(i)
- ranges[i] = rs
+ hay = s.lower() if ci else s
+ j = hay.find(needle)
+ if j < 0:
+ continue
+ rs = []
+ while j >= 0:
+ rs.append((j, j + n))
+ j = hay.find(needle, j + n)
+ matches.append(i)
+ ranges[i] = rs
self._matches = matches
self._ranges = ranges
+ self._matched_key = (term, ci, count, src)
def search_repeat(self, direction: int, include_current: bool = False) -> None:
if not getattr(self, "_term", ""):
@@ -768,6 +801,7 @@ class SearchMixin:
self._term = ""
self._matches = []
self._ranges = {}
+ self._matched_key = None
self.refresh()
def _match_style(self, idx: int) -> Style:
@@ -949,6 +983,7 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
self._pending_op = None
self._matches = []
self._ranges = {}
+ self._matched_key = None
self._prime()
@work(thread=True, exclusive=True, group="listing-prime")
@@ -1023,6 +1058,7 @@ 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._matched_key = None # ...and which rows match at all
self._clamp_x()
self.refresh()
self._app_status("opcodes: " + {0: "off", 1: f"limited ({_OP_LIMIT} bytes)",
@@ -1458,6 +1494,7 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
self.cursor_x = cursor_x
self._matches = []
self._ranges = {}
+ self._matched_key = None
# 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)