diff options
| author | blasty <blasty@local> | 2026-08-06 23:25:23 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-08-06 23:25:23 +0200 |
| commit | 9622e957cdd9602a2f2230ed7d4c90b06688a217 (patch) | |
| tree | d81a9c2761da67f2929d6fe852090a3f418038b1 /tests/_fixtures.py | |
| parent | split: stop the resync loop that spun the worker forever (diff) | |
| download | ida-tui-9622e957cdd9602a2f2230ed7d4c90b06688a217.tar.gz ida-tui-9622e957cdd9602a2f2230ed7d4c90b06688a217.tar.xz ida-tui-9622e957cdd9602a2f2230ed7d4c90b06688a217.zip | |
tests: wait for the thing, don't sleep and hope
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 -> 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.
Diffstat (limited to 'tests/_fixtures.py')
| -rw-r--r-- | tests/_fixtures.py | 89 |
1 files changed, 89 insertions, 0 deletions
diff --git a/tests/_fixtures.py b/tests/_fixtures.py new file mode 100644 index 0000000..766ecd5 --- /dev/null +++ b/tests/_fixtures.py @@ -0,0 +1,89 @@ +"""Shared setup for the suites that need a real analysed binary. + +Two things every IDA suite has to get right, and only test_scenarios did: + +**Work on a scratch copy.** Opening a binary writes a `.i64` beside it, and the +suites EDIT it -- they define code, undefine items, rename and comment, and IDA +saves that. Run against the tracked target and each run inherits the last one's +damage: a scenario started failing with no code change because an earlier one +had undefined an instruction. A suite whose result depends on its own history +can't be trusted to accuse the code. + +**Seed from a golden database.** Auto-analysis is the bulk of a suite's runtime +(`targets/echo` is ~30s of it) and it produces the same answer every time. Doing +it once and keeping the result as `<binary>.pristine.i64` -- which nothing ever +writes back to -- turns that into a file copy. + + with staged("targets/echo") as target: + app = IdaTui(open_path=target, ...) + +The cache is rebuilt whenever it is older than the binary, so editing a target +doesn't silently test the previous one. `.pristine.i64` is gitignored. +""" +from __future__ import annotations + +import contextlib +import os +import shutil +import tempfile + + +def cache_path(binary: str) -> str: + return binary + ".pristine.i64" + + +def cache_is_fresh(binary: str) -> bool: + c = cache_path(binary) + return os.path.exists(c) and os.path.getmtime(c) >= os.path.getmtime(binary) + + +async def build_pristine(binary: str, cache: str, app_factory) -> None: + """Analyse ``binary`` once and keep the database as a golden copy. + + ``app_factory(path)`` builds the IdaTui -- passed in so this module needs no + import of the app (and so a suite can hand over its own load options). + """ + print(f" (building pristine database for {os.path.basename(binary)}\u2026)") + app = app_factory(binary) + async with app.run_test(size=(140, 44)) as pilot: + for _ in range(6000): + await pilot.pause(0.05) + if app._func_index is not None and app._func_index.complete: + break + app.program.client.call("idb_save", timeout=600.0) + db = binary + ".i64" + if os.path.exists(db): + shutil.copy2(db, cache) + + +@contextlib.contextmanager +def scratch_copy(binary: str, prefix: str = "idatui-test-"): + """A temp-dir copy of ``binary``, seeded from the pristine cache if there is + a fresh one. Yields the copy's path; the directory goes away after. + + Does NOT build the cache (that needs an app and an event loop) -- a suite + that wants one calls :func:`build_pristine` first. Without a cache this is + still correct, just slow: the worker analyses from scratch. + """ + with tempfile.TemporaryDirectory(prefix=prefix) as d: + target = os.path.join(d, os.path.basename(binary)) + shutil.copy2(binary, target) + if cache_is_fresh(binary): + shutil.copy2(cache_path(binary), target + ".i64") + yield target + + +@contextlib.asynccontextmanager +async def staged(binary: str, app_factory=None, prefix: str = "idatui-test-"): + """:func:`scratch_copy`, building the pristine cache first if it's missing. + + This is what a suite wants: one call, and the analysis is paid once ever + rather than once per run. + """ + if app_factory is not None and not cache_is_fresh(binary): + with tempfile.TemporaryDirectory(prefix=prefix) as seed_dir: + seed = os.path.join(seed_dir, os.path.basename(binary)) + shutil.copy2(binary, seed) + await build_pristine(seed, cache_path(binary), app_factory) + with scratch_copy(binary, prefix) as target: + yield target |
