1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
|
#!/usr/bin/env python3
"""Adversarial stress tests for idatui.client.
Kills/respawns the server, forces timeouts, drops connections, and hammers with
concurrency — asserting the client always fails *cleanly* (no hangs, no
deadlocks) and recovers where recovery is possible.
python3 tests/stress_client.py # run all scenarios
python3 tests/stress_client.py timeout # run one by name
Requires ~/ida-venv + spawn.sh (via tests/serverctl.sh). Leaves the server UP.
"""
import concurrent.futures
import os
import subprocess
import sys
import threading
import time
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
sys.path.insert(0, REPO)
from idatui.client import ( # noqa: E402
IDAClient,
IDAConnectionError,
IDASessionError,
IDATimeoutError,
IDAToolError,
IDAError,
)
URL = "http://127.0.0.1:8745/mcp"
CTL = os.path.join(HERE, "serverctl.sh")
PASS = FAIL = 0
def log(m):
print(m, flush=True)
def check(name, cond, detail=""):
global PASS, FAIL
if cond:
PASS += 1
log(f" ok {name}")
else:
FAIL += 1
log(f" FAIL {name} {detail}")
def ctl(*args, timeout=180):
r = subprocess.run([CTL, *args], capture_output=True, text=True, timeout=timeout)
return r.returncode, (r.stdout + r.stderr).strip()
def ensure_up():
rc, out = ctl("ready", "5")
if rc != 0:
log(" (server down; starting...)")
rc, out = ctl("start")
assert rc == 0, f"could not start server: {out}"
rc, sid = ctl("ready", "60")
return sid.strip().splitlines()[-1] if sid.strip() else None
def deadline(fn, secs):
"""Run fn() in a thread; return (finished_bool, result_or_exc)."""
box = {}
def run():
try:
box["r"] = fn()
except BaseException as e: # noqa: BLE001
box["e"] = e
t = threading.Thread(target=run, daemon=True)
t.start()
t.join(secs)
if t.is_alive():
return False, None
return True, box.get("e", box.get("r"))
# --------------------------------------------------------------------------- #
def s_connect_refused():
"""Connecting to a dead port fails fast and cleanly, never hangs."""
c = IDAClient("http://127.0.0.1:59999/mcp", db="x", timeout=3.0)
fin, res = deadline(c.connect, 8)
check("dead-port connect returns (no hang)", fin)
check("dead-port raises IDAConnectionError", isinstance(res, IDAConnectionError),
f"{type(res).__name__}: {res}")
def s_timeout():
"""A too-tight timeout on the slow decompile raises IDATimeoutError, and the
client stays usable for subsequent calls."""
sid = ensure_up()
c = IDAClient(URL, db=sid)
c.connect()
# force_recompile clears the hexrays cache so decompile is genuinely slow.
try:
c.call("force_recompile", addr="main")
except IDAError:
pass
fin, res = deadline(lambda: c.call("decompile", addr="main", timeout=0.001), 10)
check("tight-timeout returns (no hang)", fin)
check("tight-timeout raises IDATimeoutError", isinstance(res, IDATimeoutError),
f"{type(res).__name__}: {res}")
# Client must still work afterwards (pool not poisoned).
fin2, res2 = deadline(lambda: c.call("list_funcs", queries=[{"count": 1}]), 10)
check("client usable after timeout", fin2 and not isinstance(res2, Exception),
f"{type(res2).__name__}: {res2}")
c.close()
def s_worker_crash():
"""Hard-kill the analysis worker mid-use: calls must raise cleanly (no hang),
and after a full restart the client recovers by re-resolving the session."""
sid = ensure_up()
c = IDAClient(URL, db=sid)
c.connect()
check("baseline call ok", isinstance(c.call("list_funcs", queries=[{"count": 1}]), (list, dict)))
ctl("kill9") # kill only the worker; supervisor may stay up
time.sleep(1.0)
def probe():
try:
return c.call("list_funcs", queries=[{"count": 1}])
except IDAError as e:
return e
fin, res = deadline(probe, 15)
check("post-crash call returns (no hang)", fin, "call hung after worker kill")
check("post-crash raises IDAError (clean)", isinstance(res, IDAError) or res is not None,
f"{type(res).__name__}: {res}")
# Full restart and recovery.
ctl("stop")
rc, _ = ctl("start")
check("server restart ok", rc == 0)
newsid = ensure_up()
# A fresh client should just work.
c2 = IDAClient(URL, db=None)
c2.connect()
fin3, res3 = deadline(lambda: (c2.set_db(c2.resolve_db()), c2.health())[1], 20)
check("fresh client recovers after restart", fin3 and isinstance(res3, dict),
f"{type(res3).__name__}: {res3}")
check("session id stable across restart" if newsid == sid else "session id changed (expected-ok)",
True, f"{sid} -> {newsid}")
c.close(); c2.close()
def s_full_restart_same_client():
"""Keep ONE auto-injected client across a full server restart. The FIRST
naive call after restart must transparently self-heal the stale db pin (no
internal poking, no manual re-resolve)."""
sid = ensure_up()
c = IDAClient(URL, db=None) # auto-resolve -> auto_recover eligible
c.connect()
old = c.resolve_db()
check("pre-restart call ok", isinstance(c.health(), dict))
ctl("stop")
# While down: calls must fail cleanly, fast.
fin, res = deadline(lambda: c.health(), 8)
check("while-down call returns (no hang)", fin)
check("while-down raises IDAError", isinstance(res, IDAError), f"{type(res).__name__}: {res}")
ctl("start")
ensure_up()
# NAIVE call: no internal poking. Auto-recovery must kick in on the stale
# "Session not found" and retry against the fresh session.
fin2, res2 = deadline(lambda: c.health(), 25)
check("naive call auto-recovers after restart", fin2 and isinstance(res2, dict),
f"{type(res2).__name__}: {res2}")
check("db pin was re-resolved to new session", c.db is not None and c.db != old,
f"old={old} new={c.db}")
c.close()
def s_no_autoswitch_when_explicit():
"""With auto_recover disabled (or an explicitly pinned db), a stale session
must NOT be silently switched — it raises so the caller stays in control."""
sid = ensure_up()
c = IDAClient(URL, db=sid, auto_recover_session=False)
c.connect()
check("pre-restart call ok", isinstance(c.health(), dict))
ctl("stop"); ctl("start"); newsid = ensure_up()
fin, res = deadline(lambda: c.health(), 15)
check("explicit-pin call returns (no hang)", fin)
check("explicit-pin raises IDAToolError (no silent switch)",
isinstance(res, IDAToolError), f"{type(res).__name__}: {res}")
check("db pin unchanged when recovery disabled", c.db == sid, f"{c.db} vs {sid}")
c.close()
def s_concurrency_high():
"""Heavy concurrency: 300 calls over 16 threads, all succeed, no deadlock."""
sid = ensure_up()
c = IDAClient(URL, db=sid, pool_size=8)
c.connect()
N = 300
errors = []
def work(i):
try:
tool = ("list_funcs", "disasm")[i % 2]
if tool == "list_funcs":
return bool(c.call("list_funcs", queries=[{"count": 3}]))
return bool(c.call("disasm", addr="main", max_instructions=5))
except IDAError as e:
errors.append(e); return False
def run_all():
with concurrent.futures.ThreadPoolExecutor(max_workers=16) as ex:
return sum(ex.map(work, range(N)))
fin, ok = deadline(run_all, 60)
check("300/16 concurrent finished (no deadlock)", fin, "pool deadlocked")
check("300/16 all succeeded", ok == N, f"{ok}/{N}, errs={errors[:3]}")
c.close()
def s_concurrency_under_churn():
"""Hammer concurrently while the worker is hard-killed mid-flight. No hang,
no deadlock; errors are clean; after respawn calls succeed again."""
sid = ensure_up()
c = IDAClient(URL, db=sid, pool_size=8)
c.connect()
stop = threading.Event()
stats = {"ok": 0, "err": 0, "weird": 0}
lock = threading.Lock()
def spinner():
while not stop.is_set():
try:
c.call("list_funcs", queries=[{"count": 1}])
with lock: stats["ok"] += 1
except IDAError:
with lock: stats["err"] += 1
except Exception: # noqa: BLE001 -- any non-IDAError is a bug
with lock: stats["weird"] += 1
time.sleep(0.01)
threads = [threading.Thread(target=spinner, daemon=True) for _ in range(12)]
for t in threads: t.start()
time.sleep(1.0)
ctl("kill9") # crash the worker under load
time.sleep(2.0)
stop.set()
for t in threads: t.join(10)
alive = [t for t in threads if t.is_alive()]
check("no spinner thread hung on worker kill", not alive, f"{len(alive)} stuck")
check("no non-IDAError leaked during churn", stats["weird"] == 0, str(stats))
log(f" churn stats: {stats}")
# Recover.
ctl("stop"); ctl("start"); ensure_up()
c.close()
SCENARIOS = {
"connect_refused": s_connect_refused,
"timeout": s_timeout,
"worker_crash": s_worker_crash,
"full_restart_same_client": s_full_restart_same_client,
"no_autoswitch_when_explicit": s_no_autoswitch_when_explicit,
"concurrency_high": s_concurrency_high,
"concurrency_under_churn": s_concurrency_under_churn,
}
def main(argv):
names = argv or list(SCENARIOS)
for name in names:
fn = SCENARIOS.get(name)
if not fn:
log(f"unknown scenario: {name} (have: {', '.join(SCENARIOS)})")
return 2
log(f"\n[{name}]")
t0 = time.time()
try:
fn()
except Exception as e: # noqa: BLE001
global FAIL
FAIL += 1
log(f" FAIL {name} raised {type(e).__name__}: {e}")
log(f" ({time.time() - t0:.1f}s)")
log("\n== ensuring server is UP for subsequent work ==")
sid = ensure_up()
log(f" server ready, db={sid}")
log(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
|