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
|
"""WorkerPool — keeps a live idalib worker per project binary, within a budget.
One worker process holds exactly one database (idalib is single-DB and
main-thread-only), so a project with N binaries means up to N processes. They are
not cheap and they do not share: a worker on ``bash`` measures ~126 MB RSS /
117 MB PSS, and the database working set dominates for anything larger
(``libcrypto.so.3``'s ``.i64`` alone is 72 MB).
Residency is therefore bounded by a **memory budget**, not a worker count — a
count is the wrong knob when one project holds both a 50 KB helper and a 6 MB
crypto library. Workers are spawned lazily on first use, kept resident while they
fit, and least-recently-used ones evicted when they don't. Eviction **saves the
database first**, so coming back is a load rather than a re-analysis.
The pool never evicts the active binary, nor anything pinned.
"""
from __future__ import annotations
import os
from .project import BinaryRef, Project
#: Fallback budget if /proc/meminfo can't be read (MB).
_FALLBACK_BUDGET_MB = 2048
def _total_ram_mb() -> int:
try:
with open("/proc/meminfo") as f:
for line in f:
if line.startswith("MemTotal:"):
return int(line.split()[1]) // 1024
except (OSError, ValueError, IndexError):
pass
return 0
def _pss_mb(pid: int | None) -> int:
"""Proportional set size of a worker, in MB.
PSS (not RSS) is the honest per-worker cost: it splits shared pages between
the processes mapping them. In practice workers share very little, so the two
are close, but PSS is what makes summing across workers meaningful.
"""
if not pid:
return 0
try:
with open(f"/proc/{pid}/smaps_rollup") as f:
for line in f:
if line.startswith("Pss:"):
return int(line.split()[1]) // 1024
except (OSError, ValueError, IndexError):
pass
return 0
def _default_spawn(ref: BinaryRef, ttl: int): # pragma: no cover - needs idalib
from .worker_client import WorkerClient
return WorkerClient(ref.staged, ttl=ttl, load_args=ref.load_args)
class WorkerPool:
"""Live workers for a project's binaries, keyed by label."""
def __init__(self, project: Project, *, budget_mb: int | None = None,
ttl: int = 1800, spawn=None, mem_fn=None) -> None:
self.project = project
self._ttl = ttl
self._spawn = spawn or _default_spawn
self._mem = mem_fn or (lambda c: _pss_mb(getattr(c, "pid", None)))
self._clients: dict[str, object] = {}
self._lru: list[str] = [] # least-recently-used first
self._pinned: set[str] = set()
self.active: str | None = None # never evicted
if budget_mb is None:
ram = _total_ram_mb()
budget_mb = (ram * project.memory_pct // 100) if ram else _FALLBACK_BUDGET_MB
self.budget_mb = max(budget_mb, 256)
self.evicted: list[str] = [] # labels evicted, most recent last
# -- residency --------------------------------------------------------- #
def resident(self) -> list[str]:
"""Labels with a live worker, least-recently-used first."""
return list(self._lru)
def is_resident(self, label: str) -> bool:
return label in self._clients
def memory_mb(self) -> int:
return sum(self._mem(c) for c in self._clients.values())
def pin(self, label: str, on: bool = True) -> None:
"""Keep ``label`` resident regardless of the budget."""
self._pinned.add(label) if on else self._pinned.discard(label)
def is_pinned(self, label: str) -> bool:
return label in self._pinned
# -- acquire ----------------------------------------------------------- #
def get(self, label: str, progress=None):
"""A live client for ``label``, spawning it (and making room) if needed.
Staging and the scratch sweep happen here: a worker killed hard last time
leaves unpacked ``.id0/.id1/...`` behind, and the database then refuses to
reopen. Nothing else holds this DB (one worker per label), so it is safe.
"""
client = self._clients.get(label)
if client is not None:
self._touch(label)
return client
ref = self.project.by_label(label)
if ref is None:
raise KeyError(f"no such binary in the project: {label}")
def note(msg: str) -> None:
if progress is not None:
progress(msg)
note(f"staging {ref.label}\u2026")
self.project.stage(ref)
self.project.sweep_scratch(ref)
note(f"opening {ref.label}\u2026")
client = self._spawn(ref, self._ttl)
connect = getattr(client, "connect", None)
if connect is not None:
connect(progress=progress) if progress is not None else connect()
self._clients[label] = client
self._lru.append(label)
self._enforce_budget(protect=label)
return client
def prewarm(self, label: str, progress=None) -> bool:
"""Spawn a worker for ``label`` only if it fits the budget AS IT STANDS.
Pre-warming must never cost residency: evicting a binary the user
actually visited to speculatively load one they haven't is a straight
downgrade, and the eviction would also throw away that binary's caches.
So this refuses rather than making room, and returns False.
The cost of a worker that doesn't exist yet can only be estimated; the
largest resident one is the best evidence available (they are all the
same program with a different database). With nothing resident we have
no evidence at all, so we allow one — that is the case where the budget
is certainly free.
"""
if label in self._clients:
return False
if self.project.by_label(label) is None:
return False
used = self.memory_mb()
est = max((self._mem(c) for c in self._clients.values()), default=0)
if used + est > self.budget_mb:
return False
self.get(label, progress=progress)
# get() enforces the budget protecting the NEW label; if that had to
# evict, our estimate was wrong and the speculative worker is the one
# that should go — never a binary the user chose.
if self.memory_mb() > self.budget_mb and label != self.active:
self.evict(label)
return False
return True
def _touch(self, label: str) -> None:
if label in self._lru:
self._lru.remove(label)
self._lru.append(label)
def set_active(self, label: str | None) -> None:
self.active = label
if label:
self._touch(label)
# -- release ----------------------------------------------------------- #
def evict(self, label: str, save: bool = True) -> bool:
"""Drop a resident worker, persisting its database first."""
client = self._clients.pop(label, None)
if client is None:
return False
if label in self._lru:
self._lru.remove(label)
if save:
try: # persist analysis + edits so the next open is a load
client.call("idb_save")
except Exception: # noqa: BLE001 -- evict regardless
pass
try:
client.close()
except Exception: # noqa: BLE001
pass
self.evicted.append(label)
return True
def _evictable(self, protect: str | None) -> str | None:
for label in self._lru: # least-recently-used first
if label == protect or label == self.active or label in self._pinned:
continue
return label
return None
def _enforce_budget(self, protect: str | None = None) -> int:
"""Evict LRU workers until the pool fits its budget. Returns how many."""
n = 0
while self.memory_mb() > self.budget_mb:
victim = self._evictable(protect)
if victim is None: # everything left is active/pinned/protected
break
self.evict(victim)
n += 1
return n
def close_all(self, save: bool = True) -> None:
for label in list(self._clients):
self.evict(label, save=save)
self.active = None
# -- introspection ------------------------------------------------------ #
def status(self) -> list[dict]:
"""Per-binary residency for the switcher UI."""
out = []
for ref in self.project.refs:
client = self._clients.get(ref.label)
out.append({
"label": ref.label,
"source": ref.source,
"resident": client is not None,
"pinned": ref.label in self._pinned,
"active": ref.label == self.active,
"analysed": self.project.has_db(ref),
"memory_mb": self._mem(client) if client is not None else 0,
})
return out
def __repr__(self) -> str: # pragma: no cover - debug aid
return (f"<WorkerPool {len(self._clients)}/{len(self.project.refs)} resident "
f"{self.memory_mb()}/{self.budget_mb}MB active={self.active}>")
|