@neoline/hostbridge 2.0.4 → 2.0.5
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.
- package/README.md +11 -9
- package/docs/ARCHITECTURE.md +62 -0
- package/package.json +2 -1
- package/pyproject.toml +1 -1
- package/src/server_control_mcp/__init__.py +4 -3
- package/src/server_control_mcp/async_lifecycle.py +41 -0
- package/src/server_control_mcp/cli.py +1 -1
- package/src/server_control_mcp/client.py +302 -287
- package/src/server_control_mcp/config.py +2 -1
- package/src/server_control_mcp/daemon.py +233 -526
- package/src/server_control_mcp/doctor.py +34 -2
- package/src/server_control_mcp/hosts.py +136 -2
- package/src/server_control_mcp/mock_ssh.py +52 -14
- package/src/server_control_mcp/mock_ssh_exec.py +147 -58
- package/src/server_control_mcp/mock_ssh_session.py +3 -2
- package/src/server_control_mcp/mock_ssh_sftp.py +126 -28
- package/src/server_control_mcp/mock_ssh_tunnel.py +141 -444
- package/src/server_control_mcp/mux_connection.py +326 -0
- package/src/server_control_mcp/mux_daemon.py +186 -0
- package/src/server_control_mcp/mux_protocol.py +279 -0
- package/src/server_control_mcp/mux_records.py +71 -0
- package/src/server_control_mcp/mux_rpc.py +1779 -0
- package/src/server_control_mcp/mux_service.py +506 -0
- package/src/server_control_mcp/mux_stream.py +283 -0
- package/src/server_control_mcp/mux_sync_client.py +224 -0
- package/src/server_control_mcp/remote_agent_bundle.py +56 -0
- package/src/server_control_mcp/remote_mux_agent.py +769 -0
- package/src/server_control_mcp/runtime_services.py +862 -0
- package/src/server_control_mcp/server.py +33 -18
- package/src/server_control_mcp/services.py +2 -666
- package/src/server_control_mcp/transports/__init__.py +4 -8
- package/src/server_control_mcp/transports/base.py +60 -38
- package/src/server_control_mcp/transports/ssh.py +206 -259
- package/src/server_control_mcp/tunnel_manager.py +1245 -0
- package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
- package/src/server_control_mcp/tunnel_providers.py +20 -0
- package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
- package/src/server_control_mcp/protocol.py +0 -363
- package/src/server_control_mcp/remote_tunnel_agent.py +0 -143
- package/src/server_control_mcp/transports/pty.py +0 -515
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Bounded stream state shared by multiplexed RPC clients and servers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from .mux_connection import ByteCreditWindow, StreamLifecycle, StreamState
|
|
11
|
+
from .mux_protocol import SequenceTracker
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MuxStreamOwner(Protocol):
|
|
15
|
+
async def _stream_accept(self, stream: MuxRpcStream) -> None: ...
|
|
16
|
+
|
|
17
|
+
async def _stream_send_data(self, stream: MuxRpcStream, data: bytes) -> None: ...
|
|
18
|
+
|
|
19
|
+
async def _stream_send_eof(self, stream: MuxRpcStream) -> None: ...
|
|
20
|
+
|
|
21
|
+
async def _stream_release_credit(self, stream: MuxRpcStream, amount: int) -> None: ...
|
|
22
|
+
|
|
23
|
+
async def _stream_reset(self, stream: MuxRpcStream, message: str) -> None: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AsyncByteCreditWindow:
|
|
27
|
+
def __init__(self, credit: int, *, on_stall: Callable[[], None] | None = None) -> None:
|
|
28
|
+
if not isinstance(credit, int) or isinstance(credit, bool) or credit <= 0:
|
|
29
|
+
raise ValueError("credit must be a positive integer")
|
|
30
|
+
self._available = credit
|
|
31
|
+
self._maximum = credit
|
|
32
|
+
self._on_stall = on_stall
|
|
33
|
+
self._stall_count = 0
|
|
34
|
+
self._closed_error: BaseException | None = None
|
|
35
|
+
self._condition = asyncio.Condition()
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def available(self) -> int:
|
|
39
|
+
return self._available
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def maximum(self) -> int:
|
|
43
|
+
return self._maximum
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def stall_count(self) -> int:
|
|
47
|
+
return self._stall_count
|
|
48
|
+
|
|
49
|
+
async def acquire(self, amount: int) -> None:
|
|
50
|
+
_positive(amount, "amount")
|
|
51
|
+
if amount > self._maximum:
|
|
52
|
+
raise ValueError("amount exceeds maximum credit")
|
|
53
|
+
async with self._condition:
|
|
54
|
+
if self._closed_error is None and self._available < amount:
|
|
55
|
+
self._stall_count += 1
|
|
56
|
+
if self._on_stall is not None:
|
|
57
|
+
with suppress(Exception):
|
|
58
|
+
self._on_stall()
|
|
59
|
+
await self._condition.wait_for(lambda: self._closed_error is not None or self._available >= amount)
|
|
60
|
+
if self._closed_error is not None:
|
|
61
|
+
raise self._closed_error
|
|
62
|
+
self._available -= amount
|
|
63
|
+
|
|
64
|
+
async def grant(self, amount: int) -> None:
|
|
65
|
+
_positive(amount, "amount")
|
|
66
|
+
async with self._condition:
|
|
67
|
+
if self._closed_error is not None:
|
|
68
|
+
return
|
|
69
|
+
if self._available + amount > self._maximum:
|
|
70
|
+
raise ValueError("credit grant exceeds negotiated maximum")
|
|
71
|
+
self._available += amount
|
|
72
|
+
self._condition.notify_all()
|
|
73
|
+
|
|
74
|
+
async def close(self, error: BaseException) -> None:
|
|
75
|
+
async with self._condition:
|
|
76
|
+
if self._closed_error is None:
|
|
77
|
+
self._closed_error = error
|
|
78
|
+
self._condition.notify_all()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _ReceiveBuffer:
|
|
82
|
+
def __init__(self) -> None:
|
|
83
|
+
self._buffer = bytearray()
|
|
84
|
+
self._eof = False
|
|
85
|
+
self._error: BaseException | None = None
|
|
86
|
+
self._condition = asyncio.Condition()
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def buffered_bytes(self) -> int:
|
|
90
|
+
return len(self._buffer)
|
|
91
|
+
|
|
92
|
+
async def feed_data(self, data: bytes) -> None:
|
|
93
|
+
async with self._condition:
|
|
94
|
+
if self._eof or self._error is not None:
|
|
95
|
+
return
|
|
96
|
+
self._buffer.extend(data)
|
|
97
|
+
self._condition.notify_all()
|
|
98
|
+
|
|
99
|
+
async def feed_eof(self) -> None:
|
|
100
|
+
async with self._condition:
|
|
101
|
+
self._eof = True
|
|
102
|
+
self._condition.notify_all()
|
|
103
|
+
|
|
104
|
+
async def fail(self, error: BaseException) -> None:
|
|
105
|
+
async with self._condition:
|
|
106
|
+
self._buffer.clear()
|
|
107
|
+
self._error = error
|
|
108
|
+
self._condition.notify_all()
|
|
109
|
+
|
|
110
|
+
async def read(self, max_bytes: int) -> bytes | None:
|
|
111
|
+
_positive(max_bytes, "max_bytes")
|
|
112
|
+
async with self._condition:
|
|
113
|
+
await self._condition.wait_for(lambda: bool(self._buffer) or self._eof or self._error is not None)
|
|
114
|
+
if self._error is not None:
|
|
115
|
+
raise self._error
|
|
116
|
+
if self._buffer:
|
|
117
|
+
size = min(max_bytes, len(self._buffer))
|
|
118
|
+
data = bytes(self._buffer[:size])
|
|
119
|
+
del self._buffer[:size]
|
|
120
|
+
return data
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class MuxRpcStream:
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
owner: MuxStreamOwner,
|
|
128
|
+
stream_id: int,
|
|
129
|
+
sequences: SequenceTracker,
|
|
130
|
+
*,
|
|
131
|
+
send_credit: int,
|
|
132
|
+
receive_credit: int,
|
|
133
|
+
open_initiator: bool,
|
|
134
|
+
result_expected: bool = True,
|
|
135
|
+
accepted: bool = True,
|
|
136
|
+
on_send_stall: Callable[[], None] | None = None,
|
|
137
|
+
) -> None:
|
|
138
|
+
self._owner = owner
|
|
139
|
+
self.stream_id = stream_id
|
|
140
|
+
self._sequences = sequences
|
|
141
|
+
self._send_window = AsyncByteCreditWindow(send_credit, on_stall=on_send_stall)
|
|
142
|
+
self._receive_window = ByteCreditWindow(
|
|
143
|
+
initial_credit=receive_credit,
|
|
144
|
+
maximum_credit=receive_credit,
|
|
145
|
+
)
|
|
146
|
+
self._receive_buffer = _ReceiveBuffer()
|
|
147
|
+
self._result: asyncio.Future[dict[str, object]] | None = (
|
|
148
|
+
asyncio.get_running_loop().create_future() if result_expected else None
|
|
149
|
+
)
|
|
150
|
+
self._lifecycle = StreamLifecycle(open_initiator=open_initiator)
|
|
151
|
+
if accepted:
|
|
152
|
+
self._lifecycle.mark_open()
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def buffered_bytes(self) -> int:
|
|
156
|
+
return self._receive_buffer.buffered_bytes
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def closed(self) -> bool:
|
|
160
|
+
return self._lifecycle.terminal
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def state(self) -> StreamState:
|
|
164
|
+
return self._lifecycle.state
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def local_eof(self) -> bool:
|
|
168
|
+
return self._lifecycle.local_eof
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def accepted(self) -> bool:
|
|
172
|
+
return self._lifecycle.opened
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def remote_eof(self) -> bool:
|
|
176
|
+
return self._lifecycle.remote_eof
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def close_sent(self) -> bool:
|
|
180
|
+
return self._lifecycle.close_sent
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def close_received(self) -> bool:
|
|
184
|
+
return self._lifecycle.close_received
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def receive_credit(self) -> int:
|
|
188
|
+
return self._receive_window.available
|
|
189
|
+
|
|
190
|
+
async def accept(self) -> None:
|
|
191
|
+
if self.accepted:
|
|
192
|
+
return
|
|
193
|
+
if self.closed:
|
|
194
|
+
raise RuntimeError("stream is closed")
|
|
195
|
+
self._lifecycle.mark_open()
|
|
196
|
+
await self._owner._stream_accept(self)
|
|
197
|
+
|
|
198
|
+
async def send(self, data: bytes) -> None:
|
|
199
|
+
if not isinstance(data, bytes):
|
|
200
|
+
raise TypeError("stream data must be bytes")
|
|
201
|
+
if not data:
|
|
202
|
+
return
|
|
203
|
+
if self.closed:
|
|
204
|
+
raise RuntimeError("stream is closed")
|
|
205
|
+
if not self.accepted:
|
|
206
|
+
raise RuntimeError("stream is not accepted")
|
|
207
|
+
self._lifecycle.validate_send_data()
|
|
208
|
+
await self._owner._stream_send_data(self, data)
|
|
209
|
+
|
|
210
|
+
async def send_eof(self) -> None:
|
|
211
|
+
if self.closed or self.local_eof:
|
|
212
|
+
return
|
|
213
|
+
if not self.accepted:
|
|
214
|
+
raise RuntimeError("stream is not accepted")
|
|
215
|
+
self._lifecycle.send_eof()
|
|
216
|
+
await self._owner._stream_send_eof(self)
|
|
217
|
+
|
|
218
|
+
async def receive(self, max_bytes: int = 64 * 1024) -> bytes | None:
|
|
219
|
+
if not self.accepted:
|
|
220
|
+
raise RuntimeError("stream is not accepted")
|
|
221
|
+
data = await self._receive_buffer.read(max_bytes)
|
|
222
|
+
if data is not None:
|
|
223
|
+
await self._owner._stream_release_credit(self, len(data))
|
|
224
|
+
return data
|
|
225
|
+
|
|
226
|
+
async def finish(self) -> dict[str, object]:
|
|
227
|
+
if self._result is None:
|
|
228
|
+
raise RuntimeError("server-owned streams do not receive a terminal result")
|
|
229
|
+
return await self._result
|
|
230
|
+
|
|
231
|
+
async def reset(self, message: str = "stream reset") -> None:
|
|
232
|
+
if self.closed:
|
|
233
|
+
return
|
|
234
|
+
await self._owner._stream_reset(self, message)
|
|
235
|
+
|
|
236
|
+
async def _accept_data(self, data: bytes) -> None:
|
|
237
|
+
self._lifecycle.validate_receive_data()
|
|
238
|
+
self._receive_window.consume(len(data))
|
|
239
|
+
await self._receive_buffer.feed_data(data)
|
|
240
|
+
|
|
241
|
+
async def _accept_eof(self) -> None:
|
|
242
|
+
self._lifecycle.receive_eof()
|
|
243
|
+
await self._receive_buffer.feed_eof()
|
|
244
|
+
|
|
245
|
+
def _send_close(self) -> None:
|
|
246
|
+
self._lifecycle.send_close()
|
|
247
|
+
|
|
248
|
+
def _receive_close(self) -> None:
|
|
249
|
+
self._lifecycle.receive_close()
|
|
250
|
+
|
|
251
|
+
async def _release_receive_credit(self, amount: int) -> None:
|
|
252
|
+
self._receive_window.grant(amount)
|
|
253
|
+
|
|
254
|
+
async def _grant_send_credit(self, amount: int) -> None:
|
|
255
|
+
await self._send_window.grant(amount)
|
|
256
|
+
|
|
257
|
+
async def _fail(self, error: BaseException) -> None:
|
|
258
|
+
if self.closed:
|
|
259
|
+
return
|
|
260
|
+
self._lifecycle.send_reset()
|
|
261
|
+
self._receive_window.close()
|
|
262
|
+
await self._send_window.close(error)
|
|
263
|
+
await self._receive_buffer.fail(error)
|
|
264
|
+
if self._result is not None and not self._result.done():
|
|
265
|
+
self._result.set_exception(error)
|
|
266
|
+
self._result.exception()
|
|
267
|
+
|
|
268
|
+
async def _finish(self, result: dict[str, object]) -> None:
|
|
269
|
+
if self.closed:
|
|
270
|
+
return
|
|
271
|
+
if self._lifecycle.open_initiator:
|
|
272
|
+
self._lifecycle.receive_close_ack()
|
|
273
|
+
else:
|
|
274
|
+
self._lifecycle.send_close_ack()
|
|
275
|
+
self._receive_window.close()
|
|
276
|
+
await self._send_window.close(RuntimeError("stream is closed"))
|
|
277
|
+
if self._result is not None and not self._result.done():
|
|
278
|
+
self._result.set_result(result)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _positive(value: int, name: str) -> None:
|
|
282
|
+
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
|
283
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Thread-safe synchronous facade for the asyncio multiplexed RPC client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Coroutine, Mapping
|
|
8
|
+
from concurrent.futures import TimeoutError as FutureTimeoutError
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
from typing import Any, TypeVar
|
|
12
|
+
|
|
13
|
+
from .mux_rpc import MuxConnectionClosed, MuxRpcClient
|
|
14
|
+
from .mux_stream import MuxRpcStream
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MuxSyncStream:
|
|
20
|
+
def __init__(self, owner: MuxSyncClient, stream: MuxRpcStream) -> None:
|
|
21
|
+
self._owner = owner
|
|
22
|
+
self._stream = stream
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def stream_id(self) -> int:
|
|
26
|
+
return self._stream.stream_id
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def closed(self) -> bool:
|
|
30
|
+
return self._stream.closed
|
|
31
|
+
|
|
32
|
+
def send(self, data: bytes, *, timeout: float | None = None) -> None:
|
|
33
|
+
self._owner._submit(self._stream.send(data), timeout=timeout)
|
|
34
|
+
|
|
35
|
+
def send_eof(self, *, timeout: float | None = None) -> None:
|
|
36
|
+
self._owner._submit(self._stream.send_eof(), timeout=timeout)
|
|
37
|
+
|
|
38
|
+
def receive(self, max_bytes: int = 64 * 1024, *, timeout: float | None = None) -> bytes | None:
|
|
39
|
+
return self._owner._submit(self._stream.receive(max_bytes), timeout=timeout)
|
|
40
|
+
|
|
41
|
+
def receive_unbounded(self, max_bytes: int = 64 * 1024) -> bytes | None:
|
|
42
|
+
return self._owner._submit_unbounded(self._stream.receive(max_bytes))
|
|
43
|
+
|
|
44
|
+
def finish(self, *, timeout: float | None = None) -> dict[str, object]:
|
|
45
|
+
return self._owner._submit(self._stream.finish(), timeout=timeout)
|
|
46
|
+
|
|
47
|
+
def reset(self, message: str = "stream reset", *, timeout: float | None = None) -> None:
|
|
48
|
+
self._owner._submit(self._stream.reset(message), timeout=timeout)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class MuxSyncClient:
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
socket_path: Path | str,
|
|
55
|
+
*,
|
|
56
|
+
default_timeout: float = 30.0,
|
|
57
|
+
max_pending: int = 256,
|
|
58
|
+
) -> None:
|
|
59
|
+
if default_timeout <= 0:
|
|
60
|
+
raise ValueError("default_timeout must be positive")
|
|
61
|
+
if not isinstance(max_pending, int) or isinstance(max_pending, bool) or max_pending <= 0:
|
|
62
|
+
raise ValueError("max_pending must be a positive integer")
|
|
63
|
+
self.socket_path = Path(socket_path)
|
|
64
|
+
self.default_timeout = float(default_timeout)
|
|
65
|
+
self._max_pending = max_pending
|
|
66
|
+
self._loop = asyncio.new_event_loop()
|
|
67
|
+
self._client: MuxRpcClient | None = None
|
|
68
|
+
self._connect_lock: asyncio.Lock | None = None
|
|
69
|
+
self._state_lock = threading.Lock()
|
|
70
|
+
self._started = threading.Event()
|
|
71
|
+
self._closed = False
|
|
72
|
+
self._thread = threading.Thread(target=self._run_loop, name="hostbridge-mux-client", daemon=True)
|
|
73
|
+
self._thread.start()
|
|
74
|
+
self._started.wait()
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def closed(self) -> bool:
|
|
78
|
+
with self._state_lock:
|
|
79
|
+
return self._closed
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def worker_thread_alive(self) -> bool:
|
|
83
|
+
return self._thread.is_alive()
|
|
84
|
+
|
|
85
|
+
def request(
|
|
86
|
+
self,
|
|
87
|
+
method: str,
|
|
88
|
+
params: Mapping[str, object],
|
|
89
|
+
*,
|
|
90
|
+
timeout: float | None = None,
|
|
91
|
+
) -> dict[str, object]:
|
|
92
|
+
return self._submit(self._request(method, params), timeout=timeout)
|
|
93
|
+
|
|
94
|
+
def open_stream(
|
|
95
|
+
self,
|
|
96
|
+
method: str,
|
|
97
|
+
params: Mapping[str, object],
|
|
98
|
+
*,
|
|
99
|
+
timeout: float | None = None,
|
|
100
|
+
receive_window: int | None = None,
|
|
101
|
+
) -> MuxSyncStream:
|
|
102
|
+
stream = self._submit(
|
|
103
|
+
self._open_stream(method, params, receive_window=receive_window),
|
|
104
|
+
timeout=timeout,
|
|
105
|
+
)
|
|
106
|
+
return MuxSyncStream(self, stream)
|
|
107
|
+
|
|
108
|
+
def close(self) -> None:
|
|
109
|
+
with self._state_lock:
|
|
110
|
+
if self._closed:
|
|
111
|
+
return
|
|
112
|
+
self._closed = True
|
|
113
|
+
if self._thread.is_alive():
|
|
114
|
+
future = asyncio.run_coroutine_threadsafe(self._close_client(), self._loop)
|
|
115
|
+
try:
|
|
116
|
+
future.result(timeout=self.default_timeout)
|
|
117
|
+
finally:
|
|
118
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
119
|
+
if threading.get_ident() != self._thread.ident:
|
|
120
|
+
self._thread.join(timeout=self.default_timeout)
|
|
121
|
+
|
|
122
|
+
def __enter__(self) -> MuxSyncClient:
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
def __exit__(
|
|
126
|
+
self,
|
|
127
|
+
exc_type: type[BaseException] | None,
|
|
128
|
+
exc: BaseException | None,
|
|
129
|
+
traceback: TracebackType | None,
|
|
130
|
+
) -> None:
|
|
131
|
+
self.close()
|
|
132
|
+
|
|
133
|
+
async def _request(self, method: str, params: Mapping[str, object]) -> dict[str, object]:
|
|
134
|
+
client = await self._ensure_client()
|
|
135
|
+
try:
|
|
136
|
+
return await client.request(method, params)
|
|
137
|
+
except MuxConnectionClosed:
|
|
138
|
+
await self._discard_client(client)
|
|
139
|
+
raise
|
|
140
|
+
|
|
141
|
+
async def _open_stream(
|
|
142
|
+
self,
|
|
143
|
+
method: str,
|
|
144
|
+
params: Mapping[str, object],
|
|
145
|
+
*,
|
|
146
|
+
receive_window: int | None,
|
|
147
|
+
) -> MuxRpcStream:
|
|
148
|
+
client = await self._ensure_client()
|
|
149
|
+
try:
|
|
150
|
+
return await client.open_stream(method, params, receive_window=receive_window)
|
|
151
|
+
except MuxConnectionClosed:
|
|
152
|
+
await self._discard_client(client)
|
|
153
|
+
raise
|
|
154
|
+
|
|
155
|
+
async def _ensure_client(self) -> MuxRpcClient:
|
|
156
|
+
current = self._client
|
|
157
|
+
if current is not None and not current.closed:
|
|
158
|
+
return current
|
|
159
|
+
if self._connect_lock is None:
|
|
160
|
+
self._connect_lock = asyncio.Lock()
|
|
161
|
+
async with self._connect_lock:
|
|
162
|
+
current = self._client
|
|
163
|
+
if current is not None and not current.closed:
|
|
164
|
+
return current
|
|
165
|
+
replacement = await MuxRpcClient.connect_unix(self.socket_path, max_pending=self._max_pending)
|
|
166
|
+
self._client = replacement
|
|
167
|
+
if current is not None:
|
|
168
|
+
await current.close()
|
|
169
|
+
return replacement
|
|
170
|
+
|
|
171
|
+
async def _discard_client(self, client: MuxRpcClient) -> None:
|
|
172
|
+
if self._connect_lock is None:
|
|
173
|
+
self._connect_lock = asyncio.Lock()
|
|
174
|
+
async with self._connect_lock:
|
|
175
|
+
if self._client is client:
|
|
176
|
+
self._client = None
|
|
177
|
+
await client.close()
|
|
178
|
+
|
|
179
|
+
async def _close_client(self) -> None:
|
|
180
|
+
if self._client is not None:
|
|
181
|
+
await self._client.close()
|
|
182
|
+
self._client = None
|
|
183
|
+
|
|
184
|
+
def _submit(self, coroutine: Coroutine[Any, Any, T], *, timeout: float | None) -> T:
|
|
185
|
+
effective_timeout = self.default_timeout if timeout is None else timeout
|
|
186
|
+
if effective_timeout <= 0:
|
|
187
|
+
coroutine.close()
|
|
188
|
+
raise ValueError("timeout must be positive")
|
|
189
|
+
with self._state_lock:
|
|
190
|
+
if self._closed:
|
|
191
|
+
coroutine.close()
|
|
192
|
+
raise RuntimeError("multiplexed client is closed")
|
|
193
|
+
future = asyncio.run_coroutine_threadsafe(coroutine, self._loop)
|
|
194
|
+
try:
|
|
195
|
+
return future.result(timeout=effective_timeout)
|
|
196
|
+
except FutureTimeoutError as exc:
|
|
197
|
+
future.cancel()
|
|
198
|
+
raise TimeoutError("multiplexed client operation timed out") from exc
|
|
199
|
+
|
|
200
|
+
def _submit_unbounded(self, coroutine: Coroutine[Any, Any, T]) -> T:
|
|
201
|
+
with self._state_lock:
|
|
202
|
+
if self._closed:
|
|
203
|
+
coroutine.close()
|
|
204
|
+
raise RuntimeError("multiplexed client is closed")
|
|
205
|
+
future = asyncio.run_coroutine_threadsafe(coroutine, self._loop)
|
|
206
|
+
try:
|
|
207
|
+
return future.result()
|
|
208
|
+
except BaseException:
|
|
209
|
+
future.cancel()
|
|
210
|
+
raise
|
|
211
|
+
|
|
212
|
+
def _run_loop(self) -> None:
|
|
213
|
+
asyncio.set_event_loop(self._loop)
|
|
214
|
+
self._started.set()
|
|
215
|
+
try:
|
|
216
|
+
self._loop.run_forever()
|
|
217
|
+
finally:
|
|
218
|
+
pending = asyncio.all_tasks(self._loop)
|
|
219
|
+
for task in pending:
|
|
220
|
+
task.cancel()
|
|
221
|
+
if pending:
|
|
222
|
+
self._loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
|
223
|
+
self._loop.run_until_complete(self._loop.shutdown_asyncgens())
|
|
224
|
+
self._loop.close()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Build the deterministic, self-contained remote tunnel agent zipapp."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import zipfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_PACKAGE_MODULES = (
|
|
10
|
+
"async_lifecycle.py",
|
|
11
|
+
"mux_connection.py",
|
|
12
|
+
"mux_protocol.py",
|
|
13
|
+
"mux_records.py",
|
|
14
|
+
"mux_rpc.py",
|
|
15
|
+
"mux_stream.py",
|
|
16
|
+
"remote_mux_agent.py",
|
|
17
|
+
)
|
|
18
|
+
_ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0)
|
|
19
|
+
_PACKAGE_INIT = b'"""Self-contained HostBridge remote tunnel agent."""\n'
|
|
20
|
+
_ENTRYPOINT = b"""from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
from server_control_mcp.remote_mux_agent import run_stdio_agent
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
exit_code = asyncio.run(run_stdio_agent())
|
|
30
|
+
except Exception as exc:
|
|
31
|
+
nonce = sys.argv[1].encode("ascii")
|
|
32
|
+
error = type(exc).__name__.encode("ascii", "replace")[:64]
|
|
33
|
+
os.write(1, b"\\0HBFAIL2:" + nonce + b":" + error + b"\\0")
|
|
34
|
+
raise
|
|
35
|
+
raise SystemExit(exit_code)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_remote_agent_bundle() -> bytes:
|
|
40
|
+
package_dir = Path(__file__).resolve().parent
|
|
41
|
+
files: list[tuple[str, bytes]] = [
|
|
42
|
+
("__main__.py", _ENTRYPOINT),
|
|
43
|
+
("server_control_mcp/__init__.py", _PACKAGE_INIT),
|
|
44
|
+
]
|
|
45
|
+
for name in _PACKAGE_MODULES:
|
|
46
|
+
files.append((f"server_control_mcp/{name}", (package_dir / name).read_bytes()))
|
|
47
|
+
|
|
48
|
+
output = io.BytesIO()
|
|
49
|
+
with zipfile.ZipFile(output, mode="w", compression=zipfile.ZIP_STORED, strict_timestamps=True) as archive:
|
|
50
|
+
for path, content in files:
|
|
51
|
+
info = zipfile.ZipInfo(path, date_time=_ZIP_TIMESTAMP)
|
|
52
|
+
info.compress_type = zipfile.ZIP_STORED
|
|
53
|
+
info.create_system = 3
|
|
54
|
+
info.external_attr = 0o100644 << 16
|
|
55
|
+
archive.writestr(info, content)
|
|
56
|
+
return output.getvalue()
|