replx-agent 2.0.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.

Potentially problematic release.


This version of replx-agent might be problematic. Click here for more details.

Files changed (46) hide show
  1. replx_agent/__init__.py +3 -0
  2. replx_agent/__main__.py +5 -0
  3. replx_agent/commands.py +131 -0
  4. replx_agent/helpers/__init__.py +3 -0
  5. replx_agent/helpers/scanner.py +308 -0
  6. replx_agent/pkg/__init__.py +11 -0
  7. replx_agent/pkg/compiler.py +153 -0
  8. replx_agent/pkg/registry.py +387 -0
  9. replx_agent/pkg/service.py +690 -0
  10. replx_agent/pkg/store.py +83 -0
  11. replx_agent/protocol/__init__.py +11 -0
  12. replx_agent/protocol/repl.py +1029 -0
  13. replx_agent/protocol/storage.py +1018 -0
  14. replx_agent/rpc/__init__.py +3 -0
  15. replx_agent/rpc/protocol.py +220 -0
  16. replx_agent/server/__init__.py +3 -0
  17. replx_agent/server/__main__.py +4 -0
  18. replx_agent/server/command_dispatcher.py +49 -0
  19. replx_agent/server/connection_manager.py +654 -0
  20. replx_agent/server/core.py +1221 -0
  21. replx_agent/server/handlers/__init__.py +24 -0
  22. replx_agent/server/handlers/exec.py +553 -0
  23. replx_agent/server/handlers/filesystem.py +383 -0
  24. replx_agent/server/handlers/firmware.py +118 -0
  25. replx_agent/server/handlers/i2c.py +23 -0
  26. replx_agent/server/handlers/package.py +182 -0
  27. replx_agent/server/handlers/repl.py +188 -0
  28. replx_agent/server/handlers/session.py +205 -0
  29. replx_agent/server/handlers/spi.py +23 -0
  30. replx_agent/server/handlers/transfer.py +600 -0
  31. replx_agent/server/handlers/uart.py +32 -0
  32. replx_agent/server/session_manager.py +391 -0
  33. replx_agent/server/tcp.py +207 -0
  34. replx_agent/terminal.py +566 -0
  35. replx_agent/transport/__init__.py +32 -0
  36. replx_agent/transport/serial.py +220 -0
  37. replx_agent/utils/__init__.py +95 -0
  38. replx_agent/utils/constants.py +78 -0
  39. replx_agent/utils/device_info.py +183 -0
  40. replx_agent/utils/exceptions.py +31 -0
  41. replx_agent-2.0.0.dist-info/METADATA +74 -0
  42. replx_agent-2.0.0.dist-info/RECORD +46 -0
  43. replx_agent-2.0.0.dist-info/WHEEL +5 -0
  44. replx_agent-2.0.0.dist-info/entry_points.txt +2 -0
  45. replx_agent-2.0.0.dist-info/licenses/LICENSE +21 -0
  46. replx_agent-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1221 @@
1
+ import asyncio
2
+ import contextlib
3
+ import gc
4
+ import hmac
5
+ import ipaddress
6
+ import logging
7
+ import os
8
+ import posixpath
9
+ import sys
10
+ import threading
11
+ import time
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ from typing import Optional, Dict, Any, Hashable
14
+
15
+ from replx_agent.utils.constants import (
16
+ DEFAULT_AGENT_PORT, AGENT_HOST, MAX_CONNECTIONS, HEARTBEAT_INTERVAL,
17
+ ZOMBIE_CHECK_INTERVAL, GC_THRESHOLD,
18
+ GC_COLLECT_INTERVAL,
19
+ )
20
+ from replx_agent.utils.exceptions import TransportError
21
+ from replx_agent.commands import CmdGroups
22
+ from replx_agent.rpc.protocol import AgentProtocol
23
+ from replx_agent import __version__
24
+ from .connection_manager import ConnectionManager, BoardConnection
25
+ from .session_manager import SessionManager, Session
26
+ from .tcp import AgentTCPServer, TcpReliabilityMixin
27
+ from .command_dispatcher import (
28
+ CommandContext,
29
+ NON_REPL_COMMANDS,
30
+ READ_ONLY_COMMANDS,
31
+ SESSION_COMMANDS,
32
+ PERSISTENT_BUSY_COMMANDS,
33
+ STREAMING_COMMANDS,
34
+ )
35
+
36
+ from .handlers import (
37
+ SessionCommandsMixin,
38
+ ExecCommandsMixin,
39
+ FilesystemCommandsMixin,
40
+ TransferCommandsMixin,
41
+ ReplCommandsMixin,
42
+ I2cCommandsMixin,
43
+ UartCommandsMixin,
44
+ SpiCommandsMixin,
45
+ DisconnectedError,
46
+ FirmwareCommandsMixin,
47
+ PackageCommandsMixin,
48
+ )
49
+
50
+ gc.set_threshold(*GC_THRESHOLD)
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ _FAST_POOL_WORKERS = 4
55
+ _SLOW_POOL_WORKERS = 16
56
+ _LOGICAL_PROBE_INTERVAL_S = 5.0
57
+ _LOGICAL_PROBE_TIMEOUT_S = 1.0
58
+ _LOGICAL_PROBE_FAILURE_THRESHOLD = 2
59
+
60
+ class AgentServer(
61
+ TcpReliabilityMixin,
62
+ SessionCommandsMixin,
63
+ ExecCommandsMixin,
64
+ FilesystemCommandsMixin,
65
+ TransferCommandsMixin,
66
+ ReplCommandsMixin,
67
+ I2cCommandsMixin,
68
+ UartCommandsMixin,
69
+ SpiCommandsMixin,
70
+ FirmwareCommandsMixin,
71
+ PackageCommandsMixin,
72
+ ):
73
+ def __init__(self, port: int = None, host: str = None, auth_token: str = None):
74
+ self.agent_port = port or DEFAULT_AGENT_PORT
75
+ self.agent_host = host or AGENT_HOST
76
+ self.auth_token = (auth_token or os.environ.get("REPLX_AGENT_TOKEN") or "").strip()
77
+ try:
78
+ bind_address = ipaddress.ip_address(self.agent_host.split('%', 1)[0])
79
+ except ValueError as exc:
80
+ raise ValueError(f"Agent bind host must be an IP address: {self.agent_host}") from exc
81
+ if not bind_address.is_loopback and not self.auth_token:
82
+ raise RuntimeError(
83
+ "Binding replx-agent to a network interface requires REPLX_AGENT_TOKEN."
84
+ )
85
+ self.running = False
86
+
87
+ self.connection_manager = ConnectionManager()
88
+ self.session_manager = SessionManager()
89
+ self._notification_lock = threading.Lock()
90
+ self._pending_notifications: dict[Any, list[str]] = {}
91
+ self._i2c_bus: dict = {}
92
+ self._uart_bus: dict = {}
93
+ self._spi_bus: dict = {}
94
+ self._init_transfer_state()
95
+
96
+ self._command_handlers = self._build_command_handlers()
97
+
98
+ self._fast_executor = ThreadPoolExecutor(
99
+ max_workers=_FAST_POOL_WORKERS,
100
+ thread_name_prefix='agent-fast',
101
+ )
102
+ self._slow_executor = ThreadPoolExecutor(
103
+ max_workers=_SLOW_POOL_WORKERS,
104
+ thread_name_prefix='agent-slow',
105
+ )
106
+
107
+ self._loop: asyncio.AbstractEventLoop | None = None
108
+ self._stop_event: asyncio.Event | None = None
109
+ self._tcp_server: AgentTCPServer | None = None
110
+ self._tcp_thread: threading.Thread | None = None
111
+ self._init_tcp_reliability(response_cache_max=512, last_seq_max=256)
112
+ self._cleaned_up: bool = False
113
+
114
+ @property
115
+ def _default_port(self) -> Optional[str]:
116
+ return self.connection_manager.default_port
117
+
118
+ @_default_port.setter
119
+ def _default_port(self, value: Optional[str]):
120
+ self.connection_manager.default_port = value
121
+
122
+ def _to_real_path(self, virtual_path: str, conn: BoardConnection = None) -> str:
123
+ device_root_fs = posixpath.normpath(
124
+ (conn.device_root_fs if conn else "/") or "/"
125
+ )
126
+ virtual_path = (virtual_path or "/").replace("\\", "/")
127
+
128
+ if device_root_fs == "/":
129
+ return posixpath.normpath("/" + virtual_path.lstrip("/"))
130
+
131
+ if virtual_path == device_root_fs or virtual_path.startswith(device_root_fs + "/"):
132
+ real_path = posixpath.normpath(virtual_path)
133
+ else:
134
+ real_path = posixpath.normpath(
135
+ posixpath.join(device_root_fs, virtual_path.lstrip("/"))
136
+ )
137
+
138
+ if real_path != device_root_fs and not real_path.startswith(device_root_fs + "/"):
139
+ raise ValueError("Remote path is outside the device filesystem root")
140
+ return real_path
141
+
142
+ def _to_virtual_path(self, real_path: str, conn: BoardConnection = None) -> str:
143
+ device_root_fs = conn.device_root_fs if conn else "/"
144
+
145
+ if device_root_fs == '/':
146
+ return real_path
147
+
148
+ root = device_root_fs.rstrip('/')
149
+ if real_path == root:
150
+ return '/'
151
+ elif real_path.startswith(root + '/'):
152
+ return real_path[len(root):]
153
+ else:
154
+ return real_path
155
+
156
+ def _get_or_create_session(self, ppid: Hashable) -> Session:
157
+ return self.session_manager.get_or_create_session(ppid)
158
+
159
+ @staticmethod
160
+ def _client_ip(client_addr: tuple = None) -> str:
161
+ address = getattr(client_addr, 'address', client_addr)
162
+ return address[0] if address else AGENT_HOST
163
+
164
+ def _session_key(self, ppid: int, client_addr: tuple = None) -> Optional[Hashable]:
165
+ return self.session_manager.session_key(ppid, self._client_ip(client_addr))
166
+
167
+ def _get_session(self, ppid: Hashable) -> Optional[Session]:
168
+ return self.session_manager.get_session(ppid)
169
+
170
+ def _resolve_connection_for_session(self, ppid: Hashable, explicit_port: str = None) -> Optional[str]:
171
+ return self.session_manager.resolve_port(ppid, explicit_port, self._default_port)
172
+
173
+ def _effective_default_port(self, session=None) -> Optional[str]:
174
+ """Return the effective default port for a session: per-session
175
+ default takes precedence, falling back to the global default (M4).
176
+ """
177
+ if session is not None and getattr(session, 'default_port', None):
178
+ return session.default_port
179
+ return self._default_port
180
+
181
+ def _cleanup_zombie_sessions(self):
182
+ all_conns = self.connection_manager.get_all_connections()
183
+ connected_ports = set(all_conns.keys())
184
+ zombie_ppids = self.session_manager.cleanup_zombie_sessions(connected_ports=connected_ports)
185
+
186
+ if not zombie_ppids:
187
+ return
188
+
189
+ for conn in all_conns.values():
190
+ if conn.repl.active and conn.repl.ppid in zombie_ppids:
191
+ conn.repl.stop()
192
+ conn.release()
193
+
194
+ if conn.interactive.active and conn.interactive.ppid in zombie_ppids:
195
+ # Signal the blocking _read_ex loop to exit before stopping so
196
+ # the interactive thread unblocks quickly even when the board
197
+ # has frozen without disconnecting the USB port.
198
+ if conn.repl_protocol:
199
+ try:
200
+ conn.repl_protocol._stop_event.set()
201
+ conn.repl_protocol.interrupt()
202
+ except Exception as exc:
203
+ logger.warning(
204
+ "Could not interrupt zombie interactive session on %s: %s",
205
+ conn.port,
206
+ exc,
207
+ )
208
+ conn.interactive.stop()
209
+ conn.release()
210
+
211
+ busy, _busy_command, busy_session, _busy_client, _detached = conn.busy_snapshot()
212
+ if busy and not _detached and busy_session in zombie_ppids:
213
+ conn.release()
214
+
215
+ def _get_session_info(self, current_session_id=None) -> Dict[str, Any]:
216
+ if current_session_id is not None:
217
+ self.session_manager.get_session(current_session_id)
218
+ sessions_info = []
219
+ sessions = self.session_manager.get_all_sessions()
220
+ connections = self.connection_manager.get_all_connections()
221
+
222
+ for session_id, session in sessions.items():
223
+ session_info = {
224
+ 'ppid': session.ppid,
225
+ 'sid': session.ppid,
226
+ 'client_ip': session.client_ip,
227
+ 'session_key': f'{session.client_ip}/{session.ppid}',
228
+ 'is_current': session_id == current_session_id,
229
+ 'foreground': session.foreground,
230
+ 'backgrounds': list(session.backgrounds),
231
+ 'last_access': session.last_access,
232
+ 'default_port': session.default_port
233
+ }
234
+ sessions_info.append(session_info)
235
+
236
+ connections_info = []
237
+ any_detached = False
238
+ for port, conn in connections.items():
239
+ referencing_sessions = self.session_manager.find_sessions_using_port(port)
240
+ busy, busy_command, _busy_session, _busy_client, is_detached_port = conn.busy_snapshot()
241
+ if is_detached_port:
242
+ any_detached = True
243
+ conn_info = {
244
+ 'port': port,
245
+ 'device': conn.device,
246
+ 'core': conn.core,
247
+ 'version': conn.version,
248
+ 'manufacturer': getattr(conn, 'manufacturer', ''),
249
+ 'busy': busy or is_detached_port,
250
+ 'busy_command': busy_command or ("detached_script" if is_detached_port else None),
251
+ 'connected': conn.is_connected(),
252
+ 'sessions': referencing_sessions
253
+ }
254
+ connections_info.append(conn_info)
255
+
256
+ return {
257
+ 'sessions': sessions_info,
258
+ 'connections': connections_info,
259
+ 'default_port': self._default_port,
260
+ 'detached_running': any_detached
261
+ }
262
+
263
+ def _set_default_port(self, port: str):
264
+ self.connection_manager.default_port = port if port else None
265
+
266
+ def _get_connection(self, port: str) -> Optional[BoardConnection]:
267
+ return self.connection_manager.get_connection(port)
268
+
269
+ def _get_active_connection(self, ppid: Hashable = None, explicit_port: str = None) -> Optional[BoardConnection]:
270
+ if explicit_port:
271
+ conn = self.connection_manager.get_connection(explicit_port)
272
+ if conn:
273
+ return conn
274
+
275
+ if ppid:
276
+ session = self.session_manager.get_session(ppid)
277
+ if session and session.foreground:
278
+ conn = self.connection_manager.get_connection(session.foreground)
279
+ if conn:
280
+ return conn
281
+
282
+ return None
283
+
284
+ def _add_connection(self, port: str, connection: BoardConnection) -> None:
285
+ current_count = self.connection_manager.connection_count()
286
+ if current_count >= MAX_CONNECTIONS:
287
+ raise ValueError(f"Maximum connections ({MAX_CONNECTIONS}) reached. Free some connections first.")
288
+ self.connection_manager.add_connection(port, connection)
289
+
290
+ def _remove_connection(self, port: str) -> Optional[BoardConnection]:
291
+ return self.connection_manager.remove_connection(port)
292
+
293
+ def _get_all_connections(self) -> Dict[str, BoardConnection]:
294
+ return self.connection_manager.get_all_connections()
295
+
296
+ def _create_board_connection(self, port: str,
297
+ core: str = "RP2350", device: str = None,
298
+ device_root_fs: str = "/") -> BoardConnection:
299
+ if not port:
300
+ raise ValueError("Port is required")
301
+
302
+ board_conn, error = self.connection_manager.create_serial_connection(
303
+ port=port,
304
+ core=core,
305
+ device=device
306
+ )
307
+
308
+ if error:
309
+ raise RuntimeError(f"Failed to create connection: {error}")
310
+
311
+ if not board_conn:
312
+ raise RuntimeError("Failed to create connection: unknown error")
313
+
314
+ return board_conn
315
+
316
+ def start(self) -> None:
317
+ loop = asyncio.new_event_loop()
318
+ asyncio.set_event_loop(loop)
319
+ self._loop = loop
320
+ self._cleaned_up = False
321
+ logger.info("replx agent starting (PID %s)", os.getpid())
322
+ logger.info("TCP %s:%s", self.agent_host, self.agent_port)
323
+
324
+ try:
325
+ loop.run_until_complete(self._async_main())
326
+ except (KeyboardInterrupt, SystemExit):
327
+ logger.info("Shutting down...")
328
+ finally:
329
+ self._do_cleanup_resources()
330
+ if not loop.is_closed():
331
+ loop.close()
332
+ self._loop = None
333
+ self._stop_event = None
334
+
335
+ async def _async_main(self) -> None:
336
+ loop = asyncio.get_running_loop()
337
+ self._stop_event = asyncio.Event()
338
+
339
+ self._tcp_server = AgentTCPServer((self.agent_host, self.agent_port), self)
340
+ self.running = True
341
+ self._tcp_thread = threading.Thread(
342
+ target=self._tcp_server.serve_forever,
343
+ name='replx-agent-tcp',
344
+ daemon=True,
345
+ )
346
+ self._tcp_thread.start()
347
+ logger.info("replx agent started - listening on %s:%s (TCP)", self.agent_host, self.agent_port)
348
+
349
+ heartbeat_task = asyncio.create_task(
350
+ self._heartbeat_coro(), name='replx-heartbeat'
351
+ )
352
+ try:
353
+ await self._stop_event.wait()
354
+ finally:
355
+ heartbeat_task.cancel()
356
+ with contextlib.suppress(asyncio.CancelledError):
357
+ await asyncio.wait_for(asyncio.shield(heartbeat_task), timeout=2.0)
358
+ self._do_cleanup_resources()
359
+
360
+ async def _heartbeat_coro(self) -> None:
361
+ loop = asyncio.get_running_loop()
362
+ zombie_counter = 0
363
+ gc_counter = 0
364
+ while self.running:
365
+ await asyncio.sleep(HEARTBEAT_INTERVAL)
366
+ zombie_counter, gc_counter = await loop.run_in_executor(
367
+ self._slow_executor,
368
+ self._heartbeat_tick,
369
+ zombie_counter,
370
+ gc_counter,
371
+ )
372
+
373
+ def _heartbeat_tick(self, zombie_counter: int, gc_counter: int) -> tuple[int, int]:
374
+ all_conns = self.connection_manager.get_all_connections()
375
+ zombie_counter += 1
376
+ gc_counter += 1
377
+
378
+ if zombie_counter >= ZOMBIE_CHECK_INTERVAL:
379
+ self._cleanup_zombie_sessions()
380
+ zombie_counter = 0
381
+
382
+ if gc_counter >= GC_COLLECT_INTERVAL:
383
+ gc.collect()
384
+ gc_counter = 0
385
+
386
+ dead_ports = []
387
+ for port, conn in all_conns.items():
388
+ transport = getattr(
389
+ getattr(conn, 'repl_protocol', None), 'transport', None
390
+ )
391
+ if transport is None:
392
+ continue
393
+ # The snapshot may be stale: an intentional disconnect can close
394
+ # this transport (and a new connection can take the port) while we
395
+ # iterate. That is not a device removal.
396
+ if self.connection_manager.get_connection(port) is not conn:
397
+ continue
398
+
399
+ if not conn.acquire_for_heartbeat():
400
+ continue
401
+
402
+ should_disconnect = False
403
+ try:
404
+ if self.connection_manager.get_connection(port) is not conn:
405
+ continue
406
+ try:
407
+ if hasattr(transport, 'check_connection'):
408
+ is_alive = bool(transport.check_connection())
409
+ else:
410
+ is_alive = bool(transport.is_open)
411
+ except Exception:
412
+ is_alive = False
413
+
414
+ if self.connection_manager.get_connection(port) is not conn:
415
+ continue
416
+ if is_alive:
417
+ conn._liveness_fail_count = 0
418
+ now = time.monotonic()
419
+ if now - conn._last_logical_probe_time >= _LOGICAL_PROBE_INTERVAL_S:
420
+ conn._last_logical_probe_time = now
421
+ try:
422
+ conn.repl_protocol.probe(timeout=_LOGICAL_PROBE_TIMEOUT_S)
423
+ conn._logical_liveness_fail_count = 0
424
+ except Exception as exc:
425
+ conn._logical_liveness_fail_count += 1
426
+ should_disconnect = (
427
+ conn._logical_liveness_fail_count
428
+ >= _LOGICAL_PROBE_FAILURE_THRESHOLD
429
+ )
430
+ if should_disconnect:
431
+ logger.warning("REPL health probe failed on %s: %s", port, exc)
432
+ else:
433
+ conn._liveness_fail_count += 1
434
+ should_disconnect = conn._liveness_fail_count >= 3
435
+ finally:
436
+ if not should_disconnect or self.connection_manager.get_connection(port) is not conn:
437
+ conn.release(reset_liveness=False, update_last_command_time=False)
438
+
439
+ if should_disconnect and self.connection_manager.get_connection(port) is conn:
440
+ dead_ports.append((port, conn))
441
+
442
+ for port, conn in dead_ports:
443
+ try:
444
+ self._handle_port_disconnect(port, conn)
445
+ except Exception:
446
+ logger.exception("Failed to disconnect stale port %s", port)
447
+ finally:
448
+ conn.release(reset_liveness=False, update_last_command_time=False)
449
+
450
+ return zombie_counter, gc_counter
451
+
452
+ def _start_drain_thread(self, conn: BoardConnection):
453
+ conn.set_detached(True)
454
+
455
+ def drain_loop():
456
+ tail_buffer = b""
457
+ # Brief startup delay so the script can begin executing before we poll.
458
+ for _ in range(50):
459
+ if not conn.is_detached():
460
+ return
461
+ time.sleep(0.01)
462
+
463
+ # Maximum wall-clock time before we give up and force-release.
464
+ DRAIN_HARD_TIMEOUT_S = 120.0
465
+ # How long to wait without any serial data before running a
466
+ # liveness check (transport still open? board still alive?).
467
+ SILENCE_LIVENESS_CHECK_S = 1.0
468
+
469
+ start_time = time.time()
470
+ last_data_time = time.time()
471
+ last_liveness_check_time = time.time()
472
+
473
+ while conn.is_detached() and self.running:
474
+ now = time.time()
475
+
476
+ # Hard timeout: the script has been running too long without
477
+ # returning to the REPL. Force-release so the board manager
478
+ # becomes usable again (user can run `replx reset` to interrupt
479
+ # the script on the board itself).
480
+ if now - start_time > DRAIN_HARD_TIMEOUT_S:
481
+ logger.warning(
482
+ "replx agent: drain timeout for %s (%.0fs), force-releasing detached state",
483
+ conn.port,
484
+ DRAIN_HARD_TIMEOUT_S,
485
+ )
486
+ conn.set_detached(False)
487
+ conn.release()
488
+ return
489
+
490
+ try:
491
+ if conn.repl_protocol:
492
+ data = conn.repl_protocol.drain()
493
+ if data:
494
+ last_data_time = now
495
+ tail_buffer = (tail_buffer + data)[-128:]
496
+
497
+ # Normal completion: board returned to REPL prompt.
498
+ if b'\r\n>>> ' in tail_buffer or tail_buffer.endswith(b'>>> '):
499
+ conn.set_detached(False)
500
+ conn.release()
501
+ return
502
+
503
+ # Board reboot detected (hard-fault, watchdog, or
504
+ # explicit machine.reset()). The MicroPython banner
505
+ # appears before the REPL prompt, so catching it here
506
+ # lets us release immediately rather than waiting for
507
+ # the trailing `>>> `.
508
+ if b'MicroPython v' in data or b'soft reboot' in data:
509
+ conn.set_detached(False)
510
+ conn.release()
511
+ return
512
+ else:
513
+ # No data arriving. Periodically check whether the
514
+ # serial transport itself is still alive so that a USB
515
+ # cable pull (or hard board freeze) is detected even
516
+ # when no TransportError is raised proactively.
517
+ if (
518
+ now - last_data_time > SILENCE_LIVENESS_CHECK_S
519
+ and now - last_liveness_check_time > SILENCE_LIVENESS_CHECK_S
520
+ ):
521
+ last_liveness_check_time = now
522
+ transport = getattr(conn.repl_protocol, 'transport', None)
523
+ if transport is not None and hasattr(transport, 'check_connection'):
524
+ if not transport.check_connection():
525
+ conn.set_detached(False)
526
+ conn.release()
527
+ try:
528
+ self._handle_port_disconnect(conn.port, conn)
529
+ except Exception:
530
+ logger.exception("Failed to disconnect stale detached port %s", conn.port)
531
+ return
532
+
533
+ except Exception:
534
+ conn.set_detached(False)
535
+ conn.release()
536
+ transport = getattr(conn.repl_protocol, 'transport', None) if conn.repl_protocol else None
537
+ still_alive = False
538
+ try:
539
+ still_alive = bool(transport and transport.check_connection())
540
+ except Exception:
541
+ still_alive = False
542
+ if not still_alive:
543
+ try:
544
+ self._handle_port_disconnect(conn.port, conn)
545
+ except Exception as exc:
546
+ logger.warning("Failed to disconnect stale detached port %s: %s", conn.port, exc)
547
+ return
548
+
549
+ time.sleep(0.01)
550
+
551
+ drain_thread = threading.Thread(target=drain_loop, daemon=True)
552
+ conn._drain_thread = drain_thread
553
+ drain_thread.start()
554
+
555
+ def _stop_detached_script(self, conn: BoardConnection = None):
556
+ if conn:
557
+ if not conn.is_detached():
558
+ return
559
+
560
+ conn.stop_detached()
561
+
562
+ if conn.repl_protocol:
563
+ try:
564
+ conn.release()
565
+ except Exception as exc:
566
+ logger.warning("Failed to release busy state for detached script on %s: %s", conn.port, exc)
567
+ try:
568
+ conn.repl_protocol.interrupt()
569
+ time.sleep(0.05)
570
+ conn.repl_protocol.interrupt()
571
+ time.sleep(0.1)
572
+ except Exception as exc:
573
+ logger.warning("Failed to interrupt detached script on %s: %s", conn.port, exc)
574
+ try:
575
+ conn.repl_protocol.drain()
576
+ except Exception as exc:
577
+ logger.warning("Failed to drain detached script output on %s: %s", conn.port, exc)
578
+ else:
579
+ all_conns = self.connection_manager.get_all_connections()
580
+ for port, c in all_conns.items():
581
+ if c.is_detached():
582
+ self._stop_detached_script(c)
583
+
584
+ def _queue_disconnect_notification(self, ppids: list[Hashable], port: str) -> None:
585
+ message = f"Serial port {port} disconnected; the connection was closed."
586
+ with self._notification_lock:
587
+ for ppid in ppids:
588
+ if ppid is None:
589
+ continue
590
+ notifications = self._pending_notifications.setdefault(ppid, [])
591
+ if message not in notifications:
592
+ notifications.append(message)
593
+ if len(notifications) > 16:
594
+ del notifications[:-16]
595
+
596
+ def _attach_pending_notifications(self, response: dict, ppid: Hashable) -> dict:
597
+ if not isinstance(response, dict) or ppid is None:
598
+ return response
599
+ with self._notification_lock:
600
+ notifications = self._pending_notifications.pop(ppid, [])
601
+ if notifications:
602
+ response['notifications'] = notifications
603
+ return response
604
+
605
+ def _handle_port_disconnect(
606
+ self, port: str, conn: BoardConnection = None, *, notify_ppid: Hashable = None
607
+ ) -> None:
608
+ """Handle a detected physical port disconnection (USB cable pull, board reset, etc.).
609
+
610
+ Stops all active sessions, releases all locks, disconnects the serial
611
+ transport, and removes the connection from both the connection manager
612
+ and every session that referenced it. This is idempotent — calling it
613
+ on an already-removed port is a no-op.
614
+ """
615
+ if conn is None:
616
+ conn = self.connection_manager.get_connection(port)
617
+ if conn is None:
618
+ return
619
+ interactive_ppid = conn.interactive.ppid if conn.interactive.active else None
620
+ # Never tear down a newer connection that reused this port name.
621
+ if self.connection_manager.get_connection(port) is not conn:
622
+ return
623
+
624
+ # Stop drain / interactive / REPL sub-threads before closing the port.
625
+ if conn.is_detached():
626
+ conn.stop_detached()
627
+ if conn.repl.active:
628
+ conn.repl.stop()
629
+ if conn.interactive.active:
630
+ # Signal the blocking _read_ex loop to exit so the interactive
631
+ # thread can finish quickly before the transport is closed.
632
+ if conn.repl_protocol:
633
+ try:
634
+ conn.repl_protocol._stop_event.set()
635
+ conn.repl_protocol.interrupt()
636
+ except Exception:
637
+ pass
638
+ conn.interactive.stop()
639
+ conn.release()
640
+
641
+ # Close the serial port and remove the connection object.
642
+ self.connection_manager.disconnect(port)
643
+ # Unlink the port from every session so sessions don't hold stale refs.
644
+ affected_sessions = self.session_manager.remove_connection_from_all_sessions(port)
645
+ excluded_ppid = notify_ppid if notify_ppid is not None else interactive_ppid
646
+ self._queue_disconnect_notification(
647
+ [ppid for ppid in affected_sessions if ppid != excluded_ppid], port
648
+ )
649
+
650
+ logger.warning(
651
+ "replx agent: port %s disconnected (device removed or cable unplugged)",
652
+ port,
653
+ )
654
+
655
+ def _encode_response_safe(self, response: dict) -> bytes:
656
+ """Encode a response, truncating overly long error/result strings.
657
+
658
+ ``encode_message`` raises ``ValueError`` if the JSON payload exceeds
659
+ ``MAX_PAYLOAD_SIZE``. Long errors (eg. huge tracebacks or recursive
660
+ listings) would otherwise propagate up and silently swallow the
661
+ response (M2 fix).
662
+ """
663
+ from replx_agent.utils.constants import MAX_PAYLOAD_SIZE
664
+ try:
665
+ return AgentProtocol.encode_message(response)
666
+ except ValueError:
667
+ seq = response.get('seq', 0) if isinstance(response, dict) else 0
668
+ if isinstance(response, dict):
669
+ err = response.get('error') or 'response too large'
670
+ err_str = str(err)
671
+ limit = max(256, MAX_PAYLOAD_SIZE - 1024)
672
+ if len(err_str) > limit:
673
+ err_str = err_str[:limit] + '\n... [truncated]'
674
+ else:
675
+ err_str = 'response too large'
676
+ fallback = AgentProtocol.create_response(seq=seq, error=err_str)
677
+ try:
678
+ return AgentProtocol.encode_message(fallback)
679
+ except Exception:
680
+ return AgentProtocol.encode_message(
681
+ AgentProtocol.create_response(seq=seq, error='response too large')
682
+ )
683
+
684
+ def _encode_ack(self, seq: int) -> bytes:
685
+ return AgentProtocol.encode_message(AgentProtocol.create_ack(seq))
686
+
687
+ def _send_duplicate_reply(self, client_addr: tuple, seq: int) -> None:
688
+ """Reply to a duplicate request: replay cached response if we have
689
+ one, otherwise just re-ACK so the client doesn't give up while the
690
+ original handler is still running."""
691
+ cached = self._lookup_response(client_addr, seq)
692
+ if cached is not None:
693
+ self._safe_send(cached, client_addr)
694
+ return
695
+ self._safe_send(self._encode_ack(seq), client_addr)
696
+
697
+ def _authorize_request(self, msg: dict, client_addr: tuple) -> bool:
698
+ if not self.auth_token:
699
+ return True
700
+ if msg.get('type', 'request') != 'request':
701
+ return bool(getattr(client_addr, 'authenticated', False))
702
+ supplied = msg.get('auth_token')
703
+ if not isinstance(supplied, str) or not hmac.compare_digest(
704
+ supplied.encode('utf-8'), self.auth_token.encode('utf-8')
705
+ ):
706
+ return False
707
+ client_addr.authenticated = True
708
+ return True
709
+
710
+ def _send_auth_error(self, msg: dict, client_addr: tuple) -> None:
711
+ seq = msg.get('seq', 0)
712
+ self._safe_send(self._encode_ack(seq), client_addr)
713
+ response = AgentProtocol.create_response(
714
+ seq=seq,
715
+ error="Authentication failed; set the matching REPLX_AGENT_TOKEN.",
716
+ )
717
+ self._safe_send(self._encode_response_safe(response), client_addr)
718
+
719
+ def _handle_direct(self, msg: dict, client_addr: tuple) -> None:
720
+ seq = msg.get('seq', 0)
721
+ command = msg.get('command')
722
+
723
+ if not self._authorize_request(msg, client_addr):
724
+ self._send_auth_error(msg, client_addr)
725
+ return
726
+
727
+ if not self._check_and_record_seq(client_addr, seq):
728
+ self._send_duplicate_reply(client_addr, seq)
729
+ return
730
+
731
+ self._safe_send(self._encode_ack(seq), client_addr)
732
+
733
+ try:
734
+ sid = msg.get('ppid')
735
+ ppid = self._session_key(sid, client_addr)
736
+ explicit_port = msg.get('port')
737
+ args = msg.get('args', {})
738
+ client_ip = self._client_ip(client_addr)
739
+ ctx = CommandContext(
740
+ connection=None,
741
+ ppid=ppid,
742
+ sid=sid,
743
+ client_ip=client_ip,
744
+ explicit_port=explicit_port,
745
+ seq=seq,
746
+ client_addr=client_addr,
747
+ )
748
+ handler, use_args = self._command_handlers[command]
749
+ result = handler(ctx, **args) if use_args else handler(ctx)
750
+ response = AgentProtocol.create_response(seq=seq, result=result)
751
+ except Exception as exc:
752
+ response = AgentProtocol.create_response(seq=seq, error=str(exc))
753
+
754
+ response = self._attach_pending_notifications(response, self._session_key(msg.get('ppid'), client_addr))
755
+ encoded = self._encode_response_safe(response)
756
+ self._cache_response(client_addr, seq, encoded)
757
+ self._safe_send(encoded, client_addr)
758
+
759
+ def _handle_request(self, request: bytes | dict, client_addr: tuple):
760
+ msg = None
761
+ try:
762
+ msg = (
763
+ AgentProtocol.decode_message(request)
764
+ if isinstance(request, bytes)
765
+ else request
766
+ )
767
+ if not msg:
768
+ logger.warning("Invalid message from %s", client_addr)
769
+ return
770
+
771
+ seq = msg.get('seq', 0)
772
+ msg_type = msg.get('type', 'request')
773
+
774
+ if not self._authorize_request(msg, client_addr):
775
+ if msg_type == 'request':
776
+ self._send_auth_error(msg, client_addr)
777
+ return
778
+
779
+ if msg_type == 'input':
780
+ ppid = msg.get('ppid')
781
+ port = msg.get('port')
782
+ self._handle_input(msg, client_addr, ppid, port)
783
+ return
784
+
785
+ if msg_type == 'stream_ack':
786
+ self._ack_stream_completion(client_addr, seq)
787
+ return
788
+ if msg_type == 'transfer_end':
789
+ self._handle_transfer_end(client_addr, msg)
790
+ return
791
+ if msg_type == 'transfer_abort':
792
+ self._handle_transfer_abort(client_addr, msg)
793
+ return
794
+
795
+ if msg_type == 'request':
796
+ if not self._check_and_record_seq(client_addr, seq):
797
+ self._send_duplicate_reply(client_addr, seq)
798
+ return
799
+
800
+ defer_ack = (
801
+ msg_type == 'request'
802
+ and msg.get('command') in {'transfer_start', 'firmware_stage'}
803
+ )
804
+ if not defer_ack:
805
+ self._safe_send(self._encode_ack(seq), client_addr)
806
+
807
+ response = self._handle_message(msg, client_addr)
808
+
809
+ if defer_ack and response is None:
810
+ self._safe_send(self._encode_ack(seq), client_addr)
811
+
812
+ if response is not None:
813
+ response = self._attach_pending_notifications(response, self._session_key(msg.get('ppid'), client_addr))
814
+ response_data = self._encode_response_safe(response)
815
+ self._cache_response(client_addr, seq, response_data)
816
+ if msg.get('command') in STREAMING_COMMANDS:
817
+ self.send_completion(response_data, client_addr, seq)
818
+ else:
819
+ self._safe_send(response_data, client_addr)
820
+
821
+ except Exception as e:
822
+ try:
823
+ seq = msg.get('seq', 0) if isinstance(msg, dict) else 0
824
+ error_response = AgentProtocol.create_response(seq=seq, error=str(e))
825
+ ppid = self._session_key(msg.get('ppid'), client_addr) if isinstance(msg, dict) else None
826
+ error_response = self._attach_pending_notifications(error_response, ppid)
827
+ error_data = self._encode_response_safe(error_response)
828
+ self._cache_response(client_addr, seq, error_data)
829
+ if isinstance(msg, dict) and msg.get('command') in STREAMING_COMMANDS:
830
+ self.send_completion(error_data, client_addr, seq)
831
+ else:
832
+ self._safe_send(error_data, client_addr)
833
+ except Exception:
834
+ pass
835
+
836
+ def _build_command_handlers(self) -> dict:
837
+ return {
838
+ 'release': (self._cmd_release, False),
839
+ 'disconnect_port': (self._cmd_disconnect_port, True),
840
+ 'exec': (self._cmd_exec, True),
841
+ 'status': (self._cmd_status, False),
842
+ 'scan': (self._cmd_scan, True),
843
+ 'shutdown': (self._cmd_shutdown, False),
844
+ 'ping': (lambda ctx: {
845
+ "pong": True,
846
+ "protocol_version": 2,
847
+ "agent_version": __version__,
848
+ }, False),
849
+ 'reset': (self._cmd_reset, False),
850
+ 'run': (self._cmd_run, True),
851
+ 'run_stop': (self._cmd_run_stop, False),
852
+ 'ls': (self._cmd_ls, True),
853
+ 'ls_recursive': (self._cmd_ls_recursive, True),
854
+ 'cat': (self._cmd_cat, True),
855
+ 'rm': (self._cmd_rm, True),
856
+ 'rmdir': (self._cmd_rmdir, True),
857
+ 'mkdir': (self._cmd_mkdir, True),
858
+ 'is_dir': (self._cmd_is_dir, True),
859
+ 'mem': (self._cmd_mem, False),
860
+ 'cp': (self._cmd_cp, True),
861
+ 'mv': (self._cmd_mv, True),
862
+ 'df': (self._cmd_df, False),
863
+ 'touch': (self._cmd_touch, True),
864
+ 'format': (self._cmd_format, False),
865
+ 'get_file': (self._cmd_get_file, True),
866
+ 'get_file_streaming': (self._cmd_get_file_streaming, True),
867
+ 'put_file': (self._cmd_put_file, True),
868
+ 'put_file_batch': (self._cmd_put_file_batch, True),
869
+ 'get_file_batch': (self._cmd_get_file_batch, True),
870
+ 'transfer_start': (self._cmd_transfer_start, True),
871
+ 'firmware_stage': (self._cmd_firmware_stage, True),
872
+ 'firmware_info': (self._cmd_firmware_info, True),
873
+ 'firmware_flash': (self._cmd_firmware_flash, True),
874
+ 'firmware_discard': (self._cmd_firmware_discard, True),
875
+ 'stat': (self._cmd_stat, True),
876
+ 'repl_enter': (self._cmd_repl_enter, False),
877
+ 'repl_exit': (self._cmd_repl_exit, False),
878
+ 'repl_write': (self._cmd_repl_write, True),
879
+ 'repl_read': (self._cmd_repl_read, False),
880
+ 'session_info': (lambda ctx: self._get_session_info(ctx.ppid), False),
881
+ 'client_disconnect': (self._cmd_client_disconnect, True),
882
+ 'session_setup': (self._cmd_session_setup, True),
883
+ 'session_disconnect': (self._cmd_session_disconnect, True),
884
+ 'session_switch_fg': (self._cmd_session_switch_fg, False),
885
+ 'run_interactive': (self._cmd_run_interactive, True),
886
+ 'i2c_bus_set': (self._cmd_i2c_bus_set, True),
887
+ 'i2c_bus_get': (self._cmd_i2c_bus_get, False),
888
+ 'i2c_bus_clear': (self._cmd_i2c_bus_clear, False),
889
+ 'uart_bus_set': (self._cmd_uart_bus_set, True),
890
+ 'uart_bus_get': (self._cmd_uart_bus_get, False),
891
+ 'uart_bus_clear': (self._cmd_uart_bus_clear, False),
892
+ 'spi_bus_set': (self._cmd_spi_bus_set, True),
893
+ 'spi_bus_get': (self._cmd_spi_bus_get, False),
894
+ 'spi_bus_clear': (self._cmd_spi_bus_clear, False),
895
+ 'pkg_search': (self._cmd_pkg_search, True),
896
+ 'pkg_download': (self._cmd_pkg_download, True),
897
+ 'pkg_install': (self._cmd_pkg_install, True),
898
+ 'pkg_clean': (self._cmd_pkg_clean, False),
899
+ 'pkg_store_status': (self._cmd_pkg_store_status, False),
900
+ 'pkg_typehints_list': (self._cmd_pkg_typehints_list, False),
901
+ 'pkg_typehints_get': (self._cmd_pkg_typehints_get, True),
902
+ }
903
+
904
+ def _cmd_scan(self, ctx: CommandContext, max_workers: int = 10) -> dict:
905
+ from serial.tools.list_ports import comports
906
+ from replx_agent.helpers.scanner import DeviceScanner
907
+
908
+ with self.connection_manager.scan_lock:
909
+ port_infos = list(comports())
910
+ scanned, permission_denied = DeviceScanner.scan_serial_ports(
911
+ max_workers=max_workers,
912
+ exclude_ports=self.connection_manager.get_connected_ports(),
913
+ port_infos=port_infos,
914
+ reserve_port=self.connection_manager.reserve_probe_port,
915
+ release_port=self.connection_manager.release_probe_port,
916
+ )
917
+ present_ports = {
918
+ DeviceScanner.port_compare_key(port_info.device)
919
+ for port_info in port_infos
920
+ }
921
+ connections = [
922
+ connection
923
+ for connection in self.connection_manager.get_all_connection_info()
924
+ if connection.get('connected')
925
+ and DeviceScanner.port_compare_key(connection['port']) in present_ports
926
+ ]
927
+
928
+ return {
929
+ 'scanned': scanned,
930
+ 'permission_denied_ports': permission_denied,
931
+ 'connections': connections,
932
+ }
933
+
934
+ def _cmd_disconnect_port(self, ctx: CommandContext, port: str = None) -> dict:
935
+ target_port = port or ctx.explicit_port
936
+ if not target_port:
937
+ raise ValueError("Port required")
938
+
939
+ conn = self.connection_manager.get_connection(target_port)
940
+ if conn:
941
+ self.connection_manager.disconnect(target_port)
942
+ # Always remove from sessions regardless of whether the connection
943
+ # object still existed — prevents stale foreground references when the
944
+ # port was already removed by a concurrent _handle_port_disconnect().
945
+ self.session_manager.remove_connection_from_all_sessions(target_port)
946
+
947
+ return {"disconnected": target_port}
948
+
949
+ def _update_session_connections(self, ppid: Hashable, explicit_port: str) -> None:
950
+ session = self._get_or_create_session(ppid)
951
+ if explicit_port and explicit_port not in [c for c in session.get_all_connections()]:
952
+ conn_exists = self._get_connection(explicit_port) is not None
953
+
954
+ if conn_exists:
955
+ if session.foreground is None:
956
+ session.add_connection(explicit_port, as_foreground=True)
957
+ else:
958
+ session.add_connection(explicit_port, as_foreground=False)
959
+
960
+ def _validate_connection(self, ppid: Hashable, explicit_port: str, seq: int) -> tuple:
961
+ active_conn = self._get_active_connection(ppid, explicit_port)
962
+
963
+ if explicit_port and not active_conn:
964
+ return None, AgentProtocol.create_response(
965
+ seq=seq,
966
+ error=f"Connection {explicit_port} not found. Run 'replx --port {explicit_port} setup' first."
967
+ )
968
+
969
+ if explicit_port and active_conn and not active_conn.repl_protocol:
970
+ return None, AgentProtocol.create_response(
971
+ seq=seq,
972
+ error=f"Connection {explicit_port} is not properly connected."
973
+ )
974
+
975
+ return active_conn, None
976
+
977
+ def _handle_message(self, msg: dict, client_addr: tuple = None) -> dict:
978
+ command = msg.get('command')
979
+ args = msg.get('args', {})
980
+ seq = msg.get('seq', 0)
981
+ sid = msg.get('ppid')
982
+ client_ip = self._client_ip(client_addr)
983
+ ppid = self.session_manager.session_key(sid, client_ip)
984
+ explicit_port = msg.get('port')
985
+
986
+ if any(key in args for key in ('local_path', 'script_path')):
987
+ return AgentProtocol.create_response(
988
+ seq=seq,
989
+ error="Agent RPC does not accept CLI-local filesystem paths",
990
+ )
991
+
992
+ if ppid and command not in READ_ONLY_COMMANDS and command not in SESSION_COMMANDS:
993
+ self._update_session_connections(ppid, explicit_port)
994
+
995
+ if command not in NON_REPL_COMMANDS and command not in CmdGroups.DETACHED_ALLOW:
996
+ target_port = None
997
+ if explicit_port:
998
+ target_port = explicit_port
999
+ elif ppid:
1000
+ session = self.session_manager.get_session(ppid)
1001
+ if session and session.foreground:
1002
+ target_port = session.foreground
1003
+
1004
+ if target_port:
1005
+ target_conn = self.connection_manager.get_connection(target_port)
1006
+ if target_conn and target_conn.is_detached():
1007
+ return AgentProtocol.create_response(
1008
+ seq=seq,
1009
+ error=f"Connection {target_port} is busy. A detached script is running. Use 'replx reset' to stop it first."
1010
+ )
1011
+
1012
+ active_conn = None
1013
+ if command not in NON_REPL_COMMANDS and ppid:
1014
+ active_conn, error_response = self._validate_connection(ppid, explicit_port, seq)
1015
+ if error_response:
1016
+ return error_response
1017
+
1018
+ ctx = CommandContext(
1019
+ connection=active_conn,
1020
+ ppid=ppid,
1021
+ sid=sid,
1022
+ client_ip=client_ip,
1023
+ explicit_port=explicit_port,
1024
+ seq=seq,
1025
+ client_addr=client_addr
1026
+ )
1027
+
1028
+ if command not in NON_REPL_COMMANDS and active_conn:
1029
+ if active_conn.repl.active and command not in CmdGroups.REPL:
1030
+ if not active_conn.repl.is_owner(ppid):
1031
+ return AgentProtocol.create_response(
1032
+ seq=seq,
1033
+ error=f"Connection {active_conn.port} is busy. REPL session is active from another terminal. Exit REPL first with exit() or Ctrl+D."
1034
+ )
1035
+
1036
+ _acquired = False
1037
+ if command not in NON_REPL_COMMANDS and active_conn:
1038
+ if not active_conn.is_connected():
1039
+ return AgentProtocol.create_response(
1040
+ seq=seq,
1041
+ error=f"Connection {active_conn.port} was disconnected."
1042
+ )
1043
+ # REPL sub-commands (write/read/exit) bypass the busy-lock.
1044
+ # repl_enter already holds it; ownership is enforced inside each handler.
1045
+ _is_repl_subcmd = command in ('repl_write', 'repl_read', 'repl_exit')
1046
+ if _is_repl_subcmd and active_conn.repl.active and active_conn.repl.is_owner(ppid):
1047
+ pass # bypass acquire — repl_enter holds the lock
1048
+ else:
1049
+ allow_when_detached = command in CmdGroups.DETACHED_ALLOW
1050
+ if not active_conn.acquire_for_command(
1051
+ ppid, command, client_addr,
1052
+ allow_when_detached=allow_when_detached,
1053
+ ):
1054
+ _busy, busy_command, _busy_session, _busy_client, _detached = active_conn.busy_snapshot()
1055
+ return AgentProtocol.create_response(
1056
+ seq=seq,
1057
+ error=f"Connection {active_conn.port} is busy. Another command ({busy_command}) is currently running. Please wait for it to complete or press Ctrl+C to cancel."
1058
+ )
1059
+ # Track acquisition only for commands that should auto-release in finally.
1060
+ if command not in PERSISTENT_BUSY_COMMANDS and command not in STREAMING_COMMANDS:
1061
+ _acquired = True
1062
+
1063
+ try:
1064
+ if command == 'connect':
1065
+ connect_port = args.get('port') or explicit_port
1066
+ result = self._cmd_session_setup(
1067
+ ctx,
1068
+ port=connect_port,
1069
+ core=args.get('core', 'RP2350'),
1070
+ device=args.get('device'),
1071
+ as_foreground=True
1072
+ )
1073
+ elif command == 'set_default':
1074
+ port_arg = args.get('port') or explicit_port
1075
+ result = self._cmd_set_default(ctx, port=port_arg)
1076
+ elif command in self._command_handlers:
1077
+ handler, use_args = self._command_handlers[command]
1078
+ if command in STREAMING_COMMANDS:
1079
+ handler(ctx, **args) if use_args else handler(ctx)
1080
+ return None
1081
+ result = handler(ctx, **args) if use_args else handler(ctx)
1082
+ else:
1083
+ raise ValueError(f"Unknown command: {command}")
1084
+
1085
+ return AgentProtocol.create_response(seq=seq, result=result)
1086
+
1087
+ except (TransportError, DisconnectedError, ConnectionError, OSError, BrokenPipeError) as e:
1088
+ if active_conn:
1089
+ failed_port = active_conn.port
1090
+ if isinstance(e, (TransportError, DisconnectedError)) or not self.connection_manager.check_health(failed_port):
1091
+ self._handle_port_disconnect(failed_port, active_conn, notify_ppid=ppid)
1092
+ return AgentProtocol.create_response(seq=seq, error=f"Connection {failed_port} lost: {str(e)}. Connection removed.")
1093
+ return AgentProtocol.create_response(seq=seq, error=str(e))
1094
+ else:
1095
+ return AgentProtocol.create_response(seq=seq, error=f"Connection error: {str(e)}")
1096
+
1097
+ except Exception as e:
1098
+ return AgentProtocol.create_response(seq=seq, error=str(e))
1099
+
1100
+ finally:
1101
+ if _acquired and active_conn:
1102
+ active_conn.release()
1103
+
1104
+ def _cmd_client_disconnect(self, ctx: CommandContext, ip: str) -> dict:
1105
+ try:
1106
+ client_ip = ipaddress.ip_address(str(ip).strip()).compressed
1107
+ except ValueError as exc:
1108
+ raise ValueError(f"Invalid client IP: {ip}") from exc
1109
+
1110
+ removed_sessions = self.session_manager.remove_client(client_ip)
1111
+ removed_session_ids = {session_id for session_id, _session in removed_sessions}
1112
+ for conn in self.connection_manager.get_all_connections().values():
1113
+ busy, _command, busy_session, _client, detached = conn.busy_snapshot()
1114
+ if not busy or busy_session not in removed_session_ids:
1115
+ continue
1116
+ if detached:
1117
+ self._stop_detached_script(conn)
1118
+ continue
1119
+ if conn.interactive.active and conn.interactive.ppid == busy_session:
1120
+ if conn.repl_protocol:
1121
+ try:
1122
+ conn.repl_protocol._stop_event.set()
1123
+ conn.repl_protocol.interrupt()
1124
+ except Exception as exc:
1125
+ logger.warning(
1126
+ "Could not interrupt disconnected client's interactive session on %s: %s",
1127
+ conn.port,
1128
+ exc,
1129
+ )
1130
+ conn.interactive.stop()
1131
+ if conn.repl.active and conn.repl.ppid == busy_session:
1132
+ conn.repl.stop()
1133
+ conn.release_if_owned(busy_session)
1134
+
1135
+ affected_ports = {
1136
+ port
1137
+ for _session_id, session in removed_sessions
1138
+ for port in session.get_all_connections()
1139
+ }
1140
+ released_ports = []
1141
+ for port in sorted(affected_ports):
1142
+ if self.session_manager.find_sessions_using_port(port):
1143
+ continue
1144
+ self._cmd_disconnect_port(ctx, port=port)
1145
+ released_ports.append(port)
1146
+
1147
+ return {
1148
+ "client_ip": client_ip,
1149
+ "sessions_disconnected": len(removed_sessions),
1150
+ "released_ports": released_ports,
1151
+ }
1152
+
1153
+ def cleanup(self) -> None:
1154
+ self.running = False
1155
+ loop = self._loop
1156
+ stop_event = self._stop_event
1157
+ if loop is not None and not loop.is_closed() and stop_event is not None:
1158
+ loop.call_soon_threadsafe(stop_event.set)
1159
+ else:
1160
+ self._do_cleanup_resources()
1161
+
1162
+ def _do_cleanup_resources(self) -> None:
1163
+ if self._cleaned_up:
1164
+ return
1165
+ self._cleaned_up = True
1166
+
1167
+ for conn in self.connection_manager.get_all_connections().values():
1168
+ if conn.is_detached():
1169
+ conn.stop_detached()
1170
+
1171
+ for conn in self.connection_manager.get_all_connections().values():
1172
+ if conn.repl.active:
1173
+ conn.repl.stop()
1174
+ if conn.interactive.active:
1175
+ conn.interactive.stop()
1176
+
1177
+ self.connection_manager.disconnect_all()
1178
+ self.session_manager.clear_all_sessions()
1179
+ self._cleanup_transfer_state()
1180
+
1181
+ tcp_server = self._tcp_server
1182
+ if tcp_server is not None:
1183
+ try:
1184
+ tcp_server.shutdown()
1185
+ tcp_server.server_close()
1186
+ except Exception:
1187
+ pass
1188
+ self._tcp_server = None
1189
+
1190
+ try:
1191
+ self._fast_executor.shutdown(wait=False, cancel_futures=True)
1192
+ except Exception:
1193
+ pass
1194
+ try:
1195
+ self._slow_executor.shutdown(wait=False, cancel_futures=True)
1196
+ except Exception:
1197
+ pass
1198
+
1199
+ logger.info("replx agent stopped")
1200
+
1201
+
1202
+ def main():
1203
+ import argparse
1204
+
1205
+ parser = argparse.ArgumentParser(description="Run the replx board-management agent.")
1206
+ parser.add_argument("port", nargs="?", type=int, default=DEFAULT_AGENT_PORT)
1207
+ parser.add_argument("--host", default=AGENT_HOST, help="IP address to bind (default: loopback).")
1208
+ args = parser.parse_args()
1209
+ if not 1 <= args.port <= 65535:
1210
+ parser.error("port must be between 1 and 65535")
1211
+
1212
+ server = AgentServer(port=args.port, host=args.host)
1213
+ try:
1214
+ server.start()
1215
+ except Exception as e:
1216
+ logger.exception("Fatal error")
1217
+ sys.exit(1)
1218
+
1219
+
1220
+ if __name__ == '__main__':
1221
+ main()