From f4c1d9b5497fd0d38137b6b345e8171b307c1117 Mon Sep 17 00:00:00 2001 From: Duncan Ogilvie Date: Thu, 20 Aug 2026 23:55:52 +0200 Subject: Switch to ida-nexus --- idatui/__init__.py | 6 +- idatui/app.py | 42 ++-- idatui/codemode_client.py | 551 ---------------------------------------------- idatui/domain.py | 28 +-- idatui/errors.py | 2 +- idatui/launch.py | 16 +- idatui/nexus_client.py | 551 ++++++++++++++++++++++++++++++++++++++++++++++ idatui/pane.py | 18 +- idatui/pool.py | 14 +- idatui/project.py | 14 +- idatui/remote_ops.py | 4 +- idatui/remote_tools.py | 12 +- 12 files changed, 628 insertions(+), 630 deletions(-) delete mode 100644 idatui/codemode_client.py create mode 100644 idatui/nexus_client.py (limited to 'idatui') diff --git a/idatui/__init__.py b/idatui/__init__.py index 7da28b3..e89d8f6 100644 --- a/idatui/__init__.py +++ b/idatui/__init__.py @@ -1,4 +1,4 @@ -"""idatui — a keyboard-first TUI using shared IDA Code Mode databases.""" +"""idatui — a keyboard-first TUI using shared IDA Nexus databases.""" from .errors import ( IDAError, @@ -10,7 +10,7 @@ from .errors import ( IDASessionError, Session, ) -from .codemode_client import CodeModeClient +from .nexus_client import NexusClient from .domain import ( Program, FunctionIndex, @@ -25,7 +25,7 @@ from .domain import ( ) __all__ = [ - "CodeModeClient", + "NexusClient", "Program", "FunctionIndex", "DisasmModel", diff --git a/idatui/app.py b/idatui/app.py index e4c2936..05b428a 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -10,7 +10,7 @@ Design notes: without ever materializing 52k lines in a widget. * All network/domain work runs in Textual worker threads; the UI never blocks. * An address-history stack backs Enter (follow) / Esc (back), IDA-style. -* Database lifecycle is lease-based through ida_codemode: matching GUI sessions +* Database lifecycle is lease-based through ida_nexus: matching GUI sessions are reused, otherwise a shared managed idalib worker is opened on demand. """ @@ -55,7 +55,7 @@ from .highlight import CTextArea, highlight_c from .journal import Journal from .errors import IDAConnectionError -from .codemode_client import CodeModeClient, registered_database +from .nexus_client import NexusClient, registered_database from .domain import Func, Head, ListingModel, Program, Struct # Styles for the disassembly listing. @@ -1087,7 +1087,7 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru def _span_segments(h: Head, fallback: Style): """Segments for a row's disassembly text. - Uses IDA's own token classification when Code Mode supplies it; falls + Uses IDA's own token classification when IDA Nexus supplies it; falls back to the mnemonic/rest split when spans are absent or disagree with the plain text. """ @@ -5238,7 +5238,7 @@ class IdaTui(App): self._open_path = open_path self._ttl = ttl self._load_args = load_args or "" # first-open options for a headerless blob - self._new_database = False # Ctrl+L asks Code Mode for a fresh IDB + self._new_database = False # Ctrl+L asks IDA Nexus for a fresh IDB self._title = (os.path.basename(open_path) if open_path else "") #: Where we are in the execution trace, and everything that moves us. #: Owns the trace state; the _trace/_t/_trail_* properties below @@ -5247,7 +5247,7 @@ class IdaTui(App): self._do_keepalive = keepalive self._rpc_path = rpc_path self._rpc = None - self.client: CodeModeClient | None = None + self.client: NexusClient | None = None self.program: Program | None = None self._loading_screen: LoadingScreen | None = None self._ka = None @@ -5288,7 +5288,7 @@ class IdaTui(App): self.journal = Journal() self._xref_focus_name: str | None = None self._dirty = False - # One subscription for the active database. CodeModeClient debounces + # One subscription for the active database. NexusClient debounces # bursts off the Textual worker pool; the callback re-enters here on the # UI thread to invalidate and reload the visible models. self._idb_event_watch = None @@ -5362,7 +5362,7 @@ class IdaTui(App): if self._rpc_path: self._start_rpc() # A file no loader recognises has to be described before it can be - # opened, so ask BEFORE Code Mode creates it — once IDA has made a database + # opened, so ask BEFORE IDA Nexus creates it — once IDA has made a database # the answer is baked in and changing it requires a fresh-IDB reopen. if self._project is not None: ref = self._pending_load_ref() @@ -5435,7 +5435,7 @@ class IdaTui(App): ref = self._project.by_label(self._binary) if ref is not None: path, label = ref.source, ref.label - # Release our lease first. Code Mode waits for a managed worker's final + # Release our lease first. IDA Nexus waits for a managed worker's final # lease grace, then creates the replacement IDB atomically. A GUI-backed # database is rejected by _can_reload(): the TUI must never close it. self._release_database() @@ -5634,7 +5634,7 @@ class IdaTui(App): return len(text) # -- live refresh from shared IDB changes ----------------------------- # - def _start_idb_event_watch(self, client: CodeModeClient) -> None: + def _start_idb_event_watch(self, client: NexusClient) -> None: self._stop_idb_event_watch() watch = getattr(client, "watch_idb_events", None) if watch is None: # IDA-free test doubles and pre-event adapters @@ -5661,7 +5661,7 @@ class IdaTui(App): watcher.close() def _idb_event_watch_failed( - self, client: CodeModeClient, error: BaseException + self, client: NexusClient, error: BaseException ) -> None: if client is not self.client: return @@ -5685,7 +5685,7 @@ class IdaTui(App): return anchor def _refresh_idb_events( - self, client: CodeModeClient, events: tuple[dict, ...] + self, client: NexusClient, events: tuple[dict, ...] ) -> None: """Invalidate once per external edit burst and reload the active surface.""" program = self.program @@ -5768,7 +5768,7 @@ class IdaTui(App): # -- connection loss / recovery --------------------------------------- # def _handle_exception(self, error: BaseException) -> None: - """Intercept a lost Code Mode lease so the app can rediscover the DB. + """Intercept a lost IDA Nexus lease so the app can rediscover the DB. Everything unrelated to database connectivity crashes as usual. """ @@ -5812,11 +5812,11 @@ class IdaTui(App): return if self._project is not None and self._binary is not None: ref = self._project.by_label(self._binary) - client = CodeModeClient( + client = NexusClient( ref.staged, ttl=self._ttl, load_args=ref.load_args, output_database=ref.db, spawn=False) else: - client = CodeModeClient( + client = NexusClient( self._open_path, ttl=self._ttl, load_args=self._load_args, spawn=False) client.connect(progress=lambda m: self.app.call_from_thread( @@ -5826,7 +5826,7 @@ class IdaTui(App): return self.app.call_from_thread(self._after_reconnect, client, Program(client)) - def _after_reconnect(self, client: "CodeModeClient", program: "Program") -> None: + def _after_reconnect(self, client: "NexusClient", program: "Program") -> None: old_client, old_program = self.client, self.program self._stop_idb_event_watch() if old_program is not None: @@ -5886,7 +5886,7 @@ class IdaTui(App): self._load_functions() def _open_database_client(self): # type: ignore[no-untyped-def] - """Attach through Code Mode, reusing a GUI or managed idalib database.""" + """Attach through IDA Nexus, reusing a GUI or managed idalib database.""" if self._pool is not None: # project mode: the pool owns the leases label = self._binary or self._project.refs[0].label client = self._pool.get(label, progress=lambda m: @@ -5898,13 +5898,13 @@ class IdaTui(App): return client if not self._open_path: self.app.call_from_thread( - self._status, "Code Mode needs a database or executable path") + self._status, "IDA Nexus needs a database or executable path") self.app.call_from_thread(self._dismiss_loading) return None base = os.path.basename(self._open_path) self.app.call_from_thread( - self._status, f"discovering Code Mode database for {base}…") - client = CodeModeClient(self._open_path, ttl=self._ttl, + self._status, f"discovering IDA Nexus database for {base}…") + client = NexusClient(self._open_path, ttl=self._ttl, load_args=self._load_args, new_database=self._new_database) client.connect(progress=lambda m: self.app.call_from_thread( @@ -5978,7 +5978,7 @@ class IdaTui(App): @work(thread=True, exclusive=True, group="index") def _index_binary(self) -> None: """Fold this binary's symbols + strings into the project index, so it can - be searched later even when its Code Mode lease is gone.""" + be searched later even when its IDA Nexus lease is gone.""" if self._index is None or self._project is None or self._binary is None: return ref = self._project.by_label(self._binary) @@ -6081,7 +6081,7 @@ class IdaTui(App): cursor=0, push=True, is_region=True) def _can_reload(self) -> bool: - """Whether Code Mode can replace this IDB with different options. + """Whether IDA Nexus can replace this IDB with different options. A GUI database is owned by the user and has no remote close/rollback route. Managed idalib databases can be released and reopened fresh. diff --git a/idatui/codemode_client.py b/idatui/codemode_client.py deleted file mode 100644 index e95238f..0000000 --- a/idatui/codemode_client.py +++ /dev/null @@ -1,551 +0,0 @@ -"""Client adapter from ida-tui's domain operations to IDA Code Mode. - -``DatabaseHandle`` is the lifecycle boundary: it discovers an already-registered -GUI database, reuses a shared managed idalib worker, or starts one when needed. -The TUI never owns or terminates an IDA process. Closing this client releases -only its lease. - -Remote operations are ordinary typed Python functions declared in -``idatui.remote_ops``. Code Mode installs their content-addressed modules once -per handle; subsequent calls send only encoded arguments. The optimized -IDAPython listing/decompiler implementation remains real source in -``idatui.remote_tools`` and is installed through the same module interface. -""" - -from __future__ import annotations - -import os -import shlex -import threading -import time -from collections.abc import Callable -from typing import Any - -from .errors import IDAConnectionError, IDATimeoutError, IDAToolError, Session - -# ida_codemode is imported EAGERLY-IF-PRESENT but never at hard import cost. -# -# The paging/graph/trace layers and their offline test suites must keep importing -# `idatui` on a machine with no IDA and no Code Mode installed -- that is the -# house rule the stdlib-only worker client used to satisfy for free, and -# `tests/run.py --fast` (380 checks, any python3) depends on it. A hard top-level -# import here makes the whole package unimportable, so the failure is deferred to -# the first operation that genuinely needs the library. -_CODEMODE_ERROR: Exception | None = None -try: - from ida_codemode import ( - CodeModeConnectionError, - DatabaseBusyError, - DatabaseDisconnectedError, - DatabaseHandle, - DatabaseInstance, - DatabaseOpenOptions, - RemoteError, - find_database_owner, - wait_database_released, - ) -except ImportError as _exc: # library absent: usable only for offline layers - _CODEMODE_ERROR = _exc - # Bound to None rather than left undefined so the names stay patchable: the - # offline contract tests inject a fake DatabaseHandle here. - CodeModeConnectionError = DatabaseDisconnectedError = RemoteError = None # type: ignore[assignment,misc] - DatabaseBusyError = DatabaseHandle = DatabaseInstance = None # type: ignore[assignment,misc] - DatabaseOpenOptions = find_database_owner = wait_database_released = None # type: ignore[assignment] - - -def _require_codemode() -> None: - """Raise an actionable error when the Code Mode library is missing. - - Gated on the binding, not on the original import result, so a test that - injects a fake ``DatabaseHandle`` exercises the real adapter logic. - """ - if DatabaseHandle is None: - raise IDAConnectionError( - "ida-codemode is not installed in this environment " - f"({_CODEMODE_ERROR}). Install it (e.g. `uv sync`, or " - "`pip install ida-codemode`) so ida-tui can lease a " - "database." - ) from _CODEMODE_ERROR - - -def database_owner(idb_path: str, staged_path: str | None = None): - """The Code Mode instance that owns ``idb_path``/``staged_path``, else None. - - Returns None when the Code Mode library is absent: with no library there is - no client in this environment that could be holding the database, and the - IDA-free layers (project staging) must keep working. Discovery errors with - the library installed still propagate because unknown ownership is unsafe. - """ - if DatabaseHandle is None: - return None - if staged_path: - owner = find_database_owner( - staged_path, - output_database=idb_path, - timeout=0.5, - ) - return owner or find_database_owner(staged_path, timeout=0.5) - return find_database_owner(idb_path, timeout=0.5) - - -def registered_database(path: str, output_database: str | None = None) -> bool: - """Whether a live/lock-held Code Mode instance owns this target.""" - _require_codemode() - return ( - find_database_owner( - path, - output_database=output_database, - timeout=0.5, - ) - is not None - ) - - -class _NoopKeepAlive: - """Compatibility shim: the DatabaseHandle's SSE lease is the heartbeat.""" - - def __init__(self) -> None: - self.beats = self.failures = 0 - - def start(self) -> "_NoopKeepAlive": - return self - - def stop(self) -> None: - pass - - -def _parse_load_args(value: str) -> tuple[str | None, int | None, str | None]: - """Translate ida-tui's legacy first-open switches to Code Mode options. - - Code Mode has typed options for processor, natural loading address and file - type. It deliberately has no arbitrary command-line escape hatch; reject - switches we cannot represent instead of silently loading a blob wrongly. - """ - processor: str | None = None - loading_address: int | None = None - file_type: str | None = None - unsupported: list[str] = [] - try: - words = shlex.split(value or "", posix=os.name != "nt") - except ValueError as exc: - raise ValueError(f"invalid IDA load options: {exc}") from exc - for word in words: - if word.startswith("-p") and len(word) > 2: - processor = word[2:] - elif word.startswith("-b") and len(word) > 2: - try: - # IDA's -b is in 16-byte paragraphs. DatabaseHandle expects the - # natural address, which is the safer public API. - loading_address = int(word[2:], 16) << 4 - except ValueError as exc: - raise ValueError(f"invalid IDA loading address: {word!r}") from exc - elif word.startswith("-T") and len(word) > 2: - file_type = word[2:] - else: - unsupported.append(word) - if unsupported: - joined = " ".join(unsupported) - raise ValueError( - "ida-codemode cannot represent arbitrary IDA load options: " - f"{joined!r}; use processor/base/file type options instead" - ) - return processor, loading_address, file_type - - -class IDBEventListener: - """Debounced, closeable delivery of another client's IDB changes. - - Code Mode's subscription is a blocking iterator, so one daemon thread reads - it and a second waits for a quiet period before handing a batch to the UI. - Keeping the debounce here avoids a permanent Textual worker (which would - make the app's worker-idle contract impossible) and bounds refresh work to - one pass per edit burst. - """ - - def __init__( - self, - client: "CodeModeClient", - callback: Callable[[tuple[dict[str, Any], ...]], None], - *, - on_error: Callable[[BaseException], None] | None = None, - debounce: float = 0.2, - ) -> None: - self._client = client - self._callback = callback - self._on_error = on_error - self._debounce = max(float(debounce), 0.0) - self._condition = threading.Condition() - self._closed = False - self._subscription = None - self._pending: list[dict[str, Any]] = [] - self._deadline = 0.0 - self._reader = threading.Thread( - target=self._read, name="idatui-idb-events", daemon=True - ) - self._deliverer = threading.Thread( - target=self._deliver, name="idatui-idb-refresh", daemon=True - ) - self._deliverer.start() - self._reader.start() - - def _report(self, error: BaseException) -> None: - disconnected = DatabaseDisconnectedError - if isinstance(disconnected, type) and isinstance(error, disconnected): - error = self._client._connection_error(error) - with self._condition: - closed = self._closed - if not closed and self._on_error is not None: - self._on_error(error) - - def _read(self) -> None: - try: - subscription = self._client.subscribe_idb_events() - except Exception as exc: # noqa: BLE001 -- surfaced through on_error - self._report(exc) - with self._condition: - self._closed = True - self._pending.clear() - self._condition.notify_all() - return - with self._condition: - if self._closed: - subscription.close() - return - self._subscription = subscription - try: - for event in subscription: - with self._condition: - if self._closed: - break - if self._client.owns_event(event): - continue - with self._condition: - if self._closed: - break - self._pending.append(event) - self._deadline = time.monotonic() + self._debounce - self._condition.notify_all() - except Exception as exc: # noqa: BLE001 -- stream failures are recoverable - self._report(exc) - finally: - subscription.close() - with self._condition: - if self._subscription is subscription: - self._subscription = None - self._closed = True - self._pending.clear() - self._condition.notify_all() - - def _deliver(self) -> None: - while True: - with self._condition: - while not self._closed and not self._pending: - self._condition.wait() - if self._closed: - return - remaining = self._deadline - time.monotonic() - if remaining > 0: - self._condition.wait(remaining) - continue - batch = tuple(self._pending) - self._pending.clear() - try: - self._callback(batch) - except Exception as exc: # noqa: BLE001 -- keep the stream alive - self._report(exc) - - def close(self) -> None: - """Stop delivery and unblock the subscription reader.""" - with self._condition: - if self._closed: - return - self._closed = True - self._pending.clear() - subscription = self._subscription - self._condition.notify_all() - if subscription is not None: - subscription.close() - - -class CodeModeClient: - """A leased GUI/idalib database accessed through ``ida_codemode``.""" - - def __init__( - self, - binary_path: str, - *, - ttl: int = 0, - load_args: str = "", - processor: str | None = None, - loading_address: int | None = None, - file_type: str | None = None, - output_database: str | None = None, - spawn: bool = True, - new_database: bool = False, - ) -> None: - del ttl # managed-worker lifetime is lease-based, not idle-TTL based - self._path = os.path.abspath(os.path.expanduser(binary_path)) - parsed_processor, parsed_address, parsed_file_type = _parse_load_args(load_args) - self._processor = processor or parsed_processor - self._loading_address = ( - loading_address if loading_address is not None else parsed_address - ) - self._file_type = file_type or parsed_file_type - self._output_database = output_database - self._spawn = spawn - self._new_database = new_database - self._handle: DatabaseHandle | None = None - self._last_instance: DatabaseInstance | None = None - self._connect_lock = threading.Lock() - - def connect(self, timeout: float = 1800.0, progress=None) -> "CodeModeClient": - _require_codemode() - with self._connect_lock: - handle = self._handle - if handle is not None: - if handle.connected: - return self - raise IDAConnectionError( - "Code Mode database disconnected; explicit rediscovery required" - ) - if progress: - progress( - f"discovering Code Mode database for {os.path.basename(self._path)}…" - ) - try: - # A Ctrl+L reload releases its current managed-worker lease, but - # that worker remains registered during Code Mode's final-lease - # grace period. Retry only that known handoff window. A GUI or - # another long-lived client remains busy and yields a clear - # failure rather than being modified underneath its owner. - deadline = time.monotonic() + min(timeout, 60.0) - while True: - try: - handle = DatabaseHandle.open( - self._path, - options=DatabaseOpenOptions( - spawn=self._spawn, - startup_timeout=max(0.1, timeout), - output_database=self._output_database, - processor=self._processor, - # The natural byte address is converted to IDA's - # paragraph-based -b value by Code Mode. - image_base=self._loading_address, - file_type=self._file_type, - new_database=self._new_database, - ), - ) - break - except DatabaseBusyError: - if not self._new_database or time.monotonic() >= deadline: - raise - if progress: - progress( - "waiting for the previous Code Mode lease to close…" - ) - owner = find_database_owner( - self._path, - output_database=self._output_database, - timeout=0.5, - ) - if owner is not None: - wait_database_released( - owner, - max(0.0, deadline - time.monotonic()), - ) - else: - time.sleep(0.2) - if progress: - backend = handle.instance.backend - progress( - f"attached to {backend} database; waiting for auto-analysis…" - ) - handle.wait_autoanalysis(timeout=timeout) - except Exception as exc: # normalize the dependency's transport errors - raise self._connection_error(exc) from exc - self._handle = handle - self._last_instance = handle.instance - return self - - @staticmethod - def _connection_error(exc: BaseException) -> IDAConnectionError: - return IDAConnectionError(str(exc) or type(exc).__name__) - - @property - def connected(self) -> bool: - return self._handle is not None and self._handle.connected - - @property - def pid(self) -> int | None: - return self._handle.instance.pid if self._handle is not None else None - - @property - def backend(self) -> str | None: - return self._handle.instance.backend if self._handle is not None else None - - def owns_event(self, event: dict[str, Any]) -> bool: - """Whether ``event`` was produced through this client's handle.""" - handle = self._handle - return handle is not None and handle.owns_event(event) - - def subscribe_idb_events(self): - """Open Code Mode's closeable IDB-change iterator.""" - if not self.connected: - self.connect() - handle = self._handle - if handle is None: - raise IDAConnectionError("Code Mode database is not connected") - try: - return handle.subscribe_idb_events() - except (DatabaseDisconnectedError, CodeModeConnectionError) as exc: - raise self._connection_error(exc) from exc - - def watch_idb_events( - self, - callback: Callable[[tuple[dict[str, Any], ...]], None], - *, - on_error: Callable[[BaseException], None] | None = None, - debounce: float = 0.2, - ) -> IDBEventListener: - """Deliver external IDB changes in debounced batches.""" - return IDBEventListener(self, callback, on_error=on_error, debounce=debounce) - - def call(self, operation: Callable[..., Any], /, **args) -> Any: - """Execute one source-backed remote declaration through this client.""" - name = getattr(operation, "__name__", "remote operation") - try: - from .remote_ops import bind - - remote = bind(operation) - except KeyError as exc: - raise IDAToolError( - name, f"remote operation {name!r} is not registered" - ) from exc - if not self.connected: - self.connect() - handle = self._handle - if handle is None: - raise IDAConnectionError("Code Mode database is not connected") - try: - return remote(handle, **args) - except RemoteError as exc: - message = str(exc) - if exc.details.get("traceback"): - message += f"\n{exc.details['traceback']}" - if exc.code == "operation_timeout": - raise IDATimeoutError(message) from exc - raise IDAToolError(name, message) from exc - except (DatabaseDisconnectedError, CodeModeConnectionError) as exc: - raise self._connection_error(exc) from exc - - def save_database(self) -> dict[str, Any]: - if not self.connected: - self.connect() - handle = self._handle - if handle is None: - raise IDAConnectionError("Code Mode database is not connected") - try: - return handle.save_database() - except RemoteError as exc: - raise IDAToolError("save_database", str(exc)) from exc - except (DatabaseDisconnectedError, CodeModeConnectionError) as exc: - raise self._connection_error(exc) from exc - - def discard_database(self, timeout: float = 5.0) -> bool: - """Discard a final managed-worker lease; otherwise transfer finalization. - - ``False`` is an expected ownership result: a GUI owns its session, or - another lease still shares the managed worker. A busy final worker is - retried briefly so background reads finishing during quit do not turn a - real discard into an implicit save. - """ - handle = self._handle - if handle is None or not handle.connected: - return False - entry = handle.instance - if entry.backend != "idalib" or not getattr(entry, "managed", False): - return False - deadline = time.monotonic() + max(float(timeout), 0.0) - while True: - try: - handle.shutdown_database(save=False) - return True - except RemoteError as exc: - if exc.code in ("instance_shared", "shutdown_not_supported"): - return False - if exc.code == "instance_busy" and time.monotonic() < deadline: - time.sleep(0.05) - continue - raise IDAToolError("shutdown_database", str(exc)) from exc - except (DatabaseDisconnectedError, CodeModeConnectionError) as exc: - raise self._connection_error(exc) from exc - - def health(self) -> dict[str, Any]: - if not self.connected: - self.connect() - assert self._handle is not None - entry = self._handle.instance - module = os.path.basename(entry.exe_path or entry.idb_path or self._path) - return { - "ok": self._handle.connected, - "module": module, - "backend": entry.backend, - "record_id": entry.record_id, - "input_path": entry.exe_path, - "idb_path": entry.idb_path, - } - - def keepalive(self, interval: float = 120.0) -> _NoopKeepAlive: - del interval - return _NoopKeepAlive() - - def resolve_db(self) -> str: - if not self.connected: - self.connect() - assert self._handle is not None - return self._handle.instance.record_id - - def set_db(self, db: str | None) -> None: - del db # one handle is permanently bound to one registered database - - def list_sessions(self) -> list[Session]: - if not self.connected: - self.connect() - assert self._handle is not None - entry = self._handle.instance - path = entry.exe_path or entry.idb_path or self._path - return [ - Session( - session_id=entry.record_id, - filename=os.path.basename(path), - input_path=path, - is_active=True, - ) - ] - - def close(self, grace: float = 0.0) -> None: - del grace - with self._connect_lock: - handle, self._handle = self._handle, None - if handle is not None: - self._last_instance = handle.instance - handle.close() # release our lease; never close a GUI/other client's DB - - def wait_released(self, timeout: float = 45.0) -> bool: - """Wait until a managed instance releases its lifetime lock. - - Normal application shutdown must not wait: another client may retain the - worker. This is an explicit test/maintenance helper for deleting a - temporary IDB safely after this client closes. GUI instances return - ``False`` immediately because clients never own their lifetime. - """ - instance = self._last_instance - if instance is None or instance.backend != "idalib": - return False - return wait_database_released(instance, timeout) - - def __enter__(self) -> "CodeModeClient": - return self.connect() - - def __exit__(self, *exc) -> None: - self.close() diff --git a/idatui/domain.py b/idatui/domain.py index b042332..1e5a863 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -1,4 +1,4 @@ -"""Domain / paging layer: address-centric models over IDA Code Mode. +"""Domain / paging layer: address-centric models over IDA Nexus. This is where the "millions of lines" problem is solved, so the TUI widgets only ever see a viewport-sized slice. Every hard-won constraint from @@ -7,7 +7,7 @@ ever see a viewport-sized slice. Every hard-won constraint from * Page sizes remain bounded so remote execution returns viewport-scale JSON. * Pagination advances by the number of rows actually returned. * Deep head walks are block-cached (revisits are free) and neighboring blocks - prefetch through the thread-safe Code Mode client. + prefetch through the thread-safe IDA Nexus client. * Expensive function totals are fetched once and cached. * Decompilation failures are surfaced as data, not application crashes. @@ -31,7 +31,7 @@ from . import remote_ops from .errors import IDAToolError if TYPE_CHECKING: # type hint only - from .codemode_client import CodeModeClient + from .nexus_client import NexusClient # Clamps derived from measured caps (list ~700, disasm ~500). Margin included. LIST_PAGE = 500 @@ -90,7 +90,7 @@ class Line: class Head(NamedTuple): - """One flat-listing item (from the Code Mode ``heads`` operation): a code + """One flat-listing item (from the IDA Nexus ``heads`` operation): a code instruction, a data item, or an undefined byte run. A ``NamedTuple`` rather than a dataclass because this is by far the @@ -111,7 +111,7 @@ class Head(NamedTuple): name: str | None = None raw: bytes | None = None # opcode/item bytes (filled in for code by the model) #: [(kind, text)] from IDA's own colour tags — mnem/reg/num/name/str/punct/… - #: None when Code Mode didn't provide them (or the spans + #: None when IDA Nexus didn't provide them (or the spans #: disagreed with the plain text, in which case the text wins). #: #: Held exactly as it came off the wire, and **read-only**. The worker @@ -693,7 +693,7 @@ class ListingModel: """A flat, IDA-style disassembly *listing* over one segment: code, data and undefined heads interleaved, unlike ``DisasmModel`` (one function, code only). - Backed by the Code Mode adapter's ``heads`` operation, which walks item heads + Backed by the IDA Nexus adapter's ``heads`` operation, which walks item heads and renders each via ``generate_disasm_line``. The segment is walked lazily in forward pages (``FunctionIndex`` style); line index == position in the walked head list. Random access to an address is O(distance-from-seg-start) the @@ -701,7 +701,7 @@ class ListingModel: on demand as the viewport scrolls. Synchronous + thread-safe. """ - PAGE = 500 # viewport-scale heads per Code Mode execution + PAGE = 500 # viewport-scale heads per IDA Nexus execution #: Generation marker for a skeleton (text-less) page. Never equals a real #: _text_gen, which counts up from 0, so such a page always reads as stale. _SKELETON_GEN = -1 @@ -1434,7 +1434,7 @@ class HexModel: class Program: """The bound analysis session: models, caches, and a small prefetch pool.""" - def __init__(self, client: "CodeModeClient", prefetch_workers: int = 2): + def __init__(self, client: "NexusClient", prefetch_workers: int = 2): self.client = client self._pool = ThreadPoolExecutor( max_workers=prefetch_workers, thread_name_prefix="idatui-prefetch" @@ -1485,7 +1485,7 @@ class Program: """Sorted raw segment map [(start, end, file_off, name)] — the single source for sections()/file_regions()/image_range. Cached. - Uses the Code Mode adapter's ``file_regions`` operation (a plain segment + Uses the IDA Nexus adapter's ``file_regions`` operation (a plain segment walk, ~ms), avoiding broad binary surveys on the hex-pane open path. """ if self._segments_cache is not None: @@ -1573,7 +1573,7 @@ class Program: def read_bytes(self, ea: int, n: int) -> bytes: """Raw bytes [ea, ea+n) from IDA (gaps read as zero). - The Code Mode adapter returns one contiguous hex string (C-speed in IDA). + The IDA Nexus adapter returns one contiguous hex string (C-speed in IDA). A legacy ``get_bytes`` decoding fallback remains for alternate clients. """ if n <= 0: @@ -1778,7 +1778,7 @@ class Program: except IDAToolError as e: msg = e.message if "not found" in msg.lower() and "del_type" in msg: - return "the connected Code Mode runtime cannot delete local types" + return "the connected IDA Nexus runtime cannot delete local types" return msg # -- disassembly ------------------------------------------------------- # @@ -1795,7 +1795,7 @@ class Program: """Drop local and Hex-Rays caches before an explicit view refresh. Normal edit paths use generation-based invalidation. Ctrl+R is also for - changes made by another Code Mode/IDA client, for which this Program has + changes made by another IDA Nexus/IDA client, for which this Program has seen no generation bump, so it must explicitly ask Hex-Rays to discard its cached cfunc. """ @@ -1809,7 +1809,7 @@ class Program: pass def decompile(self, ea: int, refresh: bool = False) -> Decompilation: - """Full pseudocode for a function, returned directly by Code Mode.""" + """Full pseudocode for a function, returned directly by IDA Nexus.""" if not refresh: with self._lock: hit = self._decomp.get(ea) @@ -2011,7 +2011,7 @@ class Program: def define_func(self, ea: int) -> dict: """Create a function starting at ``ea`` (IDA's 'p'). - Prefers the Code Mode operation, which works out the end when IDA can't; + Prefers the IDA Nexus operation, which works out the end when IDA can't; falls back to a plain create for alternate clients. """ try: diff --git a/idatui/errors.py b/idatui/errors.py index 7632ade..ab1365a 100644 --- a/idatui/errors.py +++ b/idatui/errors.py @@ -1,6 +1,6 @@ """TUI-facing error hierarchy and lightweight database session model. -The Code Mode adapter normalizes ``ida_codemode`` transport and execution +The IDA Nexus adapter normalizes ``ida_nexus`` transport and execution errors into these types so the domain and Textual layers do not depend on HTTP or registry implementation details. """ diff --git a/idatui/launch.py b/idatui/launch.py index e3cb091..a0201a7 100644 --- a/idatui/launch.py +++ b/idatui/launch.py @@ -1,6 +1,6 @@ -"""One-shot launcher for the IDA Code Mode-backed TUI. +"""One-shot launcher for the IDA Nexus-backed TUI. -A path first resolves to a registered GUI database; when none matches, Code Mode +A path first resolves to a registered GUI database; when none matches, IDA Nexus reuses or starts a managed idalib worker. With no path, a single registered database is selected automatically. @@ -34,9 +34,9 @@ def _log(msg: str) -> None: def _registered_databases() -> tuple[list[dict], list[dict]]: - """Ready and blocked Code Mode registrations, with normalized errors.""" + """Ready and blocked IDA Nexus registrations, with normalized errors.""" try: - from ida_codemode import InstanceState, discover_databases + from ida_nexus import InstanceState, discover_databases ready: list[dict] = [] blocked: list[dict] = [] @@ -70,7 +70,7 @@ def main(argv: list[str] | None = None) -> int: help="open a multi-binary project (created from the given " "binaries if FILE doesn't exist)") p.add_argument("--ttl", type=int, default=1800, - help="deprecated compatibility option (Code Mode uses leases)") + help="deprecated compatibility option (IDA Nexus uses leases)") p.add_argument("--no-keepalive", action="store_true", help="deprecated compatibility option (the lease is the heartbeat)") p.add_argument("--rpc", metavar="PATH", @@ -87,7 +87,7 @@ def main(argv: list[str] | None = None) -> int: g.add_argument("--base", metavar="ADDR", help="load address, e.g. 0x8000000 (any base; NOT paragraphs)") g.add_argument("--ida-args", metavar="STR", dest="ida_args", - help="legacy switches; only Code Mode-representable -p/-b/-T are accepted") + help="legacy switches; only IDA Nexus-representable -p/-b/-T are accepted") args = p.parse_args(argv) load: dict = {} @@ -166,10 +166,10 @@ def main(argv: list[str] | None = None) -> int: _log(f"attaching to registered {item.get('backend')} database: {binary}") elif not ready: detail = f" ({blocked[0].get('error')})" if blocked else "" - _log(f"no registered Code Mode database; pass a binary path{detail}") + _log(f"no registered IDA Nexus database; pass a binary path{detail}") return 2 else: - _log("several Code Mode databases are registered; pass one of these paths:") + _log("several IDA Nexus databases are registered; pass one of these paths:") for item in ready: _log(f" {item.get('exe_path') or item.get('idb_path')} " f"[{item.get('backend')}, {item.get('record_id')}]") diff --git a/idatui/nexus_client.py b/idatui/nexus_client.py new file mode 100644 index 0000000..44eb1b1 --- /dev/null +++ b/idatui/nexus_client.py @@ -0,0 +1,551 @@ +"""Client adapter from ida-tui's domain operations to IDA Nexus. + +``DatabaseHandle`` is the lifecycle boundary: it discovers an already-registered +GUI database, reuses a shared managed idalib worker, or starts one when needed. +The TUI never owns or terminates an IDA process. Closing this client releases +only its lease. + +Remote operations are ordinary typed Python functions declared in +``idatui.remote_ops``. IDA Nexus installs their content-addressed modules once +per IDA Python interpreter; subsequent calls send only encoded arguments. The +optimized IDAPython listing/decompiler implementation remains real source in +``idatui.remote_tools`` and is installed through the same module interface. +""" + +from __future__ import annotations + +import os +import shlex +import threading +import time +from collections.abc import Callable +from typing import Any + +from .errors import IDAConnectionError, IDATimeoutError, IDAToolError, Session + +# ida_nexus is imported EAGERLY-IF-PRESENT but never at hard import cost. +# +# The paging/graph/trace layers and their offline test suites must keep importing +# `idatui` on a machine with no IDA and no IDA Nexus installed -- that is the +# house rule the stdlib-only worker client used to satisfy for free, and +# `tests/run.py --fast` (380 checks, any python3) depends on it. A hard top-level +# import here makes the whole package unimportable, so the failure is deferred to +# the first operation that genuinely needs the library. +_NEXUS_ERROR: Exception | None = None +try: + from ida_nexus import ( + DatabaseBusyError, + DatabaseDisconnectedError, + DatabaseHandle, + DatabaseInstance, + DatabaseOpenOptions, + NexusConnectionError, + RemoteError, + find_database_owner, + wait_database_released, + ) +except ImportError as _exc: # library absent: usable only for offline layers + _NEXUS_ERROR = _exc + # Bound to None rather than left undefined so the names stay patchable: the + # offline contract tests inject a fake DatabaseHandle here. + NexusConnectionError = DatabaseDisconnectedError = RemoteError = None # type: ignore[assignment,misc] + DatabaseBusyError = DatabaseHandle = DatabaseInstance = None # type: ignore[assignment,misc] + DatabaseOpenOptions = find_database_owner = wait_database_released = None # type: ignore[assignment] + + +def _require_nexus() -> None: + """Raise an actionable error when the IDA Nexus library is missing. + + Gated on the binding, not on the original import result, so a test that + injects a fake ``DatabaseHandle`` exercises the real adapter logic. + """ + if DatabaseHandle is None: + raise IDAConnectionError( + "ida-nexus is not installed in this environment " + f"({_NEXUS_ERROR}). Install it (e.g. `uv sync`, or " + "`pip install ida-nexus`) so ida-tui can lease a " + "database." + ) from _NEXUS_ERROR + + +def database_owner(idb_path: str, staged_path: str | None = None): + """The IDA Nexus instance that owns ``idb_path``/``staged_path``, else None. + + Returns None when the IDA Nexus library is absent: with no library there is + no client in this environment that could be holding the database, and the + IDA-free layers (project staging) must keep working. Discovery errors with + the library installed still propagate because unknown ownership is unsafe. + """ + if DatabaseHandle is None: + return None + if staged_path: + owner = find_database_owner( + staged_path, + output_database=idb_path, + timeout=0.5, + ) + return owner or find_database_owner(staged_path, timeout=0.5) + return find_database_owner(idb_path, timeout=0.5) + + +def registered_database(path: str, output_database: str | None = None) -> bool: + """Whether a live/lock-held IDA Nexus instance owns this target.""" + _require_nexus() + return ( + find_database_owner( + path, + output_database=output_database, + timeout=0.5, + ) + is not None + ) + + +class _NoopKeepAlive: + """Compatibility shim: the DatabaseHandle's SSE lease is the heartbeat.""" + + def __init__(self) -> None: + self.beats = self.failures = 0 + + def start(self) -> "_NoopKeepAlive": + return self + + def stop(self) -> None: + pass + + +def _parse_load_args(value: str) -> tuple[str | None, int | None, str | None]: + """Translate ida-tui's legacy first-open switches to IDA Nexus options. + + IDA Nexus has typed options for processor, natural loading address and file + type. It deliberately has no arbitrary command-line escape hatch; reject + switches we cannot represent instead of silently loading a blob wrongly. + """ + processor: str | None = None + loading_address: int | None = None + file_type: str | None = None + unsupported: list[str] = [] + try: + words = shlex.split(value or "", posix=os.name != "nt") + except ValueError as exc: + raise ValueError(f"invalid IDA load options: {exc}") from exc + for word in words: + if word.startswith("-p") and len(word) > 2: + processor = word[2:] + elif word.startswith("-b") and len(word) > 2: + try: + # IDA's -b is in 16-byte paragraphs. DatabaseHandle expects the + # natural address, which is the safer public API. + loading_address = int(word[2:], 16) << 4 + except ValueError as exc: + raise ValueError(f"invalid IDA loading address: {word!r}") from exc + elif word.startswith("-T") and len(word) > 2: + file_type = word[2:] + else: + unsupported.append(word) + if unsupported: + joined = " ".join(unsupported) + raise ValueError( + "ida-nexus cannot represent arbitrary IDA load options: " + f"{joined!r}; use processor/base/file type options instead" + ) + return processor, loading_address, file_type + + +class IDBEventListener: + """Debounced, closeable delivery of another client's IDB changes. + + IDA Nexus's subscription is a blocking iterator, so one daemon thread reads + it and a second waits for a quiet period before handing a batch to the UI. + Keeping the debounce here avoids a permanent Textual worker (which would + make the app's worker-idle contract impossible) and bounds refresh work to + one pass per edit burst. + """ + + def __init__( + self, + client: "NexusClient", + callback: Callable[[tuple[dict[str, Any], ...]], None], + *, + on_error: Callable[[BaseException], None] | None = None, + debounce: float = 0.2, + ) -> None: + self._client = client + self._callback = callback + self._on_error = on_error + self._debounce = max(float(debounce), 0.0) + self._condition = threading.Condition() + self._closed = False + self._subscription = None + self._pending: list[dict[str, Any]] = [] + self._deadline = 0.0 + self._reader = threading.Thread( + target=self._read, name="idatui-idb-events", daemon=True + ) + self._deliverer = threading.Thread( + target=self._deliver, name="idatui-idb-refresh", daemon=True + ) + self._deliverer.start() + self._reader.start() + + def _report(self, error: BaseException) -> None: + disconnected = DatabaseDisconnectedError + if isinstance(disconnected, type) and isinstance(error, disconnected): + error = self._client._connection_error(error) + with self._condition: + closed = self._closed + if not closed and self._on_error is not None: + self._on_error(error) + + def _read(self) -> None: + try: + subscription = self._client.subscribe_idb_events() + except Exception as exc: # noqa: BLE001 -- surfaced through on_error + self._report(exc) + with self._condition: + self._closed = True + self._pending.clear() + self._condition.notify_all() + return + with self._condition: + if self._closed: + subscription.close() + return + self._subscription = subscription + try: + for event in subscription: + with self._condition: + if self._closed: + break + if self._client.owns_event(event): + continue + with self._condition: + if self._closed: + break + self._pending.append(event) + self._deadline = time.monotonic() + self._debounce + self._condition.notify_all() + except Exception as exc: # noqa: BLE001 -- stream failures are recoverable + self._report(exc) + finally: + subscription.close() + with self._condition: + if self._subscription is subscription: + self._subscription = None + self._closed = True + self._pending.clear() + self._condition.notify_all() + + def _deliver(self) -> None: + while True: + with self._condition: + while not self._closed and not self._pending: + self._condition.wait() + if self._closed: + return + remaining = self._deadline - time.monotonic() + if remaining > 0: + self._condition.wait(remaining) + continue + batch = tuple(self._pending) + self._pending.clear() + try: + self._callback(batch) + except Exception as exc: # noqa: BLE001 -- keep the stream alive + self._report(exc) + + def close(self) -> None: + """Stop delivery and unblock the subscription reader.""" + with self._condition: + if self._closed: + return + self._closed = True + self._pending.clear() + subscription = self._subscription + self._condition.notify_all() + if subscription is not None: + subscription.close() + + +class NexusClient: + """A leased GUI/idalib database accessed through ``ida_nexus``.""" + + def __init__( + self, + binary_path: str, + *, + ttl: int = 0, + load_args: str = "", + processor: str | None = None, + loading_address: int | None = None, + file_type: str | None = None, + output_database: str | None = None, + spawn: bool = True, + new_database: bool = False, + ) -> None: + del ttl # managed-worker lifetime is lease-based, not idle-TTL based + self._path = os.path.abspath(os.path.expanduser(binary_path)) + parsed_processor, parsed_address, parsed_file_type = _parse_load_args(load_args) + self._processor = processor or parsed_processor + self._loading_address = ( + loading_address if loading_address is not None else parsed_address + ) + self._file_type = file_type or parsed_file_type + self._output_database = output_database + self._spawn = spawn + self._new_database = new_database + self._handle: DatabaseHandle | None = None + self._last_instance: DatabaseInstance | None = None + self._connect_lock = threading.Lock() + + def connect(self, timeout: float = 1800.0, progress=None) -> "NexusClient": + _require_nexus() + with self._connect_lock: + handle = self._handle + if handle is not None: + if handle.connected: + return self + raise IDAConnectionError( + "IDA Nexus database disconnected; explicit rediscovery required" + ) + if progress: + progress( + f"discovering IDA Nexus database for {os.path.basename(self._path)}…" + ) + try: + # A Ctrl+L reload releases its current managed-worker lease, but + # that worker remains registered during IDA Nexus's final-lease + # grace period. Retry only that known handoff window. A GUI or + # another long-lived client remains busy and yields a clear + # failure rather than being modified underneath its owner. + deadline = time.monotonic() + min(timeout, 60.0) + while True: + try: + handle = DatabaseHandle.open( + self._path, + options=DatabaseOpenOptions( + spawn=self._spawn, + startup_timeout=max(0.1, timeout), + output_database=self._output_database, + processor=self._processor, + # The natural byte address is converted to IDA's + # paragraph-based -b value by IDA Nexus. + image_base=self._loading_address, + file_type=self._file_type, + new_database=self._new_database, + ), + ) + break + except DatabaseBusyError: + if not self._new_database or time.monotonic() >= deadline: + raise + if progress: + progress( + "waiting for the previous IDA Nexus lease to close…" + ) + owner = find_database_owner( + self._path, + output_database=self._output_database, + timeout=0.5, + ) + if owner is not None: + wait_database_released( + owner, + max(0.0, deadline - time.monotonic()), + ) + else: + time.sleep(0.2) + if progress: + backend = handle.instance.backend + progress( + f"attached to {backend} database; waiting for auto-analysis…" + ) + handle.wait_autoanalysis(timeout=timeout) + except Exception as exc: # normalize the dependency's transport errors + raise self._connection_error(exc) from exc + self._handle = handle + self._last_instance = handle.instance + return self + + @staticmethod + def _connection_error(exc: BaseException) -> IDAConnectionError: + return IDAConnectionError(str(exc) or type(exc).__name__) + + @property + def connected(self) -> bool: + return self._handle is not None and self._handle.connected + + @property + def pid(self) -> int | None: + return self._handle.instance.pid if self._handle is not None else None + + @property + def backend(self) -> str | None: + return self._handle.instance.backend if self._handle is not None else None + + def owns_event(self, event: dict[str, Any]) -> bool: + """Whether ``event`` was produced through this client's handle.""" + handle = self._handle + return handle is not None and handle.owns_event(event) + + def subscribe_idb_events(self): + """Open IDA Nexus's closeable IDB-change iterator.""" + if not self.connected: + self.connect() + handle = self._handle + if handle is None: + raise IDAConnectionError("IDA Nexus database is not connected") + try: + return handle.subscribe_idb_events() + except (DatabaseDisconnectedError, NexusConnectionError) as exc: + raise self._connection_error(exc) from exc + + def watch_idb_events( + self, + callback: Callable[[tuple[dict[str, Any], ...]], None], + *, + on_error: Callable[[BaseException], None] | None = None, + debounce: float = 0.2, + ) -> IDBEventListener: + """Deliver external IDB changes in debounced batches.""" + return IDBEventListener(self, callback, on_error=on_error, debounce=debounce) + + def call(self, operation: Callable[..., Any], /, **args) -> Any: + """Execute one source-backed remote declaration through this client.""" + name = getattr(operation, "__name__", "remote operation") + try: + from .remote_ops import bind + + remote = bind(operation) + except KeyError as exc: + raise IDAToolError( + name, f"remote operation {name!r} is not registered" + ) from exc + if not self.connected: + self.connect() + handle = self._handle + if handle is None: + raise IDAConnectionError("IDA Nexus database is not connected") + try: + return remote(handle, **args) + except RemoteError as exc: + message = str(exc) + if exc.details.get("traceback"): + message += f"\n{exc.details['traceback']}" + if exc.code == "operation_timeout": + raise IDATimeoutError(message) from exc + raise IDAToolError(name, message) from exc + except (DatabaseDisconnectedError, NexusConnectionError) as exc: + raise self._connection_error(exc) from exc + + def save_database(self) -> dict[str, Any]: + if not self.connected: + self.connect() + handle = self._handle + if handle is None: + raise IDAConnectionError("IDA Nexus database is not connected") + try: + return handle.save_database() + except RemoteError as exc: + raise IDAToolError("save_database", str(exc)) from exc + except (DatabaseDisconnectedError, NexusConnectionError) as exc: + raise self._connection_error(exc) from exc + + def discard_database(self, timeout: float = 5.0) -> bool: + """Discard a final managed-worker lease; otherwise transfer finalization. + + ``False`` is an expected ownership result: a GUI owns its session, or + another lease still shares the managed worker. A busy final worker is + retried briefly so background reads finishing during quit do not turn a + real discard into an implicit save. + """ + handle = self._handle + if handle is None or not handle.connected: + return False + entry = handle.instance + if entry.backend != "idalib" or not getattr(entry, "managed", False): + return False + deadline = time.monotonic() + max(float(timeout), 0.0) + while True: + try: + handle.shutdown_database(save=False) + return True + except RemoteError as exc: + if exc.code in ("instance_shared", "shutdown_not_supported"): + return False + if exc.code == "instance_busy" and time.monotonic() < deadline: + time.sleep(0.05) + continue + raise IDAToolError("shutdown_database", str(exc)) from exc + except (DatabaseDisconnectedError, NexusConnectionError) as exc: + raise self._connection_error(exc) from exc + + def health(self) -> dict[str, Any]: + if not self.connected: + self.connect() + assert self._handle is not None + entry = self._handle.instance + module = os.path.basename(entry.exe_path or entry.idb_path or self._path) + return { + "ok": self._handle.connected, + "module": module, + "backend": entry.backend, + "record_id": entry.record_id, + "input_path": entry.exe_path, + "idb_path": entry.idb_path, + } + + def keepalive(self, interval: float = 120.0) -> _NoopKeepAlive: + del interval + return _NoopKeepAlive() + + def resolve_db(self) -> str: + if not self.connected: + self.connect() + assert self._handle is not None + return self._handle.instance.record_id + + def set_db(self, db: str | None) -> None: + del db # one handle is permanently bound to one registered database + + def list_sessions(self) -> list[Session]: + if not self.connected: + self.connect() + assert self._handle is not None + entry = self._handle.instance + path = entry.exe_path or entry.idb_path or self._path + return [ + Session( + session_id=entry.record_id, + filename=os.path.basename(path), + input_path=path, + is_active=True, + ) + ] + + def close(self, grace: float = 0.0) -> None: + del grace + with self._connect_lock: + handle, self._handle = self._handle, None + if handle is not None: + self._last_instance = handle.instance + handle.close() # release our lease; never close a GUI/other client's DB + + def wait_released(self, timeout: float = 45.0) -> bool: + """Wait until a managed instance releases its lifetime lock. + + Normal application shutdown must not wait: another client may retain the + worker. This is an explicit test/maintenance helper for deleting a + temporary IDB safely after this client closes. GUI instances return + ``False`` immediately because clients never own their lifetime. + """ + instance = self._last_instance + if instance is None or instance.backend != "idalib": + return False + return wait_database_released(instance, timeout) + + def __enter__(self) -> "NexusClient": + return self.connect() + + def __exit__(self, *exc) -> None: + self.close() diff --git a/idatui/pane.py b/idatui/pane.py index c31a93c..1eee847 100644 --- a/idatui/pane.py +++ b/idatui/pane.py @@ -24,7 +24,7 @@ per pane in the registry, so stop/list/capture/keys keep working across both python -m idatui.pane keys --pane Escape Requires: running inside tmux or zellij. Each pane leases a registered GUI or -shared managed idalib database through Code Mode. Uses ~/ida-venv/bin/python for +shared managed idalib database through IDA Nexus. Uses ~/ida-venv/bin/python for the TUI (needs textual) unless --python / IDATUI_PYTHON says otherwise. """ from __future__ import annotations @@ -250,8 +250,8 @@ def _pane_keys(pane: str, keys: list[str], mux: str | None = None) -> None: subprocess.run(["tmux", "send-keys", "-t", pane, *keys], check=True) -# Code Mode owns database process lifetime: a closed pane drops its lease at the -# socket/kernel boundary and Code Mode decides whether a managed worker still +# IDA Nexus owns database process lifetime: a closed pane drops its lease at the +# socket/kernel boundary and IDA Nexus decides whether a managed worker still # has clients. There is nothing for the pane layer to reap. @@ -261,7 +261,7 @@ def _count_live_panes() -> int: def _reap_orphan_workers(force: bool = False) -> int: - """Compatibility no-op: Code Mode workers are shared and lease-managed.""" + """Compatibility no-op: IDA Nexus workers are shared and lease-managed.""" del force return 0 @@ -294,7 +294,7 @@ def spawn(args) -> int: print(f"error: no such project: {project}", file=sys.stderr) return 2 - # The pane owns only the TUI. Code Mode's lease cleanup handles crashes; + # The pane owns only the TUI. IDA Nexus's lease cleanup handles crashes; # kill-pane must never reap a shared GUI/idalib database. if project is not None: # launch takes: --project FILE [binaries...]; extra binaries are added to @@ -347,7 +347,7 @@ def _wait_ready(sock: str, timeout: float, pane: str, stuck_after: float = 45.0, mux: str | None = None) -> dict[str, Any]: """Poll the socket + ping until the TUI reports ready (or timeout). - Emits a one-time hint if Code Mode discovery/opening is still not ready after + Emits a one-time hint if IDA Nexus discovery/opening is still not ready after ``stuck_after`` seconds. """ start = time.time() @@ -370,7 +370,7 @@ def _wait_ready(sock: str, timeout: float, pane: str, why = ("RPC socket not created yet" if not os.path.exists(sock) else "TUI up but analysis not ready") print(f"still waiting ({int(time.time() - start)}s): {why}. " - f"Check Code Mode registrations and worker logs.", file=sys.stderr) + f"Check IDA Nexus registrations and worker logs.", file=sys.stderr) time.sleep(0.4) last = dict(last) last["ready"] = False @@ -467,7 +467,7 @@ def list_panes(args) -> int: def reap(args) -> int: - """Deprecated no-op; shared Code Mode workers are managed by leases.""" + """Deprecated no-op; shared IDA Nexus workers are managed by leases.""" print(json.dumps({"reaped_workers": 0, "live_panes": _count_live_panes(), "forced": args.force, "deprecated": True})) return 0 @@ -572,7 +572,7 @@ def main(argv: list[str]) -> int: ls.add_argument("--prune", action="store_true", help="drop dead panes (and their sockets)") ls.set_defaults(fn=list_panes) - rp = sub.add_parser("reap", help="deprecated no-op (Code Mode uses shared leases)") + rp = sub.add_parser("reap", help="deprecated no-op (IDA Nexus uses shared leases)") rp.add_argument("--force", action="store_true", help=argparse.SUPPRESS) rp.set_defaults(fn=reap) diff --git a/idatui/pool.py b/idatui/pool.py index 573aa75..2407b44 100644 --- a/idatui/pool.py +++ b/idatui/pool.py @@ -1,6 +1,6 @@ -"""DatabasePool — LRU leases on Code Mode databases for a project. +"""DatabasePool — LRU leases on IDA Nexus databases for a project. -Code Mode may bind a lease to an existing IDA GUI or to a shared managed idalib +IDA Nexus may bind a lease to an existing IDA GUI or to a shared managed idalib worker. The pool therefore owns *client interest*, never an IDA process. Releasing an LRU entry persists managed IDBs but does not implicitly save a GUI, then closes only this TUI's lease; other clients and GUI sessions remain alive. Managed workers exit themselves after their final lease. @@ -48,8 +48,8 @@ def _pss_mb(pid: int | None) -> int: def _default_spawn(ref: BinaryRef, ttl: int, *, new_database: bool = False): # pragma: no cover - needs IDA - from .codemode_client import CodeModeClient - return CodeModeClient( + from .nexus_client import NexusClient + return NexusClient( ref.staged, ttl=ttl, load_args=ref.load_args, @@ -59,7 +59,7 @@ def _default_spawn(ref: BinaryRef, ttl: int, *, new_database: bool = False): # class DatabasePool: - """Live Code Mode database leases, keyed by project label.""" + """Live IDA Nexus database leases, keyed by project label.""" def __init__(self, project: Project, *, budget_mb: int | None = None, ttl: int = 1800, spawn=None, mem_fn=None) -> None: @@ -101,7 +101,7 @@ class DatabasePool: """A live client for ``label``, attaching or spawning as needed. Do not sweep IDA scratch files here: a registered GUI or another Code - Mode client may own the database. Code Mode's registry locks and health + Mode client may own the database. IDA Nexus's registry locks and health probes are the authority for safe discovery and stale-record cleanup. """ client = self._clients.get(label) @@ -269,5 +269,3 @@ class DatabasePool: f"{self.memory_mb()}/{self.budget_mb}MB active={self.active}>") -# Source compatibility for callers that imported the pre-Code-Mode name. -WorkerPool = DatabasePool diff --git a/idatui/project.py b/idatui/project.py index 53f3b0a..e681dfc 100644 --- a/idatui/project.py +++ b/idatui/project.py @@ -23,7 +23,7 @@ firmware image, a cleaned build tree). A source whose size/mtime no longer matches the staged copy is re-staged, and its now-stale database is dropped (the DB describes the old bytes). -The model has no IDA imports. Staging consults ida_codemode's registry before +The model has no IDA imports. Staging consults ida_nexus's registry before replacing files so it never mutates a database owned by a GUI/shared worker. """ from __future__ import annotations @@ -57,7 +57,7 @@ class BinaryRef: #: it is, a raw firmware image doesn't, and IDA defaults to metapc at 0. processor: str = "" # IDA processor name: arm, armb, mipsb, metapc, … base: int = 0 # load address (natural, e.g. 0x8000000) - ida_args: str = "" # legacy -p/-b/-T switches accepted by Code Mode adapter + ida_args: str = "" # legacy -p/-b/-T switches accepted by IDA Nexus adapter @property def db(self) -> str: @@ -311,7 +311,7 @@ class Project: """Ensure ``ref`` is staged in the sidecar; returns the staged path. Re-staging a changed source drops its database: the DB describes the old - bytes. Refuse while Code Mode reports a GUI/idalib owner; replacing a + bytes. Refuse while IDA Nexus reports a GUI/idalib owner; replacing a staged executable or IDB underneath a shared live instance is corruption. """ if not os.path.isfile(ref.source): @@ -319,15 +319,15 @@ class Project: if not self.is_stale(ref): return ref.staged try: - from .codemode_client import database_owner + from .nexus_client import database_owner owner = database_owner(ref.db, ref.staged) except Exception as exc: raise ProjectError( - f"cannot verify Code Mode ownership before staging {ref.label}: {exc}" + f"cannot verify IDA Nexus ownership before staging {ref.label}: {exc}" ) from exc if owner is not None: raise ProjectError( - f"cannot restage {ref.label}: Code Mode instance {owner.record_id} " + f"cannot restage {ref.label}: IDA Nexus instance {owner.record_id} " f"still owns {owner.idb_path}; close/release it first" ) os.makedirs(self.bin_dir, exist_ok=True) @@ -353,7 +353,7 @@ class Project: def sweep_scratch(self, ref: BinaryRef) -> int: """Delete unpacked working files (never the ``.i64``) for maintenance. - Runtime paths no longer call this: Code Mode instances are shared, so a + Runtime paths no longer call this: IDA Nexus instances are shared, so a registry owner may still be using these files. Callers must independently prove that no GUI/idalib instance owns the database. """ diff --git a/idatui/remote_ops.py b/idatui/remote_ops.py index 91ffeb7..a67b2b7 100644 --- a/idatui/remote_ops.py +++ b/idatui/remote_ops.py @@ -1,4 +1,4 @@ -"""Typed remote operations executed through ida-codemode.""" +"""Typed remote operations executed through ida-nexus.""" from __future__ import annotations # ruff: noqa @@ -1655,7 +1655,7 @@ def _bindings() -> dict[Callable[..., Any], Any]: with _BIND_LOCK: if _BOUND is not None: return _BOUND - from ida_codemode import RemoteModule + from ida_nexus import RemoteModule operations_module = RemoteModule( Path(__file__), operation_label=operation_label, codec="json" diff --git a/idatui/remote_tools.py b/idatui/remote_tools.py index db04244..6681379 100644 --- a/idatui/remote_tools.py +++ b/idatui/remote_tools.py @@ -1,8 +1,8 @@ -"""The IDAPython ida-tui runs inside the Code Mode sandbox. +"""The IDAPython ida-tui runs inside the IDA Nexus sandbox. Two features have no ida-domain surface at all and are carried over VERBATIM from the tools ida-tui was developed against (`server/patch_server.py`'s -injected BODY, which the Code Mode port deletes): +injected BODY, which the IDA Nexus port deletes): * `heads` -- the continuous listing. ida-domain enumerates defined heads and renders plain disassembly; the listing also needs coalesced undefined runs, @@ -20,8 +20,8 @@ what you see). A re-implementation drifts from it silently. This file is SOURCE SHIPPED AS TEXT to the database process; it is never imported here, because the ida_* modules do not exist in the TUI's interpreter. -`codemode_client` reads it and prepends it to the relevant snippets. Keep it -self-contained: no relative imports, nothing beyond what Code Mode provides. +`nexus_client` reads it and prepends it to the relevant snippets. Keep it +self-contained: no relative imports, nothing beyond what IDA Nexus provides. """ # ruff: noqa @@ -29,7 +29,7 @@ import re as _re # IDAPython, imported ONCE at module scope. # -# This file is never imported by the client -- codemode_client reads it as +# This file is never imported by the client -- nexus_client reads it as # TEXT and installs it as a module inside the database process -- so the # no-IDA house rule that keeps idatui importable without IDA does not apply # here, and these need not be function-local. @@ -1740,7 +1740,7 @@ def decompile(addr, include_addresses=True): Faithful to the tool ida-tui was written against, and in particular to its COST: the per-line address anchor comes from ONE ``get_line_item`` at column - 0 per line. The Code Mode port asked for the full per-column line map (what + 0 per line. The IDA Nexus port asked for the full per-column line map (what ``decomp_map`` is for) purely to fill in that anchor, which is thousands of ``get_line_item``+``dstr()`` calls per function instead of one per line, and made every pseudocode open cost the same as opening the split view. -- cgit v1.3.1-sl0p