diff options
| author | blasty <blasty@local> | 2026-07-28 15:16:34 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-28 15:16:34 +0200 |
| commit | d1deca0826a9e04da195ba3eeb344cd18d38ea17 (patch) | |
| tree | 7e2d6a8504b1b138081a99924d45b80e730007d8 | |
| parent | trace: a step in split view moves the listing cursor to the pc (diff) | |
| download | ida-tui-d1deca0826a9e04da195ba3eeb344cd18d38ea17.tar.gz ida-tui-d1deca0826a9e04da195ba3eeb344cd18d38ea17.tar.xz ida-tui-d1deca0826a9e04da195ba3eeb344cd18d38ea17.zip | |
trace: stop the decompiler thrashing during a step (and correct the record)
I blamed decomp_map in the last commit. It was innocent: called directly it
returns 769 lines, 475 with addresses, for exactly the function I said it
returned four for. The four-line map belonged to a PLT stub the decompiler had
momentarily switched to, and I sampled mid-bounce.
The actual fault: _seek_split decided "has execution left the decompiled
function?" from _split_range, which is maintained by a guarded async path
(_apply_split_map drops its result if _cur moved while in flight) and therefore
lags during stepping. A stale range made every step look like a function change,
so the decompiler bounced main -> stub -> main, each bounce paying a synchronous
769-line map fetch on the UI thread.
Now the decision comes from the map the trail painting already holds, keyed to
what the decompiler currently HAS loaded. The bouncing is gone — three map
fetches across twelve steps instead of one per step — and the pseudocode cursor
follows every instruction the decompiler attributes to a line, including across
a call into another function.
What it does NOT do: guess. Roughly half of a function's instructions have no
line attributed, and the obvious fallback (nearest mapped address at or before
the pc) is unsound — C lines are not monotonic in address, and it put an
instruction early in main on line 708, "sub_2040();", near the end. The cursor
waits instead; the trail still marks where you are.
tests: +1 trace UI (26) — over ~28 steps, every instruction that IS mapped is
followed by the pseudocode cursor. 212/0 scenarios.
TODO corrected: the entry blaming decomp_map now says what actually happened,
including that _split_ea2line/_split_range are still fed by the laggy path and
remain a latent issue for the split view's own sync.
| -rw-r--r-- | TODO | 44 | ||||
| -rw-r--r-- | idatui/app.py | 26 | ||||
| -rw-r--r-- | tests/test_trace_ui.py | 21 |
3 files changed, 63 insertions, 28 deletions
@@ -89,31 +89,27 @@ can't be trusted to accuse the code. tests/test_blob_ui.py builds a throwaway binary; test_project_ui.py stages copies; test_thumb_ui.py deletes the .i64 before each phase because the T flag and segment bitness are SAVED in it. -## decomp_map returns almost nothing for some functions (blocks split stepping) +## decomp_map was NOT the problem (corrected) -Found while making a trace step move BOTH cursors in split view. For cat's -main() — 700+ pseudocode lines — decomp_map() came back with FOUR entries, so -almost no instruction address maps to a C line and the pseudocode cursor can't -follow the program counter. On echo's main the same call maps plenty (the trail -painting test relies on it), so it is function- or timing-dependent, not simply -broken. +I recorded here that decomp_map returned four entries for cat's main and blamed +the tool. It doesn't: called directly it returns 769 lines, 475 with addresses, +for exactly that function. The four-line map belonged to a PLT stub the +decompiler had momentarily switched to, sampled mid-bounce. -Two things to separate when picking this up: +The real fault was the resync decision in _seek_split using _split_range, which +is maintained by a guarded async path and lags. A stale range made every step +look like a function change, so the decompiler thrashed +(main -> stub -> main), each bounce paying a synchronous 769-line map fetch on +the UI thread. Fixed by deciding from the map the trail painting already holds, +which is keyed to what the decompiler currently HAS loaded. -* Is the map itself sparse (the tool's item.dstr() walk missing most items for - this function), or is it being FETCHED at a moment when the decompiler has a - different function loaded? _trail_map_ea and dec.loaded_ea both read 0x24a0 - when it happened, which argues for the former. -* dec.goto(96) left dec.cursor at 0 in the same run. That may be a symptom of - the same confusion (a stale/short _strips) or an independent bug. Check it on - its own before assuming. +Still true and worth knowing: the decompiler attributes only about half of a +function's instructions to a line, so the pseudocode cursor moves on those and +waits on the rest. The tempting fallback — nearest mapped address at or before +the pc — is UNSOUND: C lines are not monotonic in address, and it resolved an +instruction early in main to a line near the end of the function. -Also noticed: _split_ea2line is refreshed by a guarded async path -(_apply_split_map drops its result if _cur moved while in flight). A burst of -trace steps moves _cur constantly, so during stepping it is frequently the map -of the function you just left. _place_decomp_at now prefers the trail's map for -that reason, but the split view's own sync still uses the laggy one. - -Consequence today: in split view a trace step moves the LISTING cursor onto the -current instruction (works, tested) but the pseudocode cursor only follows when -the map happens to cover that address. +Left alone: _split_ea2line and _split_range are still fed by that guarded path, +so the split view's own sync can still work from the map of the function you +just left. It has not caused a visible problem outside stepping, but it is the +same latent issue. diff --git a/idatui/app.py b/idatui/app.py index 76d5441..d44cc20 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -3249,6 +3249,8 @@ class IdaTui(App): self._trail_map_ea = None self._pending_trace_line = None # step waiting on a re-decompile self._trail_line_of: dict[int, int] = {} # ea -> pseudocode line + self._trail_span = None # ea span of that function + self._trail_eas: list[int] = [] # sorted keys of _trail_line_of self._t = 0 # current timestamp in that trace self._do_keepalive = keepalive self._rpc_path = rpc_path @@ -5529,10 +5531,16 @@ class IdaTui(App): lst.cursor = row lst._scroll_cursor_into_view() - rng = self._split_range - if rng is None or not (rng[0] <= pc <= rng[1]): - # Execution left the decompiled function. Load the new one, and - # remember where to land once its line map exists. + # Has execution actually left the decompiled function? Ask the map the + # trail painting keeps, which is keyed to what the decompiler currently + # HOLDS. _split_range comes from the guarded async path and lags, so a + # stale one made every step look like a function change: the decompiler + # bounced main -> PLT stub -> main, each bounce costing a synchronous + # 769-line map fetch on the UI thread. + span = self._trail_span + inside = (pc in self._trail_line_of + or (span is not None and span[0] <= pc <= span[1])) + if not inside: self._pending_trace_line = pc self._resync_decomp_async(pc) return True @@ -5552,6 +5560,13 @@ class IdaTui(App): dec = self.query_one(DecompView) line = None if self._trail_map_ea == dec.loaded_ea and self._trail_line_of: + # EXACT match only. The decompiler doesn't attribute every + # instruction to a line (about half of main's aren't), and the + # tempting fallback — the nearest mapped instruction at or before + # the pc — is unsound: C lines are not monotonic in address, so + # 0x24a8 early in main resolved to line 708, "sub_2040();", near the + # end. A cursor that jumps to an unrelated statement is worse than + # one that waits; the trail still marks where we are. line = self._trail_line_of.get(pc) if line is None: line = self._split_ea2line.get(pc) @@ -5616,6 +5631,9 @@ class IdaTui(App): for i, eas in enumerate(self._trail_map or []): for a in eas: self._trail_line_of.setdefault(a, i) + self._trail_eas = sorted(self._trail_line_of) + self._trail_span = ((self._trail_eas[0], self._trail_eas[-1]) + if self._trail_eas else None) rank = {"future": 0, "past": 1, "now": 2} lines: dict[int, str] = {} for i, eas in enumerate(self._trail_map or []): diff --git a/tests/test_trace_ui.py b/tests/test_trace_ui.py index 7ffe20f..5d620a9 100644 --- a/tests/test_trace_ui.py +++ b/tests/test_trace_ui.py @@ -231,6 +231,27 @@ async def run() -> int: lst.trail.get(t.ip(app._t)) == "now", f"{lst.trail.get(t.ip(app._t))}") + # The pseudocode cursor follows too — but only for instructions + # the decompiler actually attributes to a line. About half + # aren't, and the tempting fallback (nearest mapped address at + # or before the pc) is unsound because C lines are not monotonic + # in address: an early instruction resolved to a line near the + # END of the function. Better to wait than to jump somewhere + # unrelated. + dec = app.query_one(DecompView) + mapped = missed = 0 + for k in range(2, 30): + app._seek(base + k) + await pilot.pause(0.3) + pc = t.ip(app._t) + if app._trail_map_ea == dec.loaded_ea and pc in app._trail_line_of: + mapped += 1 + if dec.cursor != app._trail_line_of[pc]: + missed += 1 + check("the pseudocode cursor follows every mapped instruction", + mapped > 3 and missed == 0, + f"{mapped} mapped, {missed} not followed") + print(f"\n{PASS} passed, {FAIL} failed") return 1 if FAIL else 0 |
