diff options
| author | blasty <blasty@local> | 2026-07-09 11:22:18 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-09 11:22:18 +0200 |
| commit | 90636755b26c5dbee8acdda5e37c7aff9e896854 (patch) | |
| tree | 7aa0176977291608e06ce19bd35058f40da6d3e5 /idatui/client.py | |
| parent | persistent MCP client: warm handshake, keep-alive pool, error taxonomy, threa... (diff) | |
| download | ida-tui-90636755b26c5dbee8acdda5e37c7aff9e896854.tar.gz ida-tui-90636755b26c5dbee8acdda5e37c7aff9e896854.tar.xz ida-tui-90636755b26c5dbee8acdda5e37c7aff9e896854.zip | |
harden client: transparent session auto-recovery + stress suite
- auto-recover stale db ("Session not found" after server restart): drop pin,
re-resolve sole session, retry once. Only when db was auto-injected; never
silently switches an explicitly-pinned db (auto_recover_session gate).
- tests/serverctl.sh: start/stop/kill9/ready control over spawn.sh
- tests/stress_client.py: 6 adversarial scenarios, 24 assertions:
dead-port fail-fast, tight-timeout recovery, worker crash, full restart
(naive auto-recovery + no-autoswitch), 300/16 concurrency, kill-under-churn.
All clean: no hangs, no deadlocks, no non-IDAError leaks.
Diffstat (limited to 'idatui/client.py')
| -rw-r--r-- | idatui/client.py | 58 |
1 files changed, 47 insertions, 11 deletions
diff --git a/idatui/client.py b/idatui/client.py index 2c8cc68..44f5cc9 100644 --- a/idatui/client.py +++ b/idatui/client.py @@ -53,6 +53,11 @@ PROTOCOL_VERSION = "2025-06-18" # Tools that must NOT receive an injected ``database`` argument. SESSION_AGNOSTIC_TOOLS = frozenset({"idb_list", "idb_open", "int_convert"}) +# Substrings (lowercased) that identify a stale/invalid IDB session in a tool +# error message. Verified against the live server: after a server restart the +# old session id is gone and every call fails with "Session not found: <id>". +_STALE_SESSION_MARKERS = ("session not found", "database is required") + # --------------------------------------------------------------------------- # # Exceptions @@ -251,10 +256,12 @@ class IDAClient: pool_size: int = 8, client_name: str = "idatui", client_version: str = "0.0.1", + auto_recover_session: bool = True, ): self.url = url self._db = db self.timeout = timeout + self.auto_recover_session = auto_recover_session self._transport = _Transport(url, max_retries=max_retries, pool_size=pool_size) self._client_info = {"name": client_name, "version": client_version} @@ -434,26 +441,50 @@ class IDAClient: except (json.JSONDecodeError, TypeError): return text + def _prepare_args(self, tool: str, args: dict) -> tuple[dict, bool]: + """Return (arguments, db_was_injected). Never mutates the caller's dict.""" + prepared = dict(args) + if tool in SESSION_AGNOSTIC_TOOLS or "database" in prepared: + return prepared, False + prepared["database"] = self.resolve_db() + return prepared, True + def call_envelope(self, tool: str, *, timeout: float | None = None, **args) -> dict: """Call ``tool`` and return the full JSON-RPC envelope (for debugging).""" - self._inject_db(tool, args) - return self._rpc("tools/call", {"name": tool, "arguments": args}, timeout=timeout) + prepared, _ = self._prepare_args(tool, args) + return self._rpc( + "tools/call", {"name": tool, "arguments": prepared}, timeout=timeout + ) def call(self, tool: str, *, timeout: float | None = None, **args) -> Any: """Call ``tool`` and return its payload (parsed JSON when possible). Raises IDAToolError on a hard tool failure. Soft/per-item errors (an ``error`` field with ``isError == false``) are returned as data. - """ - envelope = self.call_envelope(tool, timeout=timeout, **args) - return self._extract_payload(tool, envelope.get("result", {})) - def _inject_db(self, tool: str, args: dict) -> None: - if tool in SESSION_AGNOSTIC_TOOLS: - return - if "database" in args: - return - args["database"] = self.resolve_db() + If ``auto_recover_session`` is set and the db was auto-injected, a stale + "Session not found" error (e.g. after a server restart) triggers exactly + one transparent recovery: drop the stale pin, re-resolve the session, + and retry. A db the caller pinned explicitly is never silently switched. + """ + prepared, injected = self._prepare_args(tool, args) + envelope = self._rpc( + "tools/call", {"name": tool, "arguments": prepared}, timeout=timeout + ) + try: + return self._extract_payload(tool, envelope.get("result", {})) + except IDAToolError as e: + if not (injected and self.auto_recover_session and _is_stale_session(e)): + raise + # The pinned IDB session vanished (server restart). Re-resolve the + # sole session and retry once. resolve_db() raises IDASessionError + # if zero/many sessions exist, so we never guess. + self.set_db(None) + prepared2, _ = self._prepare_args(tool, args) + envelope2 = self._rpc( + "tools/call", {"name": tool, "arguments": prepared2}, timeout=timeout + ) + return self._extract_payload(tool, envelope2.get("result", {})) # -- session management ------------------------------------------------ # def list_sessions(self) -> list[Session]: @@ -511,6 +542,11 @@ class IDAClient: raise IDAError(f"tool not found: {tool}") +def _is_stale_session(e: IDAToolError) -> bool: + msg = e.message.lower() + return any(marker in msg for marker in _STALE_SESSION_MARKERS) + + def _first_text(result: dict) -> str | None: content = result.get("content") if isinstance(content, list): |
