@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,326 @@
|
|
|
1
|
+
"""Lifecycle and bounded resource primitives for multiplexed connections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum, StrEnum
|
|
9
|
+
|
|
10
|
+
from .mux_protocol import FrameType, MuxProtocolError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MuxStateError(RuntimeError):
|
|
14
|
+
"""Raised when an operation is invalid for the current lifecycle state."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StreamState(StrEnum):
|
|
18
|
+
OPENING = "OPENING"
|
|
19
|
+
OPEN = "OPEN"
|
|
20
|
+
LOCAL_EOF = "LOCAL_EOF"
|
|
21
|
+
REMOTE_EOF = "REMOTE_EOF"
|
|
22
|
+
DRAINING = "DRAINING"
|
|
23
|
+
CLOSING = "CLOSING"
|
|
24
|
+
CLOSED = "CLOSED"
|
|
25
|
+
RESET = "RESET"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TerminalKind(StrEnum):
|
|
29
|
+
CLOSED = "CLOSED"
|
|
30
|
+
RESET = "RESET"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ConnectionState(StrEnum):
|
|
34
|
+
DISCONNECTED = "DISCONNECTED"
|
|
35
|
+
CONNECTING = "CONNECTING"
|
|
36
|
+
HANDSHAKING = "HANDSHAKING"
|
|
37
|
+
READY = "READY"
|
|
38
|
+
DRAINING = "DRAINING"
|
|
39
|
+
CLOSED = "CLOSED"
|
|
40
|
+
FAILED = "FAILED"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FrameDisposition(Enum):
|
|
44
|
+
UNKNOWN_STREAM = "unknown_stream"
|
|
45
|
+
DISCARD_LATE_DATA = "discard_late_data"
|
|
46
|
+
IGNORE_TERMINAL = "ignore_terminal"
|
|
47
|
+
PROTOCOL_ERROR = "protocol_error"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ByteCreditWindow:
|
|
51
|
+
def __init__(self, *, initial_credit: int, maximum_credit: int) -> None:
|
|
52
|
+
_positive_or_zero(initial_credit, "initial_credit")
|
|
53
|
+
_positive(maximum_credit, "maximum_credit")
|
|
54
|
+
if initial_credit > maximum_credit:
|
|
55
|
+
raise ValueError("initial credit exceeds maximum credit")
|
|
56
|
+
self._available = initial_credit
|
|
57
|
+
self._maximum = maximum_credit
|
|
58
|
+
self._closed = False
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def available(self) -> int:
|
|
62
|
+
return self._available
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def closed(self) -> bool:
|
|
66
|
+
return self._closed
|
|
67
|
+
|
|
68
|
+
def consume(self, amount: int) -> None:
|
|
69
|
+
_positive(amount, "amount")
|
|
70
|
+
if self._closed:
|
|
71
|
+
raise MuxStateError("credit window is closed")
|
|
72
|
+
if amount > self._available:
|
|
73
|
+
raise MuxStateError("insufficient byte credit")
|
|
74
|
+
self._available -= amount
|
|
75
|
+
|
|
76
|
+
def grant(self, amount: int) -> None:
|
|
77
|
+
_positive(amount, "amount")
|
|
78
|
+
if self._closed:
|
|
79
|
+
return
|
|
80
|
+
if self._available + amount > self._maximum:
|
|
81
|
+
raise MuxProtocolError("credit grant exceeds negotiated maximum")
|
|
82
|
+
self._available += amount
|
|
83
|
+
|
|
84
|
+
def close(self) -> None:
|
|
85
|
+
self._closed = True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class StreamLifecycle:
|
|
89
|
+
def __init__(self, *, open_initiator: bool) -> None:
|
|
90
|
+
if not isinstance(open_initiator, bool):
|
|
91
|
+
raise TypeError("open_initiator must be a boolean")
|
|
92
|
+
self.open_initiator = open_initiator
|
|
93
|
+
self.state = StreamState.OPENING
|
|
94
|
+
self.terminal_kind: TerminalKind | None = None
|
|
95
|
+
self._opened = False
|
|
96
|
+
self._local_eof = False
|
|
97
|
+
self._remote_eof = False
|
|
98
|
+
self._close_sent = False
|
|
99
|
+
self._close_received = False
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def opened(self) -> bool:
|
|
103
|
+
return self._opened
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def local_eof(self) -> bool:
|
|
107
|
+
return self._local_eof
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def remote_eof(self) -> bool:
|
|
111
|
+
return self._remote_eof
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def close_sent(self) -> bool:
|
|
115
|
+
return self._close_sent
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def close_received(self) -> bool:
|
|
119
|
+
return self._close_received
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def terminal(self) -> bool:
|
|
123
|
+
return self.state in {StreamState.CLOSED, StreamState.RESET}
|
|
124
|
+
|
|
125
|
+
def mark_open(self) -> None:
|
|
126
|
+
self._require(StreamState.OPENING)
|
|
127
|
+
self._opened = True
|
|
128
|
+
self.state = StreamState.OPEN
|
|
129
|
+
|
|
130
|
+
def send_eof(self) -> None:
|
|
131
|
+
if self._local_eof:
|
|
132
|
+
return
|
|
133
|
+
self._require_active("send EOF")
|
|
134
|
+
self._local_eof = True
|
|
135
|
+
self._update_eof_state()
|
|
136
|
+
|
|
137
|
+
def receive_eof(self) -> None:
|
|
138
|
+
if self._remote_eof:
|
|
139
|
+
return
|
|
140
|
+
self._require_active("receive EOF")
|
|
141
|
+
self._remote_eof = True
|
|
142
|
+
self._update_eof_state()
|
|
143
|
+
|
|
144
|
+
def validate_send_data(self) -> None:
|
|
145
|
+
if self.state not in {StreamState.OPEN, StreamState.REMOTE_EOF}:
|
|
146
|
+
raise MuxStateError(f"cannot send DATA while stream is {self.state.value}")
|
|
147
|
+
|
|
148
|
+
def validate_receive_data(self) -> None:
|
|
149
|
+
if self.state not in {StreamState.OPEN, StreamState.LOCAL_EOF}:
|
|
150
|
+
raise MuxStateError(f"cannot receive DATA while stream is {self.state.value}")
|
|
151
|
+
|
|
152
|
+
def send_close(self) -> None:
|
|
153
|
+
if self._close_sent:
|
|
154
|
+
return
|
|
155
|
+
if not self.open_initiator:
|
|
156
|
+
raise MuxStateError("only the OPEN initiator may send CLOSE")
|
|
157
|
+
self._require(StreamState.DRAINING)
|
|
158
|
+
self._close_sent = True
|
|
159
|
+
self.state = StreamState.CLOSING
|
|
160
|
+
|
|
161
|
+
def receive_close(self) -> None:
|
|
162
|
+
if self._close_received:
|
|
163
|
+
return
|
|
164
|
+
if self.open_initiator:
|
|
165
|
+
raise MuxStateError("OPEN initiator cannot receive CLOSE")
|
|
166
|
+
self._require(StreamState.DRAINING)
|
|
167
|
+
self._close_received = True
|
|
168
|
+
self.state = StreamState.CLOSING
|
|
169
|
+
|
|
170
|
+
def send_close_ack(self) -> None:
|
|
171
|
+
if self.state is StreamState.CLOSED:
|
|
172
|
+
return
|
|
173
|
+
if self.open_initiator or not self._close_received:
|
|
174
|
+
raise MuxStateError("CLOSE_ACK requires a received CLOSE on the acceptor")
|
|
175
|
+
self._finish_closed()
|
|
176
|
+
|
|
177
|
+
def receive_close_ack(self) -> None:
|
|
178
|
+
if self.state is StreamState.CLOSED:
|
|
179
|
+
return
|
|
180
|
+
if not self.open_initiator or not self._close_sent:
|
|
181
|
+
raise MuxStateError("CLOSE_ACK requires a sent CLOSE on the initiator")
|
|
182
|
+
self._finish_closed()
|
|
183
|
+
|
|
184
|
+
def send_reset(self) -> None:
|
|
185
|
+
self._finish_reset()
|
|
186
|
+
|
|
187
|
+
def receive_reset(self) -> None:
|
|
188
|
+
self._finish_reset()
|
|
189
|
+
|
|
190
|
+
def _update_eof_state(self) -> None:
|
|
191
|
+
if self._local_eof and self._remote_eof:
|
|
192
|
+
self.state = StreamState.DRAINING
|
|
193
|
+
elif self._local_eof:
|
|
194
|
+
self.state = StreamState.LOCAL_EOF
|
|
195
|
+
else:
|
|
196
|
+
self.state = StreamState.REMOTE_EOF
|
|
197
|
+
|
|
198
|
+
def _require_active(self, action: str) -> None:
|
|
199
|
+
if self.state not in {StreamState.OPEN, StreamState.LOCAL_EOF, StreamState.REMOTE_EOF}:
|
|
200
|
+
raise MuxStateError(f"cannot {action} while stream is {self.state.value}")
|
|
201
|
+
|
|
202
|
+
def _require(self, state: StreamState) -> None:
|
|
203
|
+
if self.state is not state:
|
|
204
|
+
raise MuxStateError(f"stream state must be {state.value}, found {self.state.value}")
|
|
205
|
+
|
|
206
|
+
def _finish_closed(self) -> None:
|
|
207
|
+
self.state = StreamState.CLOSED
|
|
208
|
+
self.terminal_kind = TerminalKind.CLOSED
|
|
209
|
+
|
|
210
|
+
def _finish_reset(self) -> None:
|
|
211
|
+
if self.state in {StreamState.CLOSED, StreamState.RESET}:
|
|
212
|
+
return
|
|
213
|
+
self.state = StreamState.RESET
|
|
214
|
+
self.terminal_kind = TerminalKind.RESET
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class ConnectionLifecycle:
|
|
218
|
+
def __init__(self) -> None:
|
|
219
|
+
self.state = ConnectionState.DISCONNECTED
|
|
220
|
+
|
|
221
|
+
def start_connecting(self) -> None:
|
|
222
|
+
self._require_any(ConnectionState.DISCONNECTED, ConnectionState.CLOSED, ConnectionState.FAILED)
|
|
223
|
+
self.state = ConnectionState.CONNECTING
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def terminal(self) -> bool:
|
|
227
|
+
return self.state in {ConnectionState.CLOSED, ConnectionState.FAILED}
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def draining(self) -> bool:
|
|
231
|
+
return self.state is ConnectionState.DRAINING
|
|
232
|
+
|
|
233
|
+
def start_handshake(self) -> None:
|
|
234
|
+
self._require(ConnectionState.CONNECTING)
|
|
235
|
+
self.state = ConnectionState.HANDSHAKING
|
|
236
|
+
|
|
237
|
+
def mark_ready(self) -> None:
|
|
238
|
+
self._require(ConnectionState.HANDSHAKING)
|
|
239
|
+
self.state = ConnectionState.READY
|
|
240
|
+
|
|
241
|
+
def start_draining(self) -> None:
|
|
242
|
+
if self.state is ConnectionState.DRAINING:
|
|
243
|
+
return
|
|
244
|
+
self._require(ConnectionState.READY)
|
|
245
|
+
self.state = ConnectionState.DRAINING
|
|
246
|
+
|
|
247
|
+
def mark_closed(self) -> None:
|
|
248
|
+
if self.state is ConnectionState.CLOSED:
|
|
249
|
+
return
|
|
250
|
+
self._require_any(ConnectionState.CONNECTING, ConnectionState.HANDSHAKING, ConnectionState.DRAINING)
|
|
251
|
+
self.state = ConnectionState.CLOSED
|
|
252
|
+
|
|
253
|
+
def mark_failed(self) -> None:
|
|
254
|
+
if self.state is ConnectionState.FAILED:
|
|
255
|
+
return
|
|
256
|
+
if self.state is ConnectionState.CLOSED:
|
|
257
|
+
raise MuxStateError("cannot fail connection while CLOSED")
|
|
258
|
+
self.state = ConnectionState.FAILED
|
|
259
|
+
|
|
260
|
+
def _require(self, state: ConnectionState) -> None:
|
|
261
|
+
if self.state is not state:
|
|
262
|
+
raise MuxStateError(f"connection state must be {state.value}, found {self.state.value}")
|
|
263
|
+
|
|
264
|
+
def _require_any(self, *states: ConnectionState) -> None:
|
|
265
|
+
if self.state not in states:
|
|
266
|
+
allowed = ", ".join(state.value for state in states)
|
|
267
|
+
raise MuxStateError(f"connection state must be one of {allowed}, found {self.state.value}")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@dataclass(frozen=True, slots=True)
|
|
271
|
+
class _Tombstone:
|
|
272
|
+
terminal_kind: TerminalKind
|
|
273
|
+
expires_at: float
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class StreamTombstones:
|
|
277
|
+
def __init__(
|
|
278
|
+
self,
|
|
279
|
+
*,
|
|
280
|
+
retention_seconds: float,
|
|
281
|
+
max_entries: int = 4096,
|
|
282
|
+
clock: Callable[[], float] = time.monotonic,
|
|
283
|
+
) -> None:
|
|
284
|
+
if retention_seconds <= 0:
|
|
285
|
+
raise ValueError("retention_seconds must be positive")
|
|
286
|
+
_positive(max_entries, "max_entries")
|
|
287
|
+
self._retention_seconds = float(retention_seconds)
|
|
288
|
+
self._max_entries = max_entries
|
|
289
|
+
self._clock = clock
|
|
290
|
+
self._entries: dict[int, _Tombstone] = {}
|
|
291
|
+
|
|
292
|
+
def add(self, stream_id: int, terminal_kind: TerminalKind) -> None:
|
|
293
|
+
if not isinstance(stream_id, int) or isinstance(stream_id, bool) or stream_id <= 0:
|
|
294
|
+
raise ValueError("stream_id must be a positive integer")
|
|
295
|
+
if not isinstance(terminal_kind, TerminalKind):
|
|
296
|
+
raise TypeError("terminal_kind must be a TerminalKind")
|
|
297
|
+
self.expire()
|
|
298
|
+
if stream_id not in self._entries and len(self._entries) >= self._max_entries:
|
|
299
|
+
raise MuxStateError("tombstone capacity is exhausted")
|
|
300
|
+
self._entries[stream_id] = _Tombstone(terminal_kind, self._clock() + self._retention_seconds)
|
|
301
|
+
|
|
302
|
+
def classify(self, stream_id: int, frame_type: FrameType) -> FrameDisposition:
|
|
303
|
+
self.expire()
|
|
304
|
+
if stream_id not in self._entries:
|
|
305
|
+
return FrameDisposition.UNKNOWN_STREAM
|
|
306
|
+
if frame_type is FrameType.OPEN:
|
|
307
|
+
return FrameDisposition.PROTOCOL_ERROR
|
|
308
|
+
if frame_type is FrameType.DATA:
|
|
309
|
+
return FrameDisposition.DISCARD_LATE_DATA
|
|
310
|
+
return FrameDisposition.IGNORE_TERMINAL
|
|
311
|
+
|
|
312
|
+
def expire(self) -> None:
|
|
313
|
+
now = self._clock()
|
|
314
|
+
expired = [stream_id for stream_id, tombstone in self._entries.items() if tombstone.expires_at <= now]
|
|
315
|
+
for stream_id in expired:
|
|
316
|
+
del self._entries[stream_id]
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _positive(value: int, name: str) -> None:
|
|
320
|
+
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
|
321
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _positive_or_zero(value: int, name: str) -> None:
|
|
325
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
326
|
+
raise ValueError(f"{name} must be a non-negative integer")
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Async Unix daemon primitives for persistent multiplexed HostBridge RPC."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from .mux_rpc import (
|
|
14
|
+
DEFAULT_CONNECTION_WINDOW_BYTES,
|
|
15
|
+
DEFAULT_STREAM_WINDOW_BYTES,
|
|
16
|
+
MuxRpcRequestClosedHandler,
|
|
17
|
+
MuxRpcServerConnection,
|
|
18
|
+
)
|
|
19
|
+
from .mux_stream import MuxRpcStream
|
|
20
|
+
|
|
21
|
+
AsyncRpcHandler = Callable[[str, dict[str, object]], Awaitable[dict[str, object]]]
|
|
22
|
+
MuxDaemonStreamHandler = Callable[
|
|
23
|
+
[str, dict[str, object], MuxRpcStream, str],
|
|
24
|
+
Awaitable[dict[str, object]],
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MuxDaemonServer:
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
socket_path: Path | str,
|
|
32
|
+
handler: AsyncRpcHandler,
|
|
33
|
+
*,
|
|
34
|
+
stream_handler: MuxDaemonStreamHandler | None = None,
|
|
35
|
+
request_closed_handler: MuxRpcRequestClosedHandler | None = None,
|
|
36
|
+
max_handlers_per_connection: int = 256,
|
|
37
|
+
drain_timeout: float = 5.0,
|
|
38
|
+
) -> None:
|
|
39
|
+
_positive(max_handlers_per_connection, "max_handlers_per_connection")
|
|
40
|
+
if drain_timeout <= 0:
|
|
41
|
+
raise ValueError("drain_timeout must be positive")
|
|
42
|
+
self.socket_path = Path(socket_path)
|
|
43
|
+
self._handler = handler
|
|
44
|
+
self._stream_handler = stream_handler
|
|
45
|
+
self._request_closed_handler = request_closed_handler
|
|
46
|
+
self._max_handlers_per_connection = max_handlers_per_connection
|
|
47
|
+
self._drain_timeout = float(drain_timeout)
|
|
48
|
+
self._server: asyncio.AbstractServer | None = None
|
|
49
|
+
self._tasks: set[asyncio.Task[None]] = set()
|
|
50
|
+
self._writers: set[asyncio.StreamWriter] = set()
|
|
51
|
+
self._connections: set[MuxRpcServerConnection] = set()
|
|
52
|
+
self._closing = False
|
|
53
|
+
self._close_task: asyncio.Task[None] | None = None
|
|
54
|
+
self.accepted_connections = 0
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def active_connections(self) -> int:
|
|
58
|
+
return len(self._tasks)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def active_handlers(self) -> int:
|
|
62
|
+
return sum(connection.active_requests for connection in self._connections)
|
|
63
|
+
|
|
64
|
+
async def start(self) -> None:
|
|
65
|
+
if self._server is not None:
|
|
66
|
+
raise RuntimeError("multiplexed daemon is already started")
|
|
67
|
+
if self._closing:
|
|
68
|
+
raise RuntimeError("multiplexed daemon is closed")
|
|
69
|
+
self.socket_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
70
|
+
self._server = await asyncio.start_unix_server(self._accept, path=self.socket_path)
|
|
71
|
+
os.chmod(self.socket_path, 0o600)
|
|
72
|
+
|
|
73
|
+
async def close(self) -> None:
|
|
74
|
+
task = self._close_task
|
|
75
|
+
if task is None:
|
|
76
|
+
self._closing = True
|
|
77
|
+
task = asyncio.create_task(
|
|
78
|
+
self._close_owned_resources(),
|
|
79
|
+
name="hostbridge-mux-daemon-close",
|
|
80
|
+
)
|
|
81
|
+
self._close_task = task
|
|
82
|
+
await asyncio.shield(task)
|
|
83
|
+
|
|
84
|
+
async def _close_owned_resources(self) -> None:
|
|
85
|
+
listener = self._server
|
|
86
|
+
if self._server is not None:
|
|
87
|
+
self._server.close()
|
|
88
|
+
self._server = None
|
|
89
|
+
connections = list(self._connections)
|
|
90
|
+
if connections:
|
|
91
|
+
drain_tasks = [asyncio.create_task(connection.start_draining()) for connection in connections]
|
|
92
|
+
await _wait_tasks_bounded(drain_tasks, self._drain_timeout)
|
|
93
|
+
pending = set(self._tasks)
|
|
94
|
+
if pending:
|
|
95
|
+
_, pending = await asyncio.wait(pending, timeout=self._drain_timeout)
|
|
96
|
+
for writer in list(self._writers):
|
|
97
|
+
writer.close()
|
|
98
|
+
if self._writers:
|
|
99
|
+
waiters = [asyncio.create_task(writer.wait_closed()) for writer in list(self._writers)]
|
|
100
|
+
await _wait_tasks_bounded(waiters, self._drain_timeout)
|
|
101
|
+
for task in pending:
|
|
102
|
+
task.cancel()
|
|
103
|
+
task.add_done_callback(_consume_task_result)
|
|
104
|
+
if listener is not None:
|
|
105
|
+
await listener.wait_closed()
|
|
106
|
+
self.socket_path.unlink(missing_ok=True)
|
|
107
|
+
|
|
108
|
+
def _accept(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
109
|
+
if self._closing:
|
|
110
|
+
writer.close()
|
|
111
|
+
return
|
|
112
|
+
self.accepted_connections += 1
|
|
113
|
+
client_connection_id = uuid4().hex
|
|
114
|
+
self._writers.add(writer)
|
|
115
|
+
|
|
116
|
+
async def run_stream_handler(
|
|
117
|
+
method: str,
|
|
118
|
+
params: dict[str, object],
|
|
119
|
+
stream: MuxRpcStream,
|
|
120
|
+
) -> dict[str, object]:
|
|
121
|
+
return await self._run_stream_handler(
|
|
122
|
+
method,
|
|
123
|
+
params,
|
|
124
|
+
stream,
|
|
125
|
+
client_connection_id,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
connection = MuxRpcServerConnection(
|
|
129
|
+
reader,
|
|
130
|
+
writer,
|
|
131
|
+
self._run_handler,
|
|
132
|
+
max_handlers=self._max_handlers_per_connection,
|
|
133
|
+
stream_handler=run_stream_handler if self._stream_handler is not None else None,
|
|
134
|
+
request_closed_handler=self._request_closed_handler,
|
|
135
|
+
connection_window=DEFAULT_CONNECTION_WINDOW_BYTES,
|
|
136
|
+
stream_window=DEFAULT_STREAM_WINDOW_BYTES,
|
|
137
|
+
cleanup_timeout=self._drain_timeout,
|
|
138
|
+
)
|
|
139
|
+
self._connections.add(connection)
|
|
140
|
+
task = asyncio.create_task(
|
|
141
|
+
connection.serve(),
|
|
142
|
+
name=f"hostbridge-mux-daemon-{self.accepted_connections}",
|
|
143
|
+
)
|
|
144
|
+
self._tasks.add(task)
|
|
145
|
+
|
|
146
|
+
def finished(done: asyncio.Task[None]) -> None:
|
|
147
|
+
_consume_task_result(done)
|
|
148
|
+
self._tasks.discard(done)
|
|
149
|
+
self._writers.discard(writer)
|
|
150
|
+
self._connections.discard(connection)
|
|
151
|
+
|
|
152
|
+
task.add_done_callback(finished)
|
|
153
|
+
|
|
154
|
+
async def _run_handler(self, method: str, params: dict[str, object]) -> dict[str, object]:
|
|
155
|
+
return await self._handler(method, params)
|
|
156
|
+
|
|
157
|
+
async def _run_stream_handler(
|
|
158
|
+
self,
|
|
159
|
+
method: str,
|
|
160
|
+
params: dict[str, object],
|
|
161
|
+
stream: MuxRpcStream,
|
|
162
|
+
client_connection_id: str,
|
|
163
|
+
) -> dict[str, object]:
|
|
164
|
+
handler = self._stream_handler
|
|
165
|
+
if handler is None:
|
|
166
|
+
raise RuntimeError("multiplexed daemon stream handler is unavailable")
|
|
167
|
+
return await handler(method, params, stream, client_connection_id)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _positive(value: int, name: str) -> None:
|
|
171
|
+
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
|
172
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _consume_task_result(task: asyncio.Task[object]) -> None:
|
|
176
|
+
with suppress(BaseException):
|
|
177
|
+
task.result()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def _wait_tasks_bounded(tasks: list[asyncio.Task[Any]], timeout: float) -> None:
|
|
181
|
+
done, stalled = await asyncio.wait(tasks, timeout=timeout)
|
|
182
|
+
for task in done:
|
|
183
|
+
_consume_task_result(task)
|
|
184
|
+
for task in stalled:
|
|
185
|
+
task.cancel()
|
|
186
|
+
task.add_done_callback(_consume_task_result)
|