aboutsummaryrefslogtreecommitdiffstats
path: root/idatui/rpc.py
diff options
context:
space:
mode:
authoruser <user@clank>2026-07-31 09:47:15 +0200
committeruser <user@clank>2026-07-31 09:47:15 +0200
commit0635d85d23ec04a130bcacb2f25cd74d680f4ef7 (patch)
treedc40ee28c677dfef28fd84bf2157e3de778d67cb /idatui/rpc.py
parentrpc: stop lying to the driver about renames, modals and teardown (diff)
downloadida-tui-0635d85d23ec04a130bcacb2f25cd74d680f4ef7.tar.gz
ida-tui-0635d85d23ec04a130bcacb2f25cd74d680f4ef7.tar.xz
ida-tui-0635d85d23ec04a130bcacb2f25cd74d680f4ef7.zip
rpc: a navigation that timed out reported success and corrupted the next edit
goto/open ran `settle(app, pred, timeout)` and threw the result away. On a large database the listing build routinely outruns the default 20 s, so the verb returned a normal snapshot while the view had not moved. Every subsequent rename/comment then applied to wherever the caller *used* to be. Reproduced on a live pane against a 4 MB Go binary: `goto 0x1002019b0` returned ok with the view still at 0x100001000, and the following `rename main_inflate_zlib` renamed internal/abi.BoundsDecode instead — then the rename's own snapshot showed `main_inflate @ 0x1002019b0`, because by the time it was taken the goto had finally landed. Success reported, right-looking readback, wrong function edited, and it survived a save. This is what made an agent session stamp net_writeFull onto main_usage and conclude the tooling was flaky. goto/open now raise TimeoutError naming the target and where we actually are, suggesting a larger timeout=. _press() does the same for follow/toggle_view/ hex/xrefs/structs, which had the identical "predicate ignored" shape. tests/test_scenarios.py: 212 passed, 0 failed.
Diffstat (limited to 'idatui/rpc.py')
-rw-r--r--idatui/rpc.py42
1 files changed, 34 insertions, 8 deletions
diff --git a/idatui/rpc.py b/idatui/rpc.py
index 28d9aba..8d90a1f 100644
--- a/idatui/rpc.py
+++ b/idatui/rpc.py
@@ -175,6 +175,14 @@ def _cursor_info(app, w) -> dict[str, Any]:
"scroll_y": round(w.scroll_offset.y)}
+def _where(app) -> str:
+ """Short 'name @ 0xea' for error messages that need to say where we ended up."""
+ cur = getattr(app, "_cur", None)
+ if cur is None:
+ return "nowhere"
+ return f"{getattr(cur, 'name', '?')} @ {getattr(cur, 'ea', 0):#x}"
+
+
def _readiness(app) -> dict[str, Any]:
"""Whether the app is drivable yet, and how far function-loading has got.
(Cheap: no network — never call client.health() here.)"""
@@ -479,9 +487,15 @@ class RpcServer:
return {"id": rid, "error": {"message": f"{type(e).__name__}: {msg}"}}
# -- composed helpers (semantic verbs) -------------------------------- #
- async def _press(self, keys, pred=None, timeout=20.0):
+ async def _press(self, keys, pred=None, timeout=20.0, what=""):
await self.app._press_keys([str(k) for k in keys])
- await settle(self.app, pred, timeout=timeout)
+ ok = await settle(self.app, pred, timeout=timeout)
+ if pred is not None and not ok:
+ # Never report success for an action that did not happen: the caller
+ # would go on to edit whatever the *previous* location was.
+ raise TimeoutError(
+ f"{what or 'action'} did not complete within {timeout}s "
+ f"(still at {_where(self.app)}); retry with a larger timeout=")
return snapshot(self.app)
async def _fill_prompt(self, open_key, input_id, value, delay_ms, clear):
@@ -716,7 +730,15 @@ class RpcServer:
target = str(params.get("target", ""))
pred = self._goto_target_pred(target)
await self._fill_prompt("g", "goto", target, delay, clear=False)
- await settle(app, pred, timeout=timeout)
+ ok = await settle(app, pred, timeout=timeout)
+ if pred is not None and not ok:
+ # A goto that silently "succeeds" without moving is worse than an
+ # error: on a big database the listing build can outrun the
+ # default timeout, and every subsequent rename/comment then lands
+ # on the function the caller *used* to be looking at.
+ raise TimeoutError(
+ f"goto {target!r} did not land within {timeout}s "
+ f"(still at {_where(app)}); retry with a larger timeout=")
return snapshot(app)
if method == "rename":
@@ -735,7 +757,8 @@ class RpcServer:
if method == "follow":
depth = len(app._nav)
- return await self._press(["enter"], lambda: len(app._nav) > depth, timeout)
+ return await self._press(["enter"], lambda: len(app._nav) > depth,
+ timeout, "follow")
if method == "back":
return await self._press(["escape"], timeout=timeout)
if method == "toggle_view":
@@ -759,12 +782,14 @@ class RpcServer:
return False
return False
- return await self._press(["tab"], _toggled, timeout)
+ return await self._press(["tab"], _toggled, timeout, "toggle_view")
if method == "hex":
- return await self._press(["backslash"], lambda: app._active == "hex", timeout)
+ return await self._press(["backslash"], lambda: app._active == "hex",
+ timeout, "hex")
if method == "xrefs":
return await self._press(
- ["x"], lambda: type(app.screen).__name__ == "XrefsScreen", timeout)
+ ["x"], lambda: type(app.screen).__name__ == "XrefsScreen",
+ timeout, "xrefs")
if method == "symbols":
await app._press_keys(["ctrl+n"])
await settle(app, lambda: type(app.screen).__name__ == "SymbolPalette", timeout=10)
@@ -775,7 +800,8 @@ class RpcServer:
return snapshot(app)
if method == "structs":
return await self._press(
- ["ctrl+t"], lambda: type(app.screen).__name__ == "StructEditor", timeout)
+ ["ctrl+t"], lambda: type(app.screen).__name__ == "StructEditor",
+ timeout, "structs")
if method == "close":
return await self._press(["escape"], timeout=timeout)
if method == "save":