aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rwxr-xr-x.auto/checks.sh77
-rw-r--r--idatui/worker_client.py25
-rw-r--r--tests/test_project_ui.py29
3 files changed, 74 insertions, 57 deletions
diff --git a/.auto/checks.sh b/.auto/checks.sh
index 6cf1102..5323fce 100755
--- a/.auto/checks.sh
+++ b/.auto/checks.sh
@@ -1,28 +1,26 @@
#!/bin/bash
# Correctness gate: no perf win is allowed to cost functionality.
#
-# 1. the pure suites (344 checks, ~3.5s, stdlib only)
-# 2. the pilot scenario suite (301 checks, ~80s) -- a real idalib worker
-# driving the real Textual app. This is the project's source of truth for
-# behaviour; a rendering/paging/nav optimisation that breaks the UI shows
-# up here and nowhere else.
-# 3. .auto/check_search.py -- the search fast paths against the plain loop.
-# Caches that go stale still return an answer, so the scenario suite
-# cannot see them; this compares fast and slow directly.
+# 1. .auto/check_search.py -- the search fast paths against the plain loop.
+# Caches that go stale still return AN answer, so no scenario test can see
+# them; this compares fast and slow directly, for every typed prefix.
+# 2. tests/run.py -- the project's own front door: every suite, pure and IDA,
+# ~185s. It used to be only the scenario suite here, and that gap cost a
+# real regression: a faster worker connect left the loading overlay up a
+# moment longer relative to the index finishing, and project mode's first
+# keypress landed on the overlay. Only test_project_ui.py covers that, and
+# it was not being run.
#
-# Only failures reach stdout: the agent sees the last 80 lines on failure, and
-# a wall of "ok" would push the actual break out of view. A failing scenario is
-# re-run ALONE before it counts -- the suite shares one warm session, and a
-# scenario that timed out under load and then failed a check trivially is a
-# flake, not a regression (see the note in tests/run.py).
+# Only failures reach stdout: the agent sees the last 80 lines on failure, and a
+# wall of "ok" would push the actual break out of view. A file that fails is
+# re-run ALONE before it counts -- the IDA suites share a loaded box, and a
+# worker that got CPU-starved mid-analysis reads as a failure but is not one
+# (see the note in tests/run.py).
set -euo pipefail
cd "$(dirname "$0")/.."
PY="${IDATUI_PYTHON:-$HOME/ida-venv/bin/python}"
-pure=$(python3 tests/run.py --fast 2>&1) || { echo "$pure" | tail -40; exit 1; }
-echo "$pure" | tail -2
-
search=$("$PY" .auto/check_search.py targets/echo 2>&1) || {
echo "--- search fast paths disagree with the plain loop ---"
echo "$search" | tail -20
@@ -30,34 +28,31 @@ search=$("$PY" .auto/check_search.py targets/echo 2>&1) || {
}
echo "$search" | tail -1
-run_suite() { # $1 = optional --only filter
- if [ -n "${1:-}" ]; then
- "$PY" tests/test_scenarios.py targets/echo --only "$1" 2>&1
- else
- "$PY" tests/test_scenarios.py targets/echo 2>&1
- fi
-}
-
-out=$(run_suite || true)
-echo "$out" | grep -E "^[0-9]+ passed, [0-9]+ failed" | tail -1
+out=$(python3 tests/run.py 2>&1) || true
+echo "$out" | tail -2
-fails=$(echo "$out" | grep -E "^ FAIL" || true)
-if [ -z "$fails" ]; then
+# Files run.py marked bad, e.g. " FAIL scenarios 301 passed, 2 failed"
+# run.py's summary says "N passed[, M failed]..."; no "failed" clause == green.
+if ! echo "$out" | tail -3 | grep -q "failed"; then
exit 0
fi
echo "--- first pass failures ---"
-echo "$fails" | head -20
-
-# Retry just the scenarios that failed, alone, on a fresh session.
-names=$(echo "$fails" | sed -E 's/^ FAIL \[([^]]+)\].*/\1/' | sort -u | paste -sd, -)
-echo "--- retrying alone: $names ---"
-retry=$(run_suite "$names" || true)
-again=$(echo "$retry" | grep -E "^ FAIL" || true)
-if [ -z "$again" ]; then
- echo "flake: all of [$names] pass in isolation"
- exit 0
+echo "$out" | grep -E "^ FAIL" | head -20
+# run.py names them itself: "failing files: formats scenarios" (colourised).
+files=$(echo "$out" | sed -e 's/\x1b\[[0-9;]*m//g' \
+ | sed -n 's/^failing files: *//p' | tr '\n' ' ')
+if [ -z "$files" ]; then
+ echo "--- could not identify the failing file; full tail ---"
+ echo "$out" | tail -30
+ exit 1
+fi
+echo "--- retrying alone: $files ---"
+retry=$(python3 tests/run.py $files 2>&1) || true
+if echo "$retry" | tail -3 | grep -q "failed"; then
+ echo "--- REAL regression ---"
+ echo "$retry" | grep -E "^ FAIL" | head -30
+ echo "$retry" | tail -3
+ exit 1
fi
-echo "--- REAL regression ---"
-echo "$again" | head -30
-exit 1
+echo "flake: [$files] pass in isolation"
diff --git a/idatui/worker_client.py b/idatui/worker_client.py
index 4f90700..173cd9d 100644
--- a/idatui/worker_client.py
+++ b/idatui/worker_client.py
@@ -117,16 +117,20 @@ class WorkerClient:
# (a small binary, or a seeded .i64), which is most of the time in
# the tests and noticeable on a re-open.
#
- # Backing off geometrically from the first probe was still too eager:
- # a seeded database is ready at ~250ms, by which point the delay has
- # grown to 134ms, so every open waited ~350ms whatever the binary --
- # the same number for a 47KB `echo` and a 1.2MB `bash`, which is what
- # gives a polling artefact away. Hold the fast rate for the first few
- # seconds (a connect attempt on an absent socket is microseconds) and
- # only slow down for a genuine cold auto-analysis, which runs for
- # minutes and does not care about 200ms.
+ # Backing off all the way to 0.2s was too eager: a seeded database
+ # is ready at ~250ms, by which point the delay has grown to 134ms, so
+ # every open waited ~350ms whatever the binary -- the same number for
+ # a 47KB `echo` and a 1.2MB `bash`, which is what gives a polling
+ # artefact away. Cap the backoff at 25ms instead: the overshoot on a
+ # fast open is bounded by that, and 40 probes a second is nothing
+ # next to an auto-analysis that runs for minutes.
+ #
+ # Do NOT be tempted to hold the 5ms rate instead. This poll runs on a
+ # background thread while the UI thread is drawing, and 200 wakeups a
+ # second through a cold analysis cost enough GIL time to delay the
+ # app's own startup -- it left the loading overlay up long enough for
+ # project mode's first keypress to land on it.
delay = 0.005
- fast_until = t0 + 5.0
while time.time() < deadline:
try:
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
@@ -142,8 +146,7 @@ class WorkerClient:
progress(f"auto-analyzing {os.path.basename(self._bin)}… "
f"({int(time.time() - t0)}s)")
time.sleep(delay)
- if time.time() > fast_until:
- delay = min(delay * 1.6, 0.2)
+ delay = min(delay * 1.6, 0.025)
raise IDAConnectionError("worker did not become ready in time")
@property
diff --git a/tests/test_project_ui.py b/tests/test_project_ui.py
index 753717a..7e93c81 100644
--- a/tests/test_project_ui.py
+++ b/tests/test_project_ui.py
@@ -56,10 +56,27 @@ async def run(bins):
async def settle(pred, t=180.0):
return await wait_for(pred, pilot.pause, t, 0.05)
+ async def usable(t=180.0):
+ """Loaded AND drivable.
+
+ An index that has finished streaming does not mean the app is
+ taking keys yet: the loading overlay is a ModalScreen and it is
+ dismissed a moment later, by auto-land. Between those two the
+ app looks ready and swallows every keypress -- so a test that
+ waits only for the index presses Ctrl+O into the overlay and
+ sees no switcher. Which side of that gap the poll lands on is
+ decided by how fast the backend happens to be, so it has to be
+ waited for explicitly, not hoped for.
+ """
+ return await settle(
+ lambda: app.program is not None
+ and app._func_index is not None
+ and app._func_index.complete
+ and app._loading_screen is None
+ and len(app.screen_stack) == 1, t)
+
# -- boots on the project's first binary ----------------------- #
- ok = await settle(lambda: app.program is not None
- and app._func_index is not None
- and app._func_index.complete)
+ ok = await usable()
check("project mode boots on the first binary", ok,
f"binary={app._binary}")
check("the active binary is the first one", app._binary == first,
@@ -100,7 +117,8 @@ async def run(bins):
await pilot.press("enter")
switched = await settle(
lambda: app._binary == second and app.program is not None
- and app._func_index is not None and app._func_index.complete)
+ and app._func_index is not None and app._func_index.complete
+ and len(app.screen_stack) == 1)
check("switching opens the other binary", switched,
f"binary={app._binary}")
check("the second binary has its own function index",
@@ -127,7 +145,8 @@ async def run(bins):
await pilot.press("enter")
back = await settle(lambda: app._binary == first
and app._func_index is not None
- and app._func_index.complete, 120)
+ and app._func_index.complete
+ and len(app.screen_stack) == 1, 120)
check("switching back returns to the first binary", back,
f"binary={app._binary}")
check("its function index came back intact",