diff options
| author | blasty <blasty@local> | 2026-07-24 01:37:48 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-24 01:37:48 +0200 |
| commit | f925701e003f05160d46cc3efd8eacf3ea256aeb (patch) | |
| tree | 6ca179a93898d66d7fcb462ca192168c8f852a65 | |
| parent | worker: idalib worker + WorkerClient (drop-in for IDAClient) — migration st... (diff) | |
| download | ida-tui-f925701e003f05160d46cc3efd8eacf3ea256aeb.tar.gz ida-tui-f925701e003f05160d46cc3efd8eacf3ea256aeb.tar.xz ida-tui-f925701e003f05160d46cc3efd8eacf3ea256aeb.zip | |
app: --backend {mcp,worker} — run the TUI on our idalib worker (migration step 3)
Wires WorkerClient into the app as a selectable backend, so you can launch:
ida-tui --backend worker /path/to/binary # or IDATUI_BACKEND=worker
* IdaTui gains a `backend` param. _connect is refactored into _open_mcp_client()
(the existing ida-pro-mcp path, unchanged) and _open_worker_client() (spawns a
private idalib worker via WorkerClient that opens+analyzes THIS binary and
streams progress into the loading overlay). The common tail (health, keepalive,
Program, load_functions) is shared, so domain.py and every view are untouched.
* _reconnect branches the same way: a dropped worker (segfault → closed socket)
respawns a fresh WorkerClient — the connection-loss recovery already built
works verbatim for the worker.
* launch.py adds --backend (env IDATUI_BACKEND); the worker path skips all the
supervisor/server plumbing (it owns a private worker) and just sweeps stale
locks + requires a binary. keepalive is a no-op for the worker (it never idles
out). mcp remains the default — nothing changes unless you opt in.
Verified without idalib: all four files parse; --backend is in --help; IdaTui
constructs for both backends with both openers present; WorkerClient covers the
full client surface. The idalib E2E (experiments/worker_smoke.py, and actually
launching --backend worker) still can't run in this sandbox — it now reaps every
idalib spawn before a byte is written — but the mcp default is untouched and the
worker path reuses proven pieces (the unix protocol benched at ~50us/call; the
worker dispatches the same tool functions the HTTP path does).
To validate on a real box: ida-tui --backend worker targets/echo (or run
experiments/worker_smoke.py for the headless read-path check).
| -rw-r--r-- | idatui/app.py | 139 | ||||
| -rw-r--r-- | idatui/launch.py | 42 |
2 files changed, 115 insertions, 66 deletions
diff --git a/idatui/app.py b/idatui/app.py index 6a47fd3..1936153 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -2317,13 +2317,15 @@ class IdaTui(App): def __init__(self, url: str, db: str | None, keepalive: bool = True, open_path: str | None = None, rpc_path: str | None = None, - ttl: int = 1800, ensure_server: bool = False) -> None: + ttl: int = 1800, ensure_server: bool = False, + backend: str = "mcp") -> None: super().__init__() self._url = url self._db = db self._open_path = open_path self._ttl = ttl self._ensure_server = ensure_server + self._backend = backend # "mcp" (ida-pro-mcp) or "worker" (our idalib worker) self._do_keepalive = keepalive self._rpc_path = rpc_path self._rpc = None @@ -2511,6 +2513,18 @@ class IdaTui(App): @work(thread=True, exclusive=True, group="reconnect") def _reconnect(self) -> None: try: + if self._backend == "worker": + from .worker_client import WorkerClient + if self._open_path is None: + self.app.call_from_thread(self._reconnect_failed, + "no binary to reopen") + return + client = WorkerClient(self._open_path, ttl=self._ttl) + client.connect(progress=lambda m: self.app.call_from_thread( + self._conn_note, m)) + self.app.call_from_thread(self._after_reconnect, client, + Program(client)) + return from .launch import _ensure_server if not _ensure_server( self._url, self._open_path, @@ -2566,59 +2580,14 @@ class IdaTui(App): @work(thread=True, exclusive=True, group="connect") def _connect(self) -> None: try: - if self._ensure_server: - # Start the analysis server here (not in the launcher) so the - # chrome + overlay are already up while we wait for it. - from .launch import _ensure_server as _ensure - if not _ensure(self._url, self._open_path, - progress=lambda m: self.app.call_from_thread( - self._status, m)): - self.app.call_from_thread( - self._status, "could not start the analysis server") - self.app.call_from_thread(self._dismiss_loading) - return - client = IDAClient(self._url, db=self._db) - client.connect() - if self._open_path is not None: - path = os.path.abspath(os.path.expanduser(self._open_path)) - base = os.path.basename(path) - self.app.call_from_thread( - self._status, f"opening {base} — analyzing…") - - def _do_open(): - # Moderate idle TTL: the keepalive heartbeat (below) keeps the - # session alive while the TUI runs; once it exits the worker - # idles out and frees its slot (avoids piling to max-workers). - return client.call("idb_open", input_path=path, - idle_ttl_sec=self._ttl, timeout=1800.0) - - res = _do_open() - if not (isinstance(res, dict) and res.get("success")): - # A hard-killed worker can wedge the DB; sweep its stale - # unpacked lock files (never the .i64) and retry once. - try: - from .launch import _sweep_locks - swept = _sweep_locks(path) - except Exception: # noqa: BLE001 - swept = 0 - if swept: - self.app.call_from_thread( - self._status, f"recovering {base} (removed {swept} " - f"stale lock file(s))…") - res = _do_open() - if not (isinstance(res, dict) and res.get("success")): - err = res.get("error") if isinstance(res, dict) else res - self.app.call_from_thread(self._status, f"open failed: {err}") - self.app.call_from_thread(self._dismiss_loading) - return - client.set_db(res["session"]["session_id"]) - elif self._db is None: - client.set_db(client.resolve_db()) - health = client.health() - module = health.get("module", "?") + client = (self._open_worker_client() if self._backend == "worker" + else self._open_mcp_client()) + if client is None: + return # the opener already reported + dismissed the overlay + module = client.health().get("module", "?") if self._do_keepalive: # Keep the session warm while we run; don't make it immortal, so - # it's reclaimed after the TUI closes. + # it's reclaimed after the TUI closes. (No-op for the worker.) self._ka = client.keepalive(interval=120.0).start() program = Program(client) except Exception as e: # noqa: BLE001 @@ -2630,6 +2599,72 @@ class IdaTui(App): self.app.call_from_thread(self._status, f"{module} — loading functions…") self._load_functions() + def _open_mcp_client(self): # type: ignore[no-untyped-def] + """ida-pro-mcp path: ensure the server, connect, open/adopt the DB. + Returns the client, or None after reporting the failure.""" + if self._ensure_server: + # Start the analysis server here (not in the launcher) so the chrome + # + overlay are already up while we wait for it. + from .launch import _ensure_server as _ensure + if not _ensure(self._url, self._open_path, + progress=lambda m: self.app.call_from_thread( + self._status, m)): + self.app.call_from_thread( + self._status, "could not start the analysis server") + self.app.call_from_thread(self._dismiss_loading) + return None + client = IDAClient(self._url, db=self._db) + client.connect() + if self._open_path is not None: + path = os.path.abspath(os.path.expanduser(self._open_path)) + base = os.path.basename(path) + self.app.call_from_thread(self._status, f"opening {base} — analyzing…") + + def _do_open(): + return client.call("idb_open", input_path=path, + idle_ttl_sec=self._ttl, timeout=1800.0) + + res = _do_open() + if not (isinstance(res, dict) and res.get("success")): + # A hard-killed worker can wedge the DB; sweep its stale unpacked + # lock files (never the .i64) and retry once. + try: + from .launch import _sweep_locks + swept = _sweep_locks(path) + except Exception: # noqa: BLE001 + swept = 0 + if swept: + self.app.call_from_thread( + self._status, f"recovering {base} (removed {swept} stale " + f"lock file(s))…") + res = _do_open() + if not (isinstance(res, dict) and res.get("success")): + err = res.get("error") if isinstance(res, dict) else res + self.app.call_from_thread(self._status, f"open failed: {err}") + self.app.call_from_thread(self._dismiss_loading) + return None + client.set_db(res["session"]["session_id"]) + elif self._db is None: + client.set_db(client.resolve_db()) + return client + + def _open_worker_client(self): # type: ignore[no-untyped-def] + """Our idalib-worker path: spawn the worker (it opens + analyzes the + binary in its own process) and connect. Returns the client, or None.""" + from .worker_client import WorkerClient + if not self._open_path: + self.app.call_from_thread( + self._status, "the worker backend needs a binary 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"starting worker — initial auto-analysis of {base}…") + client = WorkerClient(self._open_path, ttl=self._ttl) + client.connect(progress=lambda m: self.app.call_from_thread( + self._status, m)) + return client + @work(thread=True, exclusive=True, group="load-funcs") def _load_functions(self) -> None: assert self.program is not None diff --git a/idatui/launch.py b/idatui/launch.py index 2ecf8b5..15352d5 100644 --- a/idatui/launch.py +++ b/idatui/launch.py @@ -189,6 +189,10 @@ def main(argv: list[str] | None = None) -> int: help="listen for RPC on this unix socket (puppeteer the TUI)") p.add_argument("--no-server", action="store_true", help="do not auto-start the supervisor if it's down") + p.add_argument("--backend", choices=("mcp", "worker"), + default=os.environ.get("IDATUI_BACKEND", "mcp"), + help="'mcp' (ida-pro-mcp supervisor, default) or 'worker' " + "(our own in-process idalib worker over a unix socket)") args = p.parse_args(argv) binary = None @@ -202,21 +206,30 @@ def main(argv: list[str] | None = None) -> int: f"{os.path.dirname(binary)}") return 2 - # Everything slow — starting the supervisor and opening/analyzing the binary - # — is deferred to the TUI so its chrome + "loading…" overlay are on screen - # the whole time, instead of a blank terminal before the TUI even starts. - # Here we only do the instant checks. - server_down = not _server_up(*_server_addr(args.url)) - if args.no_server and server_down: - _log("supervisor is down and --no-server was given") - return 1 - if binary is not None and server_down: - # About to start a fresh supervisor (nothing holds the DB): clear any - # stale unpacked lock files a crashed worker left, so the supervisor's - # seeded initial open doesn't crash-loop on a wedged DB. - swept = _sweep_locks(binary) + if args.backend == "worker": + # The worker path owns no shared supervisor: the TUI spawns a private + # idalib worker that opens THIS binary. No server/lock plumbing here. + if binary is None: + _log("--backend worker needs a binary path") + return 2 + swept = _sweep_locks(binary) # a crashed worker can leave the DB wedged if swept: _log(f"cleared {swept} stale lock file(s) from a crashed worker") + else: + # Everything slow — starting the supervisor and opening/analyzing the + # binary — is deferred to the TUI so its chrome + "loading…" overlay are + # on screen the whole time. Here we only do the instant checks. + server_down = not _server_up(*_server_addr(args.url)) + if args.no_server and server_down: + _log("supervisor is down and --no-server was given") + return 1 + if binary is not None and server_down: + # About to start a fresh supervisor (nothing holds the DB): clear any + # stale unpacked lock files a crashed worker left, so the seeded + # initial open doesn't crash-loop on a wedged DB. + swept = _sweep_locks(binary) + if swept: + _log(f"cleared {swept} stale lock file(s) from a crashed worker") # Hand off to the TUI (imported late so --help works without textual). It # ensures the server (with on-screen progress), then opens/adopts the binary @@ -229,7 +242,8 @@ def main(argv: list[str] | None = None) -> int: rpc_path = os.path.abspath(os.path.expanduser(args.rpc)) if args.rpc else None IdaTui(url=args.url, db=args.db, open_path=(binary if args.db is None else None), ttl=args.ttl, - ensure_server=not args.no_server, + ensure_server=(args.backend == "mcp" and not args.no_server), + backend=args.backend, keepalive=not args.no_keepalive, rpc_path=rpc_path).run() return 0 |
