aboutsummaryrefslogtreecommitdiffstats
path: root/idatui
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-24 01:37:48 +0200
committerblasty <blasty@local>2026-07-24 01:37:48 +0200
commitf925701e003f05160d46cc3efd8eacf3ea256aeb (patch)
tree6ca179a93898d66d7fcb462ca192168c8f852a65 /idatui
parentworker: idalib worker + WorkerClient (drop-in for IDAClient) — migration st... (diff)
downloadida-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).
Diffstat (limited to 'idatui')
-rw-r--r--idatui/app.py139
-rw-r--r--idatui/launch.py42
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