aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-09 11:55:43 +0200
committerblasty <blasty@local>2026-07-09 11:55:43 +0200
commit21d2cbbfcdbb712b907573b784bc9d98d5c5e648 (patch)
tree8d2c94975c30e13501f0c8c8785230cae235ae98
parentdomain/paging layer: FunctionIndex, block-cached DisasmModel, decompile, resolve (diff)
downloadida-tui-21d2cbbfcdbb712b907573b784bc9d98d5c5e648.tar.gz
ida-tui-21d2cbbfcdbb712b907573b784bc9d98d5c5e648.tar.xz
ida-tui-21d2cbbfcdbb712b907573b784bc9d98d5c5e648.zip
keep idle workers alive: bump_idle_ttl + KeepAlive heartbeat
Root cause: idalib workers run an idle watchdog (worker_lifecycle.py) that self-exits after idle_ttl_sec (default 600s) of no requests -- deliberate resource hygiene for a shared/ephemeral supervisor, wrong for an interactive TUI. CLI-opened startup binary always gets 600s (no flag). Fix (both verified against a 12s-TTL session): - IDAClient.bump_idle_ttl(1e9): re-open (idempotent) re-applies TTL, no upper cap -> effectively immortal. Primary fix, call once at startup. - IDAClient.keepalive()/KeepAlive: background server_health heartbeat resets the watchdog; covers adopted sessions too. Safety net. tests/test_keepalive.py: 4/4 (heartbeat + bump both keep a short-TTL worker alive past 2x TTL). docs updated with why + fix.
-rw-r--r--docs/PAGING_FINDINGS.md37
-rw-r--r--idatui/__init__.py2
-rw-r--r--idatui/client.py79
-rw-r--r--tests/test_keepalive.py76
4 files changed, 186 insertions, 8 deletions
diff --git a/docs/PAGING_FINDINGS.md b/docs/PAGING_FINDINGS.md
index 33cb2e6..3d3ec1a 100644
--- a/docs/PAGING_FINDINGS.md
+++ b/docs/PAGING_FINDINGS.md
@@ -93,15 +93,36 @@ fall back to the disassembly view or show an error panel.
Normal decompile bodies are server-truncated with a `[N chars total]` marker
(still to be solved for full-body display — see Phase 2).
-## Workers self-exit when idle (`idle_ttl_sec`, default 600s)
+## Workers self-exit when idle (`idle_ttl_sec`, default 600s) — SOLVED
-A headless idalib worker self-exits after ~10 min idle. Its session then lingers
-in `idb_list` but calls fail with `"Worker for session <id> is not reachable"`
-(distinct from `"Session not found"`). Recovery differs: this needs a fresh
-`idb_open` of the same `input_path` (new worker), not just a db re-resolve. The
-TUI should (a) raise `idle_ttl_sec` when opening, and/or (b) detect
-"not reachable" and transparently re-open. Currently surfaced as a clean
-`IDAToolError`; auto-reopen is a TODO for the app layer.
+### Why it happens
+Each idalib worker runs a `WorkerLifecycle` watchdog
+(`ida_pro_mcp/worker_lifecycle.py`): if no JSON-RPC request hits its dispatcher
+for `idle_ttl_sec` (default **600s**), the worker **self-exits cleanly**. It is
+refcount-free — "activity == alive": every forwarded tool call `touch()`es the
+timer. This is deliberate resource hygiene for a *shared* supervisor (each worker
+holds an IDA license slot + the DB's RAM; default max 4 workers), aimed at
+ephemeral/agent usage that opens a DB and wanders off. For an **interactive TUI
+that should be able to just chill, it's the wrong default.** The startup binary
+passed to `idalib-mcp` on the CLI always gets 600s (no CLI flag to change it).
+
+After self-exit the session lingers in `idb_list` but calls fail with
+`"Worker for session <id> is not reachable"` (distinct from `"Session not
+found"`).
+
+### The fix (both implemented + verified in `tests/test_keepalive.py`)
+1. **`IDAClient.bump_idle_ttl(idle_ttl_sec=1e9)`** — `idb_open` on the
+ already-open path is idempotent and re-applies the TTL; `set_idle_ttl` has no
+ upper cap, so ~1e9s is effectively 'never'. This is the intended knob and the
+ primary fix: the TUI calls it once at startup.
+2. **`IDAClient.keepalive(interval=120)` -> `KeepAlive`** — a background
+ heartbeat issuing a cheap `server_health` (~2ms) that resets the watchdog.
+ Belt-and-suspenders, and it also covers *adopted* sessions whose path we don't
+ control / don't want to re-open.
+
+Recommended TUI policy: `bump_idle_ttl()` on open **and** run a `KeepAlive` as a
+safety net. "Worker not reachable" then only occurs on a real crash, which the
+app handles by re-`idb_open`.
## Writable path requirement (operational)
diff --git a/idatui/__init__.py b/idatui/__init__.py
index 8dd371c..24f3c0f 100644
--- a/idatui/__init__.py
+++ b/idatui/__init__.py
@@ -10,6 +10,7 @@ from .client import (
IDAToolError,
IDASessionError,
Session,
+ KeepAlive,
)
from .domain import (
Program,
@@ -42,4 +43,5 @@ __all__ = [
"IDAToolError",
"IDASessionError",
"Session",
+ "KeepAlive",
]
diff --git a/idatui/client.py b/idatui/client.py
index 44f5cc9..3e5edb4 100644
--- a/idatui/client.py
+++ b/idatui/client.py
@@ -522,6 +522,35 @@ class IDAClient:
def db(self) -> str | None:
return self._db
+ def _session_input_path(self, db: str) -> str:
+ for s in self.list_sessions():
+ if s.session_id == db:
+ if not s.input_path:
+ raise IDASessionError(f"session {db} has no input_path to re-open")
+ return s.input_path
+ raise IDASessionError(f"session {db} not found")
+
+ def bump_idle_ttl(self, idle_ttl_sec: int = 1_000_000_000,
+ path: str | None = None) -> None:
+ """Raise the worker's idle self-exit TTL so an interactive session never
+ gets reaped while the user is just reading.
+
+ Headless idalib workers self-exit after ``idle_ttl_sec`` (default 600s)
+ with no requests. ``idb_open`` on the already-open path is idempotent
+ (returns the same session) and re-applies the TTL, so this simply opens
+ the pinned session's path with a huge TTL. The default (~31 years) is
+ effectively 'never'.
+ """
+ p = path or self._session_input_path(self.resolve_db())
+ self.call("idb_open", input_path=p, idle_ttl_sec=int(idle_ttl_sec))
+
+ def keepalive(self, interval: float = 120.0) -> "KeepAlive":
+ """Return a (not-yet-started) heartbeat that touches the worker's idle
+ watchdog every ``interval`` seconds. Belt-and-suspenders next to
+ :meth:`bump_idle_ttl`; also covers adopted sessions we didn't open.
+ """
+ return KeepAlive(self, interval=interval)
+
# -- convenience ------------------------------------------------------- #
def health(self) -> dict:
return self.call("server_health")
@@ -542,6 +571,56 @@ class IDAClient:
raise IDAError(f"tool not found: {tool}")
+class KeepAlive:
+ """Background heartbeat that keeps an idalib worker from idling out.
+
+ Any forwarded request resets the worker's idle timer, so a periodic cheap
+ ``server_health`` is enough. Failures are swallowed (the next real call will
+ auto-recover); the heartbeat just keeps a chilling TUI's worker alive.
+ """
+
+ def __init__(self, client: "IDAClient", interval: float = 120.0):
+ if interval <= 0:
+ raise ValueError("interval must be > 0")
+ self._client = client
+ self.interval = interval
+ self._stop = threading.Event()
+ self._thread: threading.Thread | None = None
+ self.beats = 0
+ self.failures = 0
+
+ def start(self) -> "KeepAlive":
+ if self._thread is not None:
+ return self
+ self._stop.clear()
+ self._thread = threading.Thread(
+ target=self._run, daemon=True, name="idatui-keepalive"
+ )
+ self._thread.start()
+ return self
+
+ def stop(self) -> None:
+ self._stop.set()
+ t = self._thread
+ self._thread = None
+ if t is not None:
+ t.join(timeout=2.0)
+
+ def __enter__(self) -> "KeepAlive":
+ return self.start()
+
+ def __exit__(self, *exc) -> None:
+ self.stop()
+
+ def _run(self) -> None:
+ while not self._stop.wait(self.interval):
+ try:
+ self._client.health()
+ self.beats += 1
+ except IDAError:
+ self.failures += 1
+
+
def _is_stale_session(e: IDAToolError) -> bool:
msg = e.message.lower()
return any(marker in msg for marker in _STALE_SESSION_MARKERS)
diff --git a/tests/test_keepalive.py b/tests/test_keepalive.py
new file mode 100644
index 0000000..2910e1a
--- /dev/null
+++ b/tests/test_keepalive.py
@@ -0,0 +1,76 @@
+#!/usr/bin/env python3
+"""Prove that an interactive session can 'just chill' without the worker idling
+out. Uses a deliberately short worker TTL to keep the test fast.
+
+ python3 tests/test_keepalive.py
+
+Needs ~/ida-venv + a running server (tests/serverctl.sh) and a writable target.
+"""
+import os
+import sys
+import time
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+REPO = os.path.dirname(HERE)
+sys.path.insert(0, REPO)
+from idatui.client import IDAClient, IDAToolError # noqa: E402
+
+PASS = FAIL = 0
+
+
+def check(name, cond, detail=""):
+ global PASS, FAIL
+ if cond:
+ PASS += 1
+ print(f" ok {name}")
+ else:
+ FAIL += 1
+ print(f" FAIL {name} {detail}")
+
+
+def alive(c, sid):
+ try:
+ c.set_db(sid)
+ c.health()
+ return True
+ except IDAToolError:
+ return False
+
+
+def main():
+ target = os.path.join(REPO, "targets", "ls_ttl")
+ if not os.path.exists(target):
+ src = os.path.join(REPO, "bin", "ls")
+ os.makedirs(os.path.dirname(target), exist_ok=True)
+ import shutil
+ shutil.copy(src, target)
+
+ c = IDAClient(timeout=300)
+ c.connect()
+ SHORT = 12 # worker self-exits after ~12s idle unless kept alive
+
+ print("[heartbeat keeps a short-TTL worker alive]")
+ sid = c.call("idb_open", input_path=target, idle_ttl_sec=SHORT)["session"]["session_id"]
+ c.set_db(sid)
+ ka = c.keepalive(interval=4.0).start()
+ time.sleep(SHORT * 2 + 2) # idle well past the TTL, but heartbeat is beating
+ ok = alive(c, sid)
+ check("alive past 2x TTL with heartbeat", ok, f"beats={ka.beats}")
+ check("heartbeat actually beat", ka.beats >= 4, f"beats={ka.beats}")
+ check("heartbeat had no failures", ka.failures == 0, f"failures={ka.failures}")
+ ka.stop()
+
+ print("\n[bump_idle_ttl makes it effectively immortal]")
+ sid = c.call("idb_open", input_path=target, idle_ttl_sec=SHORT)["session"]["session_id"]
+ c.set_db(sid)
+ c.bump_idle_ttl() # ~1e9 seconds
+ time.sleep(SHORT * 2 + 6) # zero requests during this window
+ check("alive past 2x TTL after bump, zero requests", alive(c, sid))
+
+ c.close()
+ print(f"\n{PASS} passed, {FAIL} failed")
+ return 1 if FAIL else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())