<feed xmlns='http://www.w3.org/2005/Atom'>
<title>ida-tui.git/tests/test_scenarios.py, branch pre-codemode</title>
<subtitle>tui for headless ida</subtitle>
<id>https://git.sl0p.foo/ida-tui.git/atom/tests/test_scenarios.py?h=pre-codemode</id>
<link rel='self' href='https://git.sl0p.foo/ida-tui.git/atom/tests/test_scenarios.py?h=pre-codemode'/>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/'/>
<updated>2026-08-06T23:55:31Z</updated>
<entry>
<title>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.</title>
<updated>2026-08-06T23:55:31Z</updated>
<author>
<name>user</name>
<email>user@clank</email>
</author>
<published>2026-08-06T23:55:31Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=cf45e115bcd137020002e84b67da7b00547901b5'/>
<id>urn:sha1:cf45e115bcd137020002e84b67da7b00547901b5</id>
<content type='text'>
Result: {"status":"keep","total_ms":22980.2,"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}
</content>
</entry>
<entry>
<title>diag: somewhere for swallowed errors to go</title>
<updated>2026-08-06T22:33:28Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-08-06T22:33:12Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=2b0ae8df463edb597007186688ee557c18f1ac18'/>
<id>urn:sha1:2b0ae8df463edb597007186688ee557c18f1ac18</id>
<content type='text'>
A TUI must not die because one background load failed, so this codebase catches
broadly -- ~50 `except Exception` sites, two dozen resolving to `pass`. Right
policy, one bad consequence: with 44 `@work(thread=True)` workers, a failure in
a background load leaves no trace whatsoever. The view stays empty and there is
nothing to read afterwards, because the app owns the screen.

kittygfx already solved this for itself with $IDATUI_KITTY_LOG. idatui/diag.py
is the same idea for everything else: $IDATUI_LOG writes every swallowed error
plus its traceback to a file, and the last 50 are kept in memory regardless so a
driver can ask a live app what went wrong. Unset, it costs an environ lookup.

Wired in where losing the error changes a DECISION rather than just a pixel:

  * rename: a resolve() that throws renames as DATA instead of as a function.
  * name: a function_of() that throws means we never learn the address is a
    function start, so the index keeps the old name and every readback says the
    rename didn't happen.
  * retype: a resolve() that throws retypes the ENCLOSING function instead.
  * decompile: a failed full-body fetch silently returns CLIPPED pseudocode.
  * trail: a failed decomp_map stops the pseudocode being painted, silently.

Deliberately NOT wired into the query_one guards -- a modal owning the screen is
normal and constant, and logging it would bury the real entries in noise.

New RPC verb `diag {n?, clear?}`, documented in docs/RPC.md: the answer to "the
verb reported success and the pane shows nothing".

Also a flake, same shape as the others: follow_xrefs waited on the nav depth but
asserted on _cur, and a follow pushes the source entry BEFORE opening the
target -- so the check could run in between and see the function it jumped
from. About one run in ten. It waits on the postcondition it asserts now; three
clean full runs since.

833 checks; --fast is 344 in 3.5s.
</content>
</entry>
<entry>
<title>app: the view mode is a type, and 'disasm' is gone</title>
<updated>2026-08-06T22:07:22Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-08-06T22:07:22Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=cefdeb88ab3271313db741c9c95677178b18d484'/>
<id>urn:sha1:cefdeb88ab3271313db741c9c95677178b18d484</id>
<content type='text'>
_active was a bare string with 49 comparisons across four modules and a fifth
value nobody meant to keep. "disasm" was assigned on exactly one path -- a
decompile that failed with nowhere to return to -- and named the same widget as
"listing". Four sites understood it; five compared against "listing" alone and
silently took the wrong branch:

  * Tab out of a failed decompile set "listing" instead of "decomp", so the
    first press appeared to do nothing.
  * rpc.py carried a workaround for a mode change that never arrived, keyed on
    being ALREADY in the ghost state -- so it fired in the rare case and not in
    the common one. Now keyed on LISTING, which is the case that happens.
  * drive.py asked the socket to show it "disasm", a value the app will now
    never report, and would have toggled twice and given up.

ViewMode is a StrEnum on purpose: _active goes straight to drivers as
cursor.kind and the pilot compares it to plain strings, so members being strings
keeps every payload and comparison working. What it buys is one place that says
which modes exist, and an AttributeError instead of silence on a typo.

Read it through is_listing/is_decomp/is_hex/is_graph/in_code rather than ==.
The bare comparisons are what let the ghost hide, and they are what the next
mode would have to hunt down -- adding "graph" already cost one crash that way
(_active_code_view returning None when a prompt closed).

view_modes_all_handled walks the enum and asks the app the questions it asks
itself. Verified it bites: adding a fifth unhandled member fails it twice.

746 checks, 142.3s.
</content>
</entry>
<entry>
<title>tests: a guard that actually regresses on the resync storm</title>
<updated>2026-08-06T21:48:04Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-08-06T21:48:04Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=8c9490c0cc1712f61dfdd2efaaee93d33e1825f9'/>
<id>urn:sha1:8c9490c0cc1712f61dfdd2efaaee93d33e1825f9</id>
<content type='text'>
The split resync loop (f898350) had no test. Two attempts at one were worthless
and are not in this commit: a scroll-based guard passed with the bug
reintroduced, and a constructed anchor -- inside the loaded function, outside
its mapped span -- skipped, because on this target the map covers the whole
function. The real trigger is the race window while the decomp map lags the
decompiler re-pointing, which is tedious to force but wide open in split_view's
own flow.

So split_view counts lookup_funcs across its body and bounds it. Verified both
ways, which is the only reason it's worth having: 29,227 calls with the bug put
back, under 500 with the fix. The bound is loose because the bug was three
orders of magnitude out, not a near miss.

733 checks, 139s.
</content>
</entry>
<entry>
<title>tests: deterministic fixtures, and a correction</title>
<updated>2026-08-06T21:41:11Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-08-06T21:41:11Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=77db4042fbc913cedd44e63d8eced50b5702794f'/>
<id>urn:sha1:77db4042fbc913cedd44e63d8eced50b5702794f</id>
<content type='text'>
all_funcs() forced a full load of the function index only when it was EMPTY, so
a partially streamed index -- non-empty but incomplete, which is exactly the
state during boot and after any bump_items() -- came back truncated. Every
fixture picked through find_func/biggest therefore depended on how far streaming
had got by the time a scenario asked.

That is the graph_minimap flake: on an unlucky run find_func(size &gt; 0x300)
picked a much larger function than usual, whose graph never finished inside the
scenario's own 60s wait. Three failures and 65 seconds, one run in several, with
no code change to blame. Three consecutive clean runs at 1.7s since.

CORRECTION to f898350, which said a range cache for function_of 'broke
graph_minimap'. It did not. The failure happened in the run after I added the
cache and I attributed it without checking; it recurred with the cache long
gone. The cache is still not here, but for the honest reason: with the resync
loop fixed, function_of is down to 340 calls and 1.4s across the whole suite,
so caching it is not worth the invalidation surface.

Suite 195.7s -&gt; 138.4s, 732 checks.
</content>
</entry>
<entry>
<title>tests: wait for the thing, don't sleep and hope</title>
<updated>2026-08-06T21:25:23Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-08-06T21:25:23Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=9622e957cdd9602a2f2230ed7d4c90b06688a217'/>
<id>urn:sha1:9622e957cdd9602a2f2230ed7d4c90b06688a217</id>
<content type='text'>
test_trace_ui spent 18.8 of its 35.2 seconds in flat pilot.pause() calls placed
to let an async seek land. Two loops were most of it: 6 iterations at 0.5s and
28 at 0.3s, 11.4s of sleeping to check that a step moves the cursor.

They are condition waits now. The questions are unchanged -- does the listing
cursor reach the pc, does the pseudocode cursor follow -- but they cost what
they cost instead of a fixed budget. The second loop settles on something that
does NOT presuppose the answer (the listing cursor arriving, and the trail map
belonging to the loaded function): waiting on 'is this pc mapped' would have
burned the timeout on every unmapped instruction, about half of them, and come
out slower than the sleep it replaced.

35.2s -&gt; 20.9s, 39 checks, stable over repeated runs.

tests/_fixtures.py collects the staging both this suite and test_scenarios need
-- scratch copy, seeded from a golden .i64 nothing writes back to -- which was
private to test_scenarios. Worth saying plainly: on targets/echo the seeding is
worth 0.19s, not the analysis time I assumed when I went looking. It is shared
for the deduplication and for whatever gets pointed at a bigger binary.
</content>
</entry>
<entry>
<title>split: stop the resync loop that spun the worker forever</title>
<updated>2026-08-06T21:07:43Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-08-06T21:07:43Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=f898350818062ccde209657632537d0d11bbdafa'/>
<id>urn:sha1:f898350818062ccde209657632537d0d11bbdafa</id>
<content type='text'>
_split_range is the min/max of the decomp_map's addresses, which does not cover
every address in the function -- Hex-Rays doesn't attribute them all. An anchor
inside the loaded function but outside that span therefore asked _sync_split for
a resync, _apply_resync found the function already decompiled, called
_sync_split again, and it asked again. One thread worker and one lookup_funcs
round trip per iteration, for as long as the cursor sat there.

Measured in the pilot: 23,888 function_of calls in one scenario across FOUR
distinct addresses, 21,156 of them for 0x2060 alone. In the live app that is an
idle split view pegging the worker.

_sync_split grows a resync flag; the one caller that is itself the resync passes
resync=False, so the branch can be entered at most once per chain.

While measuring, three scenarios waited on "fail" appearing in the status --
the app says "cannot decompile". decomp_fallback burned its full 25s timeout and
then passed a check on _active == "listing", which was already true before Tab
was pressed: it asserted nothing, slowly. Now waits for the real text and checks
that the fallback actually said something.

scenarios 115.8s -&gt; 74.9s, suite 195.7s -&gt; 153.3s, 732 checks green.

Not included: a range cache for function_of. It broke graph_minimap (the graph
stopped loading at all -- the 65s was that scenario's own 60s wait timing out)
and with the loop gone it buys little. Left out rather than shipped
half-understood.
</content>
</entry>
<entry>
<title>tests: one front door</title>
<updated>2026-08-06T20:05:03Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-08-06T20:05:03Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=ebff7caaaf348602422f6943e9de01240a79994e'/>
<id>urn:sha1:ebff7caaaf348602422f6943e9de01240a79994e</id>
<content type='text'>
Fourteen test files, each its own __main__, and no way to run them but from
memory -- so in practice you ran the one you were working on and hoped. Worse,
nothing said which files need a licensed IDA and a real worker (minutes) and
which are pure stdlib (milliseconds), so the cheap ones nobody ran either.

tests/run.py runs the lot and prints one table. --fast selects only the suites
that need nothing, which is 257 checks in half a second under any python3 --
that's the one you run between edits.

The classification lives in the test files, not in a table here that would rot
the first time someone adds a test: each declares NEEDS_IDA at module scope and
run.py reads it with ast (it can't import them -- they run their suite at
import). A file without the marker is a hard error rather than a silent guess.
</content>
</entry>
<entry>
<title>help: reach the cheatsheet with H as well as F1</title>
<updated>2026-08-06T14:36:53Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-08-06T14:36:53Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=1eed36ec1cdd573ef5a095dcafff12a6dd4daab2'/>
<id>urn:sha1:1eed36ec1cdd573ef5a095dcafff12a6dd4daab2</id>
<content type='text'>
F1 is swallowed before it ever reaches us on at least one setup here -- the
app's own binding fires when the key is injected directly, zellij has no F1
binding of its own, and every common F1 encoding written straight into the pane
(SS3 ESC O P, CSI ESC [11~, CSI-u ESC [1;1P) opens it. So the key is being eaten
by something upstream, which is not ours to fix, and a cheatsheet reachable only
through a function key is fragile anyway: terminals and multiplexers claim them
routinely.

H opens and closes it too. '?' stays with the incremental search, which is what
it has always done in the code views.
</content>
</entry>
<entry>
<title>graph: navigate to blocks, not to coordinates</title>
<updated>2026-08-06T14:28:56Z</updated>
<author>
<name>blasty</name>
<email>blasty@local</email>
</author>
<published>2026-08-06T14:28:56Z</published>
<link rel='alternate' type='text/html' href='https://git.sl0p.foo/ida-tui.git/commit/?id=4408eaf2cb814e927f51cb0632e9845994abae51'/>
<id>urn:sha1:4408eaf2cb814e927f51cb0632e9845994abae51</id>
<content type='text'>
Clicking the minimap panned to the exact coordinate under the pointer and moved
the cursor only if a block happened to sit there. Since one minimap cell covers
many canvas cells, "there" was almost always padding: you got a jump into empty
space and the cursor stayed behind, so you had to click a block afterwards to
actually go anywhere.

Blocks cover a few percent of a laid-out graph -- 4.6% of an 87-block function,
0.8% of a 424-block one -- and the rest is the space that keeps edges apart. So
coordinates are the wrong thing to navigate by here. The minimap now snaps to
the nearest block and takes the cursor with it, and a drag scrubs from block to
block. Distance is measured with the column halved, because cells are twice as
tall as they are wide and otherwise "nearest" is not what looks nearest.

A drag-pan or ctrl+d/pageup that ends with no block on screen at all now eases
to the nearest one too, since an empty screen leaves nothing to navigate back
by. It only fires when nothing is visible, so a deliberate pan is never fought.
</content>
</entry>
</feed>
