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.
- replx_agent/__init__.py +3 -0
- replx_agent/__main__.py +5 -0
- replx_agent/commands.py +131 -0
- replx_agent/helpers/__init__.py +3 -0
- replx_agent/helpers/scanner.py +308 -0
- replx_agent/pkg/__init__.py +11 -0
- replx_agent/pkg/compiler.py +153 -0
- replx_agent/pkg/registry.py +387 -0
- replx_agent/pkg/service.py +690 -0
- replx_agent/pkg/store.py +83 -0
- replx_agent/protocol/__init__.py +11 -0
- replx_agent/protocol/repl.py +1029 -0
- replx_agent/protocol/storage.py +1018 -0
- replx_agent/rpc/__init__.py +3 -0
- replx_agent/rpc/protocol.py +220 -0
- replx_agent/server/__init__.py +3 -0
- replx_agent/server/__main__.py +4 -0
- replx_agent/server/command_dispatcher.py +49 -0
- replx_agent/server/connection_manager.py +654 -0
- replx_agent/server/core.py +1221 -0
- replx_agent/server/handlers/__init__.py +24 -0
- replx_agent/server/handlers/exec.py +553 -0
- replx_agent/server/handlers/filesystem.py +383 -0
- replx_agent/server/handlers/firmware.py +118 -0
- replx_agent/server/handlers/i2c.py +23 -0
- replx_agent/server/handlers/package.py +182 -0
- replx_agent/server/handlers/repl.py +188 -0
- replx_agent/server/handlers/session.py +205 -0
- replx_agent/server/handlers/spi.py +23 -0
- replx_agent/server/handlers/transfer.py +600 -0
- replx_agent/server/handlers/uart.py +32 -0
- replx_agent/server/session_manager.py +391 -0
- replx_agent/server/tcp.py +207 -0
- replx_agent/terminal.py +566 -0
- replx_agent/transport/__init__.py +32 -0
- replx_agent/transport/serial.py +220 -0
- replx_agent/utils/__init__.py +95 -0
- replx_agent/utils/constants.py +78 -0
- replx_agent/utils/device_info.py +183 -0
- replx_agent/utils/exceptions.py +31 -0
- replx_agent-2.0.0.dist-info/METADATA +74 -0
- replx_agent-2.0.0.dist-info/RECORD +46 -0
- replx_agent-2.0.0.dist-info/WHEEL +5 -0
- replx_agent-2.0.0.dist-info/entry_points.txt +2 -0
- replx_agent-2.0.0.dist-info/licenses/LICENSE +21 -0
- 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()
|