declib 3.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. declib/__init__.py +9 -0
  2. declib/__main__.py +190 -0
  3. declib/api/__init__.py +13 -0
  4. declib/api/artifact_dict.py +153 -0
  5. declib/api/artifact_lifter.py +161 -0
  6. declib/api/decompiler_client.py +1219 -0
  7. declib/api/decompiler_interface.py +1261 -0
  8. declib/api/decompiler_server.py +782 -0
  9. declib/api/server_registry.py +171 -0
  10. declib/api/type_definition_parser.py +201 -0
  11. declib/api/type_parser.py +409 -0
  12. declib/api/utils.py +31 -0
  13. declib/artifacts/__init__.py +93 -0
  14. declib/artifacts/artifact.py +311 -0
  15. declib/artifacts/comment.py +49 -0
  16. declib/artifacts/context.py +61 -0
  17. declib/artifacts/decompilation.py +35 -0
  18. declib/artifacts/enum.py +53 -0
  19. declib/artifacts/formatting.py +27 -0
  20. declib/artifacts/func.py +433 -0
  21. declib/artifacts/global_variable.py +31 -0
  22. declib/artifacts/patch.py +49 -0
  23. declib/artifacts/segment.py +37 -0
  24. declib/artifacts/stack_variable.py +50 -0
  25. declib/artifacts/struct.py +184 -0
  26. declib/artifacts/typedef.py +59 -0
  27. declib/cli/__init__.py +3 -0
  28. declib/cli/decompiler_cli.py +1487 -0
  29. declib/configuration.py +184 -0
  30. declib/decompiler_stubs/__init__.py +0 -0
  31. declib/decompiler_stubs/angr_declib/__init__.py +4 -0
  32. declib/decompiler_stubs/binja_declib/__init__.py +4 -0
  33. declib/decompiler_stubs/ida_declib.py +8 -0
  34. declib/decompilers/__init__.py +8 -0
  35. declib/decompilers/angr/__init__.py +11 -0
  36. declib/decompilers/angr/artifact_lifter.py +46 -0
  37. declib/decompilers/angr/compat.py +262 -0
  38. declib/decompilers/angr/interface.py +949 -0
  39. declib/decompilers/binja/__init__.py +0 -0
  40. declib/decompilers/binja/artifact_lifter.py +32 -0
  41. declib/decompilers/binja/hooks.py +201 -0
  42. declib/decompilers/binja/interface.py +795 -0
  43. declib/decompilers/ghidra/__init__.py +0 -0
  44. declib/decompilers/ghidra/artifact_lifter.py +60 -0
  45. declib/decompilers/ghidra/compat/__init__.py +0 -0
  46. declib/decompilers/ghidra/compat/headless.py +156 -0
  47. declib/decompilers/ghidra/compat/imports.py +78 -0
  48. declib/decompilers/ghidra/compat/state.py +54 -0
  49. declib/decompilers/ghidra/compat/transaction.py +30 -0
  50. declib/decompilers/ghidra/hooks.py +242 -0
  51. declib/decompilers/ghidra/interface.py +1433 -0
  52. declib/decompilers/ida/__init__.py +0 -0
  53. declib/decompilers/ida/artifact_lifter.py +51 -0
  54. declib/decompilers/ida/compat.py +2054 -0
  55. declib/decompilers/ida/hooks.py +700 -0
  56. declib/decompilers/ida/ida_ui.py +80 -0
  57. declib/decompilers/ida/interface.py +659 -0
  58. declib/logger.py +101 -0
  59. declib/plugin_installer.py +259 -0
  60. declib/skills/__init__.py +24 -0
  61. declib/skills/decompiler/SKILL.md +316 -0
  62. declib/ui/__init__.py +33 -0
  63. declib/ui/qt_objects.py +146 -0
  64. declib/ui/utils.py +115 -0
  65. declib/ui/version.py +14 -0
  66. declib-3.8.0.dist-info/METADATA +138 -0
  67. declib-3.8.0.dist-info/RECORD +71 -0
  68. declib-3.8.0.dist-info/WHEEL +5 -0
  69. declib-3.8.0.dist-info/entry_points.txt +3 -0
  70. declib-3.8.0.dist-info/licenses/LICENSE +24 -0
  71. declib-3.8.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,782 @@
1
+ # Note to reader: most of this code was generated by Claude 4.5. It may contain errors and was designed
2
+ # in tandem with decompiler_client.py and the tests/test_client_server.py file. This comment will be
3
+ # removed when the majority of the file is owned by a human author.
4
+
5
+ import logging
6
+ import pickle
7
+ import queue
8
+ import socket
9
+ import struct
10
+ import threading
11
+ import time
12
+ import tempfile
13
+ import os
14
+ from typing import Optional, Dict, Any, List
15
+
16
+ from declib.api.decompiler_interface import DecompilerInterface
17
+ from declib.api import server_registry
18
+ from declib.artifacts.formatting import ArtifactFormat
19
+
20
+ _l = logging.getLogger(__name__)
21
+
22
+ # JSON, not TOML: the `toml` package's encoder mangles raw `\x` escapes,
23
+ # which show up in decompilation text for C char literals like `'\x01'`.
24
+ _WIRE_FMT = ArtifactFormat.JSON
25
+
26
+ # Sentinel used to poke the main-thread dispatcher awake on shutdown.
27
+ _MAIN_THREAD_SHUTDOWN = object()
28
+
29
+
30
+ class _MainThreadError:
31
+ """Wrap exceptions that occurred on the main thread so the waiting client
32
+ thread can re-raise them after receiving the result."""
33
+
34
+ __slots__ = ("exc",)
35
+
36
+ def __init__(self, exc: BaseException):
37
+ self.exc = exc
38
+
39
+
40
+ class SocketProtocol:
41
+ """Helper class for socket protocol message framing"""
42
+
43
+ @staticmethod
44
+ def send_message(sock: socket.socket, data: Any) -> None:
45
+ """Send a pickled message with length prefix"""
46
+ try:
47
+ pickled_data = pickle.dumps(data)
48
+ msg_len = len(pickled_data)
49
+
50
+ # Send 4-byte length prefix
51
+ sock.sendall(struct.pack('!I', msg_len))
52
+ # Send pickled data
53
+ sock.sendall(pickled_data)
54
+ except (ConnectionError, BrokenPipeError, OSError) as e:
55
+ # Expected during shutdown when socket is closed, just re-raise
56
+ raise
57
+ except Exception as e:
58
+ # Unexpected error - log it
59
+ _l.error(f"Failed to send message (pickle.dumps): {e}")
60
+ _l.error(f"Data type: {type(data)}")
61
+ if hasattr(data, '__dict__'):
62
+ _l.error(f"Data dict: {data.__dict__}")
63
+ raise
64
+
65
+ @staticmethod
66
+ def recv_message(sock: socket.socket) -> Any:
67
+ """Receive a pickled message with length prefix"""
68
+ pickled_data = b''
69
+ try:
70
+ # Receive 4-byte length prefix
71
+ len_data = sock.recv(4)
72
+ if len(len_data) != 4:
73
+ raise ConnectionError("Failed to receive message length")
74
+
75
+ msg_len = struct.unpack('!I', len_data)[0]
76
+
77
+ # Receive the pickled data
78
+ while len(pickled_data) < msg_len:
79
+ chunk = sock.recv(msg_len - len(pickled_data))
80
+ if not chunk:
81
+ raise ConnectionError("Connection closed while receiving message")
82
+ pickled_data += chunk
83
+
84
+ return pickle.loads(pickled_data)
85
+ except (ConnectionError, socket.timeout):
86
+ # Expected during shutdown or normal timeout, just re-raise without logging
87
+ raise
88
+ except Exception as e:
89
+ # Unexpected error - log it
90
+ _l.error(f"Failed to receive message (pickle.loads): {e}")
91
+ if pickled_data:
92
+ _l.error(f"Received {len(pickled_data)} bytes of pickle data")
93
+ raise
94
+
95
+
96
+ class SocketServerHandler:
97
+ """Handler for individual client connections"""
98
+
99
+ def __init__(self, deci: DecompilerInterface, server: 'DecompilerServer' = None):
100
+ self.deci = deci
101
+ self.server = server
102
+ self._light_caches = {}
103
+ self._cache_lock = threading.Lock()
104
+ self._cache_ttl = 10.0
105
+
106
+ def _dispatch(self, func, *args, **kwargs):
107
+ """Call ``func`` either directly or via the server's main-thread queue.
108
+
109
+ Backends like IDA reject cross-thread API access, so the server
110
+ declares ``_requires_main_thread`` and we route everything through
111
+ its dispatcher. For thread-safe backends (ghidra headless, angr,
112
+ binja) we short-circuit to a direct call.
113
+ """
114
+ if self.server is None or not self.server.requires_main_thread:
115
+ return func(*args, **kwargs)
116
+ return self.server.run_on_main_thread(func, *args, **kwargs)
117
+
118
+ def handle_client(self, client_socket: socket.socket, addr: str):
119
+ """Handle a client connection"""
120
+ _l.info(f"Client connected: {addr}")
121
+
122
+ try:
123
+ while True:
124
+ try:
125
+ request = SocketProtocol.recv_message(client_socket)
126
+ response = self._process_request(request, client_socket=client_socket)
127
+ SocketProtocol.send_message(client_socket, response)
128
+ except ConnectionError:
129
+ # Client disconnected
130
+ break
131
+ except Exception as e:
132
+ # Send error response
133
+ error_response = {"error": str(e), "type": type(e).__name__}
134
+ try:
135
+ SocketProtocol.send_message(client_socket, error_response)
136
+ except:
137
+ break
138
+ finally:
139
+ # Remove from event subscribers if subscribed
140
+ if self.server:
141
+ with self.server._event_subscribers_lock:
142
+ if client_socket in self.server._event_subscribers:
143
+ self.server._event_subscribers.remove(client_socket)
144
+ _l.debug("Removed client from event subscribers")
145
+
146
+ client_socket.close()
147
+ _l.info(f"Client disconnected: {addr}")
148
+
149
+ def _process_request(self, request: Dict[str, Any], client_socket: socket.socket = None) -> Any:
150
+ """Process a client request and return response"""
151
+ request_type = request.get("type")
152
+
153
+ if request_type == "subscribe_events":
154
+ # Client wants to subscribe to artifact change events
155
+ if self.server and client_socket:
156
+ with self.server._event_subscribers_lock:
157
+ if client_socket not in self.server._event_subscribers:
158
+ self.server._event_subscribers.append(client_socket)
159
+ _l.info(f"Client subscribed to events (total subscribers: {len(self.server._event_subscribers)})")
160
+ return {"status": "subscribed"}
161
+ else:
162
+ return {"status": "error", "message": "Server not available"}
163
+
164
+ elif request_type == "unsubscribe_events":
165
+ # Client wants to unsubscribe from events
166
+ if self.server and client_socket:
167
+ with self.server._event_subscribers_lock:
168
+ if client_socket in self.server._event_subscribers:
169
+ self.server._event_subscribers.remove(client_socket)
170
+ _l.info(f"Client unsubscribed from events (total subscribers: {len(self.server._event_subscribers)})")
171
+ return {"status": "unsubscribed"}
172
+ else:
173
+ return {"status": "error", "message": "Server not available"}
174
+
175
+ elif request_type == "server_info":
176
+ # Return the metadata cached by the server at init time. Reading
177
+ # ``deci.binary_hash`` or ``deci.binary_path`` here would re-enter
178
+ # the backend from a worker thread — which IDA/idalib rejects
179
+ # with "Function can be called from the main thread only".
180
+ if self.server is not None and self.server._cached_server_info is not None:
181
+ return dict(self.server._cached_server_info)
182
+ return {
183
+ "name": "DecLib DecompilerServer (AF_UNIX)",
184
+ "version": "3.0.0",
185
+ "decompiler": self.deci.name if self.deci else "unknown",
186
+ "protocol": "unix_socket",
187
+ "binary_hash": None,
188
+ "binary_path": None,
189
+ "server_id": self.server.server_id if self.server else None,
190
+ }
191
+
192
+ elif request_type == "get_light_artifacts":
193
+ collection_name = request.get("collection_name")
194
+ return self._get_light_artifacts(collection_name)
195
+
196
+ elif request_type == "get_full_artifact":
197
+ collection_name = request.get("collection_name")
198
+ key = request.get("key")
199
+
200
+ def _fetch_full_artifact():
201
+ collection = getattr(self.deci, collection_name)
202
+ return collection[key]
203
+
204
+ artifact = self._dispatch(_fetch_full_artifact)
205
+
206
+ # Serialize the full artifact safely
207
+ if hasattr(artifact, 'dumps') and hasattr(artifact, '__class__'):
208
+ try:
209
+ return {
210
+ 'type': artifact.__class__.__name__,
211
+ 'module': artifact.__class__.__module__,
212
+ 'data': artifact.dumps(fmt=_WIRE_FMT),
213
+ 'is_artifact': True
214
+ }
215
+ except Exception as e:
216
+ _l.warning(f"Failed to serialize full artifact: {e}")
217
+ # Fall back to direct return, which might fail with pickle
218
+ return artifact
219
+ else:
220
+ return artifact
221
+
222
+ elif request_type == "method_call":
223
+ method_name = request.get("method_name")
224
+ args = request.get("args", [])
225
+ kwargs = request.get("kwargs", {})
226
+
227
+ # Handle dotted method names like "art_lifter.lift"
228
+ if "." in method_name:
229
+ obj = self.deci
230
+ for attr in method_name.split("."):
231
+ obj = getattr(obj, attr)
232
+ method = obj
233
+ else:
234
+ # Get the method from the decompiler interface
235
+ method = getattr(self.deci, method_name)
236
+ result = self._dispatch(method, *args, **kwargs)
237
+
238
+ # Check if result is an artifact and serialize it properly
239
+ if hasattr(result, 'dumps') and hasattr(result, '__class__'):
240
+ # This looks like an artifact, serialize it safely
241
+ try:
242
+ return {
243
+ 'type': result.__class__.__name__,
244
+ 'module': result.__class__.__module__,
245
+ 'data': result.dumps(fmt=_WIRE_FMT),
246
+ 'is_artifact': True
247
+ }
248
+ except Exception as e:
249
+ _l.warning(f"Failed to serialize result artifact: {e}")
250
+ # Fall back to direct return, which might fail with pickle
251
+ return result
252
+ else:
253
+ # Not an artifact, return as-is
254
+ return result
255
+
256
+ elif request_type == "property_get":
257
+ property_name = request.get("property_name")
258
+ return self._dispatch(lambda: getattr(self.deci, property_name))
259
+
260
+ elif request_type == "shutdown_deci":
261
+ if self.deci and self.server is not None and not self.server._deci_shutdown_done:
262
+ # Route through the main-thread dispatcher for IDA — calling
263
+ # idapro.close_database() from a worker thread raises
264
+ # "Function can be called from the main thread only".
265
+ self._dispatch(self.deci.shutdown)
266
+ self.server._deci_shutdown_done = True
267
+ return {"status": "shutdown"}
268
+
269
+ elif request_type == "shutdown_server":
270
+ # Tear the server down asynchronously so we can still reply.
271
+ if self.server is not None:
272
+ threading.Thread(
273
+ target=self.server.stop, name="declib-server-shutdown", daemon=True
274
+ ).start()
275
+ return {"status": "stopping"}
276
+
277
+ else:
278
+ raise ValueError(f"Unknown request type: {request_type}")
279
+
280
+ def _get_light_artifacts(self, collection_name: str) -> Dict:
281
+ """Get light artifacts for a collection, computing and caching on first request"""
282
+ with self._cache_lock:
283
+ cache_entry = self._light_caches.get(collection_name)
284
+
285
+ # Check if we have a valid cache entry
286
+ if cache_entry and time.time() - cache_entry["timestamp"] < self._cache_ttl:
287
+ return cache_entry["items"]
288
+
289
+ # Cache miss or stale - compute light artifacts on-demand
290
+ _l.debug(f"Computing light artifacts for {collection_name} on-demand")
291
+ try:
292
+ collection = getattr(self.deci, collection_name)
293
+ if hasattr(collection, '_lifted_art_lister'):
294
+ start_time = time.time()
295
+ light_items = self._dispatch(collection._lifted_art_lister)
296
+ end_time = time.time()
297
+
298
+ # Convert artifacts to serializable format using their own serialization
299
+ serializable_items = {}
300
+ for addr, artifact in light_items.items():
301
+ try:
302
+ # Use the artifact's built-in serialization which handles complex objects
303
+ serialized = artifact.dumps(fmt=_WIRE_FMT)
304
+ # Store as a tuple of (type_name, serialized_data) for reconstruction
305
+ serializable_items[addr] = {
306
+ 'type': artifact.__class__.__name__,
307
+ 'module': artifact.__class__.__module__,
308
+ 'data': serialized
309
+ }
310
+ except Exception as e:
311
+ _l.warning(f"Failed to serialize {artifact.__class__.__name__} at 0x{addr:x}: {e}")
312
+ # Skip problematic artifacts rather than failing completely
313
+ continue
314
+
315
+ # Cache the serializable artifacts
316
+ self._light_caches[collection_name] = {
317
+ "items": serializable_items,
318
+ "timestamp": time.time()
319
+ }
320
+
321
+ _l.info(f"Computed {len(serializable_items)} light {collection_name} in {end_time - start_time:.3f}s")
322
+ return serializable_items
323
+ else:
324
+ _l.warning(f"Collection {collection_name} does not support light artifacts")
325
+ return {}
326
+
327
+ except Exception as e:
328
+ _l.warning(f"Failed to compute light artifacts for {collection_name}: {e}")
329
+ # Return stale cache if available, otherwise empty dict
330
+ if cache_entry:
331
+ _l.debug(f"Returning stale cache for {collection_name} due to error")
332
+ return cache_entry["items"]
333
+ return {}
334
+
335
+
336
+ class DecompilerServer:
337
+ """
338
+ A server that exposes DecompilerInterface APIs over AF_UNIX sockets.
339
+
340
+ This server wraps a DecompilerInterface instance and provides network access
341
+ to all its public methods and artifact collections through AF_UNIX sockets.
342
+ """
343
+
344
+ def __init__(self,
345
+ decompiler_interface: Optional[DecompilerInterface] = None,
346
+ socket_path: Optional[str] = None,
347
+ server_id: Optional[str] = None,
348
+ register: bool = True,
349
+ **interface_kwargs):
350
+ """
351
+ Initialize the DecompilerServer.
352
+
353
+ Args:
354
+ decompiler_interface: An existing DecompilerInterface instance. If None,
355
+ one will be created using DecompilerInterface.discover()
356
+ socket_path: Path for the AF_UNIX socket. If None, a path is derived from server_id.
357
+ server_id: Optional explicit server ID. If None, a new one is generated.
358
+ register: If True, write the server info into the shared registry.
359
+ **interface_kwargs: Arguments passed to DecompilerInterface.discover() if
360
+ decompiler_interface is None
361
+ """
362
+
363
+ self.server_id = server_id or server_registry.new_server_id()
364
+ self.socket_path = socket_path
365
+ self._register = register
366
+ self._registered = False
367
+ self._server_socket = None
368
+ self._server_thread = None
369
+ self._running = False
370
+ self._clients = []
371
+ self._client_threads = []
372
+
373
+ # Main-thread dispatch: some backends (notably IDA/idalib) reject
374
+ # cross-thread API access. For those we route backend calls through
375
+ # a queue so they run on the thread that set the backend up.
376
+ self._main_thread_queue: "queue.Queue" = queue.Queue()
377
+ self._main_thread_ident: Optional[int] = None
378
+
379
+ # Event subscription tracking
380
+ self._event_subscribers = [] # List of sockets subscribed to events
381
+ self._event_subscribers_lock = threading.Lock()
382
+
383
+ # Track whether deci.shutdown() already ran, so teardown is idempotent
384
+ # across the worker-initiated stop() and the main-thread __exit__.
385
+ self._deci_shutdown_done = False
386
+
387
+ # Initialize the decompiler interface
388
+ if decompiler_interface is not None:
389
+ self.deci = decompiler_interface
390
+ else:
391
+ if interface_kwargs and interface_kwargs.get("headless", False):
392
+ forced_decompiler = interface_kwargs.get("force_decompiler", None)
393
+ if forced_decompiler is None:
394
+ _l.warning(f"Using a headless interface without setting a decompiler has unpredictable behavior!")
395
+ _l.info(f"Using headless interface utilizing %s", forced_decompiler)
396
+ else:
397
+ _l.info("Discovering decompiler interface...")
398
+
399
+ self.deci = DecompilerInterface.discover(**interface_kwargs)
400
+ if self.deci is None:
401
+ raise RuntimeError("Failed to discover decompiler interface")
402
+
403
+ # Cache static metadata on the *main* thread so that the connection
404
+ # handshake (`server_info`) never touches the backend from a worker
405
+ # thread — IDA/idalib raises "Function can be called from the main
406
+ # thread only" the moment such access happens.
407
+ self._cached_server_info = self._build_static_server_info()
408
+
409
+ # Create socket handler
410
+ self.handler = SocketServerHandler(self.deci, server=self)
411
+
412
+ # Register artifact change callbacks to broadcast events
413
+ self._register_artifact_callbacks()
414
+
415
+ # Generate socket path if not provided
416
+ if self.socket_path is None:
417
+ socket_path = server_registry.default_socket_path(self.server_id)
418
+ self.socket_path = socket_path
419
+ self._temp_dir = os.path.dirname(socket_path)
420
+ else:
421
+ self._temp_dir = None
422
+
423
+ _l.info(f"DecompilerServer initialized with {self.deci.name} interface (id={self.server_id})")
424
+ _l.info(f"Socket path: {self.socket_path}")
425
+
426
+ def _build_static_server_info(self) -> Dict[str, Any]:
427
+ """Collect immutable server metadata on whatever thread calls us.
428
+
429
+ This runs from ``__init__`` — i.e. the thread that constructed the
430
+ deci (the main thread in the CLI path). Capturing the values here
431
+ means ``server_info`` replies can be served from any worker thread
432
+ without re-entering backends like IDA that reject cross-thread API
433
+ calls.
434
+ """
435
+ binary_path = None
436
+ binary_hash = None
437
+ if self.deci:
438
+ try:
439
+ raw_path = self.deci.binary_path
440
+ binary_path = str(raw_path) if raw_path else None
441
+ except Exception as exc:
442
+ _l.debug("Failed to cache binary_path: %s", exc)
443
+ try:
444
+ binary_hash = self.deci.binary_hash
445
+ except Exception as exc:
446
+ _l.debug("Failed to cache binary_hash: %s", exc)
447
+
448
+ return {
449
+ "name": "DecLib DecompilerServer (AF_UNIX)",
450
+ "version": "3.0.0",
451
+ "decompiler": self.deci.name if self.deci else "unknown",
452
+ "protocol": "unix_socket",
453
+ "binary_hash": binary_hash,
454
+ "binary_path": binary_path,
455
+ "server_id": self.server_id,
456
+ }
457
+
458
+ def _register_artifact_callbacks(self):
459
+ """Register callbacks to broadcast artifact changes to subscribed clients"""
460
+ from declib.artifacts import Comment, Struct, Enum, Typedef, GlobalVariable, FunctionHeader, StackVariable
461
+
462
+ # Register callbacks for different artifact types
463
+ self.deci.artifact_change_callbacks[Comment].append(
464
+ lambda artifact, **kwargs: self._broadcast_event("comment_changed", artifact, **kwargs)
465
+ )
466
+ self.deci.artifact_change_callbacks[Struct].append(
467
+ lambda artifact, **kwargs: self._broadcast_event("struct_changed", artifact, **kwargs)
468
+ )
469
+ self.deci.artifact_change_callbacks[Enum].append(
470
+ lambda artifact, **kwargs: self._broadcast_event("enum_changed", artifact, **kwargs)
471
+ )
472
+ self.deci.artifact_change_callbacks[Typedef].append(
473
+ lambda artifact, **kwargs: self._broadcast_event("typedef_changed", artifact, **kwargs)
474
+ )
475
+ self.deci.artifact_change_callbacks[GlobalVariable].append(
476
+ lambda artifact, **kwargs: self._broadcast_event("global_variable_changed", artifact, **kwargs)
477
+ )
478
+ self.deci.artifact_change_callbacks[FunctionHeader].append(
479
+ lambda artifact, **kwargs: self._broadcast_event("function_header_changed", artifact, **kwargs)
480
+ )
481
+ self.deci.artifact_change_callbacks[StackVariable].append(
482
+ lambda artifact, **kwargs: self._broadcast_event("stack_variable_changed", artifact, **kwargs)
483
+ )
484
+
485
+ def _broadcast_event(self, event_type: str, artifact, **kwargs):
486
+ """Broadcast an artifact change event to all subscribed clients"""
487
+ with self._event_subscribers_lock:
488
+ if not self._event_subscribers:
489
+ _l.debug(f"No subscribers for event: {event_type}")
490
+ return
491
+
492
+ # Serialize the artifact
493
+ try:
494
+ serialized_artifact = {
495
+ 'type': artifact.__class__.__name__,
496
+ 'module': artifact.__class__.__module__,
497
+ 'data': artifact.dumps(fmt=_WIRE_FMT),
498
+ 'is_artifact': True
499
+ }
500
+
501
+ event_message = {
502
+ "event_type": event_type,
503
+ "artifact": serialized_artifact,
504
+ "kwargs": kwargs
505
+ }
506
+
507
+ # Send to all subscribers
508
+ dead_subscribers = []
509
+ for subscriber_socket in self._event_subscribers:
510
+ try:
511
+ SocketProtocol.send_message(subscriber_socket, event_message)
512
+ _l.debug(f"Broadcasted {event_type} to subscriber")
513
+ except Exception as e:
514
+ _l.warning(f"Failed to send event to subscriber: {e}")
515
+ dead_subscribers.append(subscriber_socket)
516
+
517
+ # Remove dead subscribers
518
+ for dead_socket in dead_subscribers:
519
+ self._event_subscribers.remove(dead_socket)
520
+ _l.debug("Removed dead subscriber")
521
+
522
+ except Exception as e:
523
+ _l.error(f"Failed to broadcast event {event_type}: {e}")
524
+
525
+ def start(self):
526
+ """Start the server in a separate thread"""
527
+ if self._running:
528
+ _l.warning("Server is already running")
529
+ return
530
+
531
+ _l.info(f"Starting DecompilerServer on {self.socket_path}")
532
+
533
+ # Create socket (AF_UNIX if available, else AF_INET)
534
+ if hasattr(socket, "AF_UNIX"):
535
+ self._server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
536
+ self._server_socket.settimeout(1.0)
537
+ if os.path.exists(self.socket_path):
538
+ os.unlink(self.socket_path)
539
+ self._server_socket.bind(self.socket_path)
540
+ else:
541
+ self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
542
+ self._server_socket.settimeout(1.0)
543
+ self._server_socket.bind(('127.0.0.1', 0))
544
+ port = self._server_socket.getsockname()[1]
545
+ try:
546
+ with open(self.socket_path, 'w') as f:
547
+ f.write(str(port))
548
+ except Exception as e:
549
+ _l.error(f"Failed to write port to {self.socket_path}: {e}")
550
+ self._server_socket.listen(5)
551
+
552
+ # Set running flag before starting thread
553
+ self._running = True
554
+
555
+ # Start server in a separate thread
556
+ self._server_thread = threading.Thread(target=self._server_loop, daemon=True)
557
+ self._server_thread.start()
558
+
559
+ # Register in shared registry so other processes can find us.
560
+ if self._register:
561
+ try:
562
+ binary_path = str(self.deci.binary_path) if self.deci and self.deci.binary_path else None
563
+ binary_hash = None
564
+ try:
565
+ binary_hash = self.deci.binary_hash if self.deci else None
566
+ except Exception:
567
+ binary_hash = None
568
+ server_registry.register_server({
569
+ "id": self.server_id,
570
+ "socket_path": self.socket_path,
571
+ "backend": self.deci.name if self.deci else None,
572
+ "binary_path": binary_path,
573
+ "binary_hash": binary_hash,
574
+ })
575
+ self._registered = True
576
+ except Exception as exc:
577
+ _l.warning("Failed to register server: %s", exc)
578
+
579
+ _l.info(f"DecompilerServer started successfully on unix://{self.socket_path}")
580
+ _l.info("Connect with: DecompilerClient.discover('unix://{}')".format(self.socket_path))
581
+
582
+ def _server_loop(self):
583
+ """Main server loop"""
584
+ try:
585
+ while self._running:
586
+ try:
587
+ client_socket, addr = self._server_socket.accept()
588
+ self._clients.append(client_socket)
589
+
590
+ # Handle client in separate thread
591
+ client_thread = threading.Thread(
592
+ target=self.handler.handle_client,
593
+ args=(client_socket, str(addr)),
594
+ daemon=True
595
+ )
596
+ self._client_threads.append(client_thread)
597
+ client_thread.start()
598
+
599
+ except socket.timeout:
600
+ # Normal timeout, continue loop to check if we should stop
601
+ continue
602
+ except OSError:
603
+ # Socket was closed
604
+ break
605
+ except Exception as e:
606
+ _l.error(f"Error accepting client: {e}")
607
+
608
+ except Exception as e:
609
+ _l.error(f"Server loop error: {e}")
610
+ finally:
611
+ _l.info("Server loop ended")
612
+
613
+ def stop(self):
614
+ """Stop the server"""
615
+ if not self._running:
616
+ _l.warning("Server is not running")
617
+ return
618
+
619
+ _l.info("Stopping DecompilerServer...")
620
+ self._running = False
621
+
622
+ # Wake the main-thread dispatcher so it can exit `wait_for_shutdown`.
623
+ try:
624
+ self._main_thread_queue.put_nowait(_MAIN_THREAD_SHUTDOWN)
625
+ except Exception:
626
+ pass
627
+
628
+ # Close all client connections
629
+ for client in self._clients:
630
+ try:
631
+ client.close()
632
+ except:
633
+ pass
634
+
635
+ # Close server socket
636
+ if self._server_socket:
637
+ self._server_socket.close()
638
+
639
+ # Wait for threads to finish (short timeout since we use daemon threads)
640
+ if self._server_thread and self._server_thread.is_alive():
641
+ self._server_thread.join(timeout=2.0)
642
+
643
+ for thread in self._client_threads:
644
+ if thread.is_alive():
645
+ thread.join(timeout=0.5)
646
+
647
+ # Clean up socket file and temp directory
648
+ if os.path.exists(self.socket_path):
649
+ os.unlink(self.socket_path)
650
+
651
+ if self._temp_dir and os.path.exists(self._temp_dir):
652
+ try:
653
+ os.rmdir(self._temp_dir)
654
+ except:
655
+ pass
656
+
657
+ # Remove from registry
658
+ if self._registered:
659
+ try:
660
+ server_registry.unregister_server(self.server_id)
661
+ except Exception as exc:
662
+ _l.debug("Failed to unregister server %s: %s", self.server_id, exc)
663
+ self._registered = False
664
+
665
+ # Shutdown the decompiler interface. For backends that need the main
666
+ # thread (IDA/idalib), defer to the main thread which will run the
667
+ # shutdown after leaving the dispatch loop — doing it from a worker
668
+ # thread here raises "Function can be called from the main thread
669
+ # only". wait_for_shutdown() / __exit__ pick it up via
670
+ # _shutdown_deci_if_needed().
671
+ if self.deci and not self._deci_shutdown_done:
672
+ on_main = (
673
+ self._main_thread_ident is None
674
+ or threading.get_ident() == self._main_thread_ident
675
+ )
676
+ if on_main or not self.requires_main_thread:
677
+ try:
678
+ self.deci.shutdown()
679
+ self._deci_shutdown_done = True
680
+ except Exception as e:
681
+ _l.warning(f"Error shutting down decompiler: {e}")
682
+
683
+ _l.info("DecompilerServer stopped")
684
+
685
+ def is_running(self) -> bool:
686
+ """Check if the server is currently running"""
687
+ return self._running
688
+
689
+ @property
690
+ def requires_main_thread(self) -> bool:
691
+ """Whether backend API calls must be routed to the main thread.
692
+
693
+ Set by the decompiler interface; IDA's idalib is the canonical case.
694
+ """
695
+ if not self.deci:
696
+ return False
697
+ return bool(getattr(self.deci, "requires_main_thread_dispatch", False))
698
+
699
+ def run_on_main_thread(self, func, *args, **kwargs):
700
+ """Run ``func(*args, **kwargs)`` on the server's main thread.
701
+
702
+ If the calling thread *is* the main thread, execute inline — this
703
+ avoids a deadlock when the main thread is itself invoking a method
704
+ (e.g. during ``__enter__`` / ``start``).
705
+ """
706
+ if self._main_thread_ident is not None and threading.get_ident() == self._main_thread_ident:
707
+ return func(*args, **kwargs)
708
+
709
+ result_q: "queue.Queue" = queue.Queue(maxsize=1)
710
+ self._main_thread_queue.put((func, args, kwargs, result_q))
711
+ result = result_q.get()
712
+ if isinstance(result, _MainThreadError):
713
+ raise result.exc
714
+ return result
715
+
716
+ def _main_thread_dispatch_loop(self):
717
+ """Drain backend work from the main-thread queue until shutdown.
718
+
719
+ Only used for backends that require main-thread dispatch (IDA).
720
+ Runs on the thread that called ``wait_for_shutdown`` — i.e. the
721
+ thread that originally created the ``deci``.
722
+ """
723
+ self._main_thread_ident = threading.get_ident()
724
+ while self._running:
725
+ try:
726
+ item = self._main_thread_queue.get(timeout=0.25)
727
+ except queue.Empty:
728
+ continue
729
+ if item is _MAIN_THREAD_SHUTDOWN:
730
+ break
731
+ func, args, kwargs, result_q = item
732
+ try:
733
+ result = func(*args, **kwargs)
734
+ except BaseException as exc: # relay every failure, including Java exceptions
735
+ result = _MainThreadError(exc)
736
+ result_q.put(result)
737
+
738
+ def wait_for_shutdown(self):
739
+ """Wait for the server to be shut down (blocking)"""
740
+ if self.requires_main_thread:
741
+ # Become the main-thread dispatcher. This blocks until stop().
742
+ try:
743
+ self._main_thread_dispatch_loop()
744
+ except KeyboardInterrupt:
745
+ _l.info("Received interrupt signal, stopping server...")
746
+ self.stop()
747
+ # Now that we're back on the main thread with the dispatch loop
748
+ # drained, finish any backend teardown stop() had to defer.
749
+ self._shutdown_deci_if_needed()
750
+ return
751
+
752
+ if self._server_thread and self._server_thread.is_alive():
753
+ try:
754
+ self._server_thread.join()
755
+ except KeyboardInterrupt:
756
+ _l.info("Received interrupt signal, stopping server...")
757
+ self.stop()
758
+
759
+ def _shutdown_deci_if_needed(self):
760
+ """Run deci.shutdown() once, from the caller's thread.
761
+
762
+ Callers must ensure they are on the thread that owns the backend
763
+ (typically the main thread). Idempotent.
764
+ """
765
+ if not self.deci or self._deci_shutdown_done:
766
+ return
767
+ try:
768
+ self.deci.shutdown()
769
+ except Exception as e:
770
+ _l.warning(f"Error shutting down decompiler: {e}")
771
+ finally:
772
+ self._deci_shutdown_done = True
773
+
774
+ def __enter__(self):
775
+ """Context manager entry"""
776
+ self.start()
777
+ return self
778
+
779
+ def __exit__(self, exc_type, exc_val, exc_tb):
780
+ """Context manager exit"""
781
+ self.stop()
782
+ self._shutdown_deci_if_needed()