diff options
| author | blasty <blasty@local> | 2026-07-24 00:51:14 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-24 00:51:14 +0200 |
| commit | 9b68b6ea6976720e57e95ba27335031ed7ee6109 (patch) | |
| tree | cb0e29ca5bfc9b5e8ab0393333ed1fdbf0af6843 | |
| parent | nav: baseline horizontal scroll at 0 when jumping to a target column (diff) | |
| download | ida-tui-9b68b6ea6976720e57e95ba27335031ed7ee6109.tar.gz ida-tui-9b68b6ea6976720e57e95ba27335031ed7ee6109.tar.xz ida-tui-9b68b6ea6976720e57e95ba27335031ed7ee6109.zip | |
robust: survive a lost server connection instead of crashing; auto-reconnect
Leaving the TUI idle could let the analysis server go away (idle exit, killed,
box slept). The next action's worker then raised IDAConnectionError, which
Textual escalated to a fatal app exit (exit_on_error) — the whole TUI crashed
with a traceback.
Now IdaTui._handle_exception intercepts a WorkerFailed whose cause is
IDAConnectionError and, instead of dying, runs a reconnect:
* show a "connection lost — reconnecting…" overlay;
* _reconnect() restarts the supervisor if it's down (_ensure_server), makes a
fresh client, re-opens the binary (open_path) or re-resolves the sole session,
restarts the keepalive, and swaps in the new client/Program;
* _after_reconnect rebuilds the function index and refreshes the current view
with the new program; on failure it leaves a message and retries on the next
action. A re-entry guard avoids stacking attempts when many in-flight calls
fail at once. Any other exception still crashes as before.
Also harden _status() to never throw (query_one can miss #status during a screen
transition), so a status update can't take down a worker.
Verified: injecting IDAConnectionError into a nav worker -> no crash, overlay
shown, new Program swapped in, overlay cleared, post-reconnect calls work. Full
pilot suite green.
| -rw-r--r-- | idatui/app.py | 97 |
1 files changed, 95 insertions, 2 deletions
diff --git a/idatui/app.py b/idatui/app.py index e54c418..6a47fd3 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -42,7 +42,7 @@ from textual.widgets.option_list import Option from .highlight import highlight_c -from .client import IDAClient, IDAToolError +from .client import IDAClient, IDAToolError, IDAConnectionError from .domain import DisasmModel, Func, Head, ListingModel, Program, Struct # Styles for the disassembly listing. @@ -2347,6 +2347,8 @@ class IdaTui(App): self._decomp_return: NavEntry | None = None # listing to return to from F5 self._busy_screen: BusyScreen | None = None self._xref_active = False # an xref gather is in flight (blocks re-entry) + self._conn_screen: LoadingScreen | None = None + self._reconnecting = False # a reconnect attempt is in flight self._search_ctx: tuple[object | None, int] = (None, 1) self._rename_ctx: tuple[object | None, str] = (None, "") self._rename_addr: int | None = None # set for address-based (listing) naming @@ -2444,7 +2446,10 @@ class IdaTui(App): # -- status helper ----------------------------------------------------- # def _status(self, text: str) -> None: - self.query_one("#status", Static).update(text) + try: + self.query_one("#status", Static).update(text) + except Exception: # noqa: BLE001 -- status bar transiently unavailable + pass if self._loading_screen is not None: # mirror progress into the overlay self._loading_screen.update_note(text) @@ -2469,6 +2474,94 @@ class IdaTui(App): pass return len(text) + # -- connection loss / recovery --------------------------------------- # + def _handle_exception(self, error: BaseException) -> None: + """Intercept a lost-connection error from any worker so the whole app + doesn't die when the analysis server goes away (it can idle out, be + killed, or the box can sleep). Everything else crashes as usual.""" + from textual.worker import WorkerFailed + orig = error.error if isinstance(error, WorkerFailed) else error + if isinstance(orig, IDAConnectionError): + self._on_connection_lost() + return + super()._handle_exception(error) + + def _on_connection_lost(self) -> None: + if self._reconnecting: + return + self._reconnecting = True + self._conn_screen = LoadingScreen( + "the analysis server", note="connection lost \u2014 reconnecting\u2026") + self.push_screen(self._conn_screen) + self._reconnect() + + def _conn_note(self, text: str) -> None: + if self._conn_screen is not None: + self._conn_screen.update_note(text) + + def _dismiss_conn(self) -> None: + cs = self._conn_screen + self._conn_screen = None + if cs is not None: + try: + cs.dismiss() + except Exception: # noqa: BLE001 -- already popped (Esc) + pass + + @work(thread=True, exclusive=True, group="reconnect") + def _reconnect(self) -> None: + try: + from .launch import _ensure_server + if not _ensure_server( + self._url, self._open_path, + progress=lambda m: self.app.call_from_thread(self._conn_note, m)): + self.app.call_from_thread(self._reconnect_failed, + "analysis server is unreachable") + return + client = IDAClient(self._url, db=None) + client.connect() + if self._open_path is not None: + path = os.path.abspath(os.path.expanduser(self._open_path)) + self.app.call_from_thread( + self._conn_note, f"re-opening {os.path.basename(path)}\u2026") + res = client.call("idb_open", input_path=path, + idle_ttl_sec=self._ttl, timeout=1800.0) + if not (isinstance(res, dict) and res.get("success")): + self.app.call_from_thread(self._reconnect_failed, "re-open failed") + return + client.set_db(res["session"]["session_id"]) + else: + client.set_db(client.resolve_db()) + client.health() + if self._ka is not None: + try: + self._ka.stop() + except Exception: # noqa: BLE001 + pass + if self._do_keepalive: + self._ka = client.keepalive(interval=120.0).start() + program = Program(client) + except Exception as e: # noqa: BLE001 + self.app.call_from_thread(self._reconnect_failed, str(e)) + return + self.app.call_from_thread(self._after_reconnect, client, program) + + def _after_reconnect(self, client: IDAClient, program: "Program") -> None: + self.client = client + self.program = program + self._reconnecting = False + self._dismiss_conn() + self._status("reconnected \u2014 reloading\u2026") + self._load_functions() # rebuild the function index against the new client + cur = self._cur + if cur is not None: # refresh the current view with the new program + self._open_entry(cur, push=False) + + def _reconnect_failed(self, why: str) -> None: + self._reconnecting = False + self._conn_note(f"reconnect failed: {why} \u2014 retry on next action, or 'q'") + self._status(f"reconnect failed: {why}") + # -- connection + initial load ---------------------------------------- # @work(thread=True, exclusive=True, group="connect") def _connect(self) -> None: |
