aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-08-10 00:14:51 +0200
committerblasty <blasty@local>2026-08-10 00:14:51 +0200
commitccf4616a0ace008af427234358ee256e537bfa02 (patch)
tree7e850556cd97005bb21fdc89a208d8c905cf60f8 /tests
parentremote_tools: delete a shadowed duplicate hiding an lru_cache landmine (diff)
downloadida-tui-ccf4616a0ace008af427234358ee256e537bfa02.tar.gz
ida-tui-ccf4616a0ace008af427234358ee256e537bfa02.tar.xz
ida-tui-ccf4616a0ace008af427234358ee256e537bfa02.zip
Skeleton pages: stop rendering 227k rows to count them (3x boot)
ListingView._grow streams the entire segment in the background for one reason: to learn how many rows it has, so the scrollbar and paging are right. It did that by rendering every row in full -- 227,500 rows of a 1.2MB bash, 911 backend calls, 9.3 seconds -- essentially none of which is ever looked at. generate_disasm_line is 22x the cost of the walk around it, so heads() gains text=False: a SKELETON page with the same rows at the same addresses with the same kinds and sizes, and no rendered text. Measured identical structurally (rows, addresses, kinds, sizes and cursor all match a real page) which is what makes one swappable for the other later. It also skips the digest (nothing to go stale) and lets the client skip the bulk opcode read, so a page costs ONE round trip instead of two. Client side is deliberately tiny, because the machinery already existed: a skeleton page is just a page whose text is stale. It is marked with a sentinel generation no _text_gen can equal, and the FIRST read of it goes through the same _ensure_text/_ensure_page path a rename uses -- which already refetches a page by address, verifies the structure still lines up and splices it in. Two staleness gates learn to fire for _skeleton as well as _renamed; that is the whole integration. bash boot: 911 calls / 9.26s -> 456 calls / 3.12s, 3.0x. The trade is that a page you actually display is fetched twice (3.3ms + 9.4ms vs 9.4ms), paid only for what is shown. _prime still loads real pages, so the viewport you land on is never a skeleton. The failure mode is BLANK ROWS, not an exception, and nothing in the suite scrolled far enough to see one: _prime renders the first ~1000 rows for real, so a test that pages down a few screens passes against a completely broken implementation. The new scenario reads deep rows through both the model and the render path, and asserts materialising changes neither the row count nor the walk. Verified by reverting the two gates: it fails with text=''. Full gate: 1050 passed.
Diffstat (limited to 'tests')
-rw-r--r--tests/test_scenarios.py57
1 files changed, 57 insertions, 0 deletions
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index 038cac5..780459a 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -412,6 +412,63 @@ async def s_auto_land(c: Ctx):
c.check("auto-land is idempotent (guarded)", app._cur is prev)
+@scenario("skeleton_pages")
+async def s_skeleton_pages(c: Ctx):
+ """The background grower loads text-less pages; reading one must fill it in.
+
+ _grow streams the whole segment only to learn how many rows it has, so it
+ asks for skeleton pages (same rows, same addresses, no rendered text) --
+ 3x cheaper and one round trip instead of two. The first read of such a page
+ has to materialise it through the same path a rename uses.
+
+ The failure mode if that path breaks is BLANK ROWS deep in the listing, not
+ an exception, and nothing else in this suite scrolls far enough to see it:
+ _prime renders the first ~1000 rows for real, so a test that only pages down
+ a few screens passes against a completely broken implementation.
+ """
+ app = c.app
+ await c.open_biggest("listing")
+ lv = app.query_one(ListingView)
+ model = lv.model
+ if model is None:
+ c.check("listing model exists", False)
+ return
+ # Let the grower finish so the tail of the segment is definitely skeleton.
+ await c.wait(lambda: model.complete, 30)
+ c.check("the grower completes", model.complete, f"rows={len(model)}")
+ if not model.complete or len(model) < 1200:
+ # A target smaller than _prime's horizon has no skeleton pages at all,
+ # so there is nothing to check rather than something broken.
+ c.check("segment is big enough to have skeleton pages", True,
+ f"skipped: only {len(model)} rows, _prime renders ~1000")
+ return
+ c.check("pages were loaded as skeletons", model._skeleton is True)
+
+ # Well past _prime's horizon, and the very last row.
+ deep = max(1200, len(model) - 40)
+ for row in (1200, len(model) // 2, deep):
+ h = model.get(row)
+ c.check(f"row {row} of a skeleton page has real text",
+ h is not None and bool((h.text or "").strip()),
+ f"ea={getattr(h, 'ea', None)} text={getattr(h, 'text', None)!r}")
+
+ # And through the render path the user actually sees, not just the model.
+ lv.cursor = deep
+ lv.refresh()
+ await c.pause(0.1)
+ painted = lv._line_plain(deep)
+ c.check("a deep row RENDERS with text",
+ bool(painted and painted.strip()), f"painted={painted!r}")
+
+ # Materialising must not change the row count or move any address: the
+ # skeleton's structure is what the scrollbar was sized from.
+ before = len(model)
+ model.get(deep)
+ c.check("materialising a page does not change the row count",
+ len(model) == before, f"{before} -> {len(model)}")
+ c.check("the walk was not disturbed", not model.stale_structure)
+
+
@scenario("palette_paging")
async def s_palette_paging(c: Ctx):
"""PgUp/PgDn move the palette list by a viewport, with the Input focused.