@neoline/hostbridge 2.0.3 → 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 +24 -15
- package/bin/hostbridge.js +80 -9
- package/docs/ARCHITECTURE.md +62 -0
- package/examples/claude_desktop_config.json +1 -1
- package/examples/codex.config.toml +1 -1
- package/examples/hosts.example.json +1 -0
- package/package.json +2 -1
- package/pyproject.toml +5 -4
- package/src/server_control_mcp/__init__.py +60 -24
- package/src/server_control_mcp/async_lifecycle.py +41 -0
- package/src/server_control_mcp/cli.py +2 -2
- package/src/server_control_mcp/client.py +345 -238
- package/src/server_control_mcp/config.py +18 -6
- package/src/server_control_mcp/daemon.py +309 -412
- package/src/server_control_mcp/doctor.py +41 -5
- package/src/server_control_mcp/hosts.py +252 -24
- package/src/server_control_mcp/mock_ssh.py +97 -960
- package/src/server_control_mcp/mock_ssh_exec.py +299 -0
- package/src/server_control_mcp/mock_ssh_session.py +69 -0
- package/src/server_control_mcp/mock_ssh_sftp.py +604 -0
- package/src/server_control_mcp/mock_ssh_tunnel.py +289 -0
- 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/policy.py +86 -14
- 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/remote_task_agent.py +102 -0
- package/src/server_control_mcp/runtime.py +3 -2
- package/src/server_control_mcp/runtime_services.py +862 -0
- package/src/server_control_mcp/secrets.py +6 -6
- package/src/server_control_mcp/secure_files.py +121 -0
- package/src/server_control_mcp/server.py +53 -20
- package/src/server_control_mcp/services.py +3 -469
- 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 +315 -84
- 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 -362
- package/src/server_control_mcp/transports/pty.py +0 -434
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""AsyncSSH direct-tcpip adaptation for persistent binary mux streams."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
from collections import deque
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from contextlib import suppress
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
import asyncssh
|
|
13
|
+
|
|
14
|
+
TUNNEL_HIGH_WATER_BYTES = 256 * 1024
|
|
15
|
+
TUNNEL_LOW_WATER_BYTES = 64 * 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MuxTunnelStream(Protocol):
|
|
19
|
+
stream_id: int
|
|
20
|
+
|
|
21
|
+
async def send(self, data: bytes) -> None: ...
|
|
22
|
+
|
|
23
|
+
async def send_eof(self) -> None: ...
|
|
24
|
+
|
|
25
|
+
async def receive(self, max_bytes: int = 64 * 1024) -> bytes | None: ...
|
|
26
|
+
|
|
27
|
+
async def finish(self) -> dict[str, object]: ...
|
|
28
|
+
|
|
29
|
+
async def reset(self, message: str = "stream reset") -> None: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MuxTunnelClient(Protocol):
|
|
33
|
+
@property
|
|
34
|
+
def closed(self) -> bool: ...
|
|
35
|
+
|
|
36
|
+
async def open_stream(self, method: str, params: dict[str, object]) -> MuxTunnelStream: ...
|
|
37
|
+
|
|
38
|
+
async def close(self) -> None: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
MuxClientFactory = Callable[[], Awaitable[MuxTunnelClient]]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TunnelConnector:
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
host_id: str,
|
|
48
|
+
client_factory: MuxClientFactory,
|
|
49
|
+
*,
|
|
50
|
+
connect_timeout: float,
|
|
51
|
+
) -> None:
|
|
52
|
+
if not isinstance(host_id, str) or not host_id:
|
|
53
|
+
raise ValueError("host_id must be a non-empty string")
|
|
54
|
+
if connect_timeout <= 0:
|
|
55
|
+
raise ValueError("connect_timeout must be positive")
|
|
56
|
+
self._host_id = host_id
|
|
57
|
+
self._client_factory = client_factory
|
|
58
|
+
self._connect_timeout = float(connect_timeout)
|
|
59
|
+
self._client: MuxTunnelClient | None = None
|
|
60
|
+
self._lock = asyncio.Lock()
|
|
61
|
+
self._closing = False
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def validate_destination(dest_host: str, dest_port: int) -> None:
|
|
65
|
+
if not isinstance(dest_host, str) or not dest_host.strip() or "\x00" in dest_host or len(dest_host) > 253:
|
|
66
|
+
raise ValueError("TCP forwarding destination host is invalid")
|
|
67
|
+
if not isinstance(dest_port, int) or isinstance(dest_port, bool) or not 1 <= dest_port <= 65535:
|
|
68
|
+
raise ValueError("TCP forwarding destination port must be between 1 and 65535")
|
|
69
|
+
|
|
70
|
+
async def start(self) -> None:
|
|
71
|
+
await self._get_client()
|
|
72
|
+
|
|
73
|
+
async def connect(self, dest_host: str, dest_port: int) -> asyncssh.SSHTCPSession[bytes]:
|
|
74
|
+
self.validate_destination(dest_host, dest_port)
|
|
75
|
+
client: MuxTunnelClient | None = None
|
|
76
|
+
try:
|
|
77
|
+
client = await self._get_client()
|
|
78
|
+
async with asyncio.timeout(self._connect_timeout):
|
|
79
|
+
stream = await client.open_stream(
|
|
80
|
+
"tcp.connect",
|
|
81
|
+
{
|
|
82
|
+
"host_id": self._host_id,
|
|
83
|
+
"destination_host": dest_host,
|
|
84
|
+
"destination_port": dest_port,
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
except asyncio.CancelledError:
|
|
88
|
+
raise
|
|
89
|
+
except (Exception, TimeoutError) as exc:
|
|
90
|
+
if client is not None and client.closed:
|
|
91
|
+
await self._discard_client(client)
|
|
92
|
+
message = str(exc) or "TCP tunnel stream open timed out"
|
|
93
|
+
raise asyncssh.ChannelOpenError(asyncssh.OPEN_CONNECT_FAILED, message) from exc
|
|
94
|
+
return DirectTCPIPSession(stream, dest_host=dest_host, dest_port=dest_port)
|
|
95
|
+
|
|
96
|
+
async def close(self) -> None:
|
|
97
|
+
async with self._lock:
|
|
98
|
+
self._closing = True
|
|
99
|
+
client = self._client
|
|
100
|
+
if client is not None:
|
|
101
|
+
await client.close()
|
|
102
|
+
if self._client is client:
|
|
103
|
+
self._client = None
|
|
104
|
+
|
|
105
|
+
async def _get_client(self) -> MuxTunnelClient:
|
|
106
|
+
async with self._lock:
|
|
107
|
+
if self._closing:
|
|
108
|
+
raise RuntimeError("TCP tunnel connector is closed")
|
|
109
|
+
if self._client is not None and not self._client.closed:
|
|
110
|
+
return self._client
|
|
111
|
+
stale = self._client
|
|
112
|
+
async with asyncio.timeout(self._connect_timeout):
|
|
113
|
+
client = await self._client_factory()
|
|
114
|
+
self._client = client
|
|
115
|
+
if stale is not None and stale is not client:
|
|
116
|
+
with suppress(Exception):
|
|
117
|
+
await stale.close()
|
|
118
|
+
return client
|
|
119
|
+
|
|
120
|
+
async def _discard_client(self, client: MuxTunnelClient) -> None:
|
|
121
|
+
async with self._lock:
|
|
122
|
+
if self._client is client:
|
|
123
|
+
self._client = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
_INPUT_EOF = object()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class DirectTCPIPSession(asyncssh.SSHTCPSession[bytes]):
|
|
130
|
+
def __init__(self, stream: MuxTunnelStream, *, dest_host: str, dest_port: int) -> None:
|
|
131
|
+
self._stream = stream
|
|
132
|
+
self._dest_host = dest_host
|
|
133
|
+
self._dest_port = dest_port
|
|
134
|
+
self._channel: asyncssh.SSHTCPChannel[bytes] | None = None
|
|
135
|
+
self._input: deque[bytes | object] = deque()
|
|
136
|
+
self._input_ready = asyncio.Event()
|
|
137
|
+
self._input_buffered_bytes = 0
|
|
138
|
+
self._input_paused = False
|
|
139
|
+
self._input_eof = False
|
|
140
|
+
self._write_ready = asyncio.Event()
|
|
141
|
+
self._write_ready.set()
|
|
142
|
+
self._runner: asyncio.Task[None] | None = None
|
|
143
|
+
self._cleanup_task: asyncio.Task[None] | None = None
|
|
144
|
+
self._finished = asyncio.Event()
|
|
145
|
+
self._aborted = False
|
|
146
|
+
self._reset_sent = False
|
|
147
|
+
|
|
148
|
+
def connection_made(self, chan: asyncssh.SSHTCPChannel[bytes]) -> None:
|
|
149
|
+
self._channel = chan
|
|
150
|
+
|
|
151
|
+
def session_started(self) -> None:
|
|
152
|
+
if self._runner is not None or self._finished.is_set():
|
|
153
|
+
return
|
|
154
|
+
self._runner = asyncio.create_task(
|
|
155
|
+
self._run(),
|
|
156
|
+
name=f"hostbridge-mock-ssh-tcp-{self._stream.stream_id}",
|
|
157
|
+
)
|
|
158
|
+
self._runner.add_done_callback(self._runner_done)
|
|
159
|
+
|
|
160
|
+
def data_received(self, data: bytes, datatype: asyncssh.DataType) -> None: # noqa: ARG002
|
|
161
|
+
if not data or self._input_eof or self._finished.is_set():
|
|
162
|
+
return
|
|
163
|
+
payload = bytes(data)
|
|
164
|
+
self._input.append(payload)
|
|
165
|
+
self._input_buffered_bytes += len(payload)
|
|
166
|
+
self._input_ready.set()
|
|
167
|
+
if (
|
|
168
|
+
self._input_buffered_bytes >= TUNNEL_HIGH_WATER_BYTES
|
|
169
|
+
and not self._input_paused
|
|
170
|
+
and self._channel is not None
|
|
171
|
+
):
|
|
172
|
+
self._channel.pause_reading()
|
|
173
|
+
self._input_paused = True
|
|
174
|
+
|
|
175
|
+
def eof_received(self) -> bool:
|
|
176
|
+
if not self._input_eof and not self._finished.is_set():
|
|
177
|
+
self._input_eof = True
|
|
178
|
+
self._input.append(_INPUT_EOF)
|
|
179
|
+
self._input_ready.set()
|
|
180
|
+
return True
|
|
181
|
+
|
|
182
|
+
def connection_lost(self, exc: Exception | None) -> None: # noqa: ARG002
|
|
183
|
+
self.abort()
|
|
184
|
+
|
|
185
|
+
def pause_writing(self) -> None:
|
|
186
|
+
self._write_ready.clear()
|
|
187
|
+
|
|
188
|
+
def resume_writing(self) -> None:
|
|
189
|
+
self._write_ready.set()
|
|
190
|
+
|
|
191
|
+
def abort(self) -> None:
|
|
192
|
+
if self._finished.is_set():
|
|
193
|
+
return
|
|
194
|
+
self._aborted = True
|
|
195
|
+
if self._runner is not None and self._runner is not asyncio.current_task() and not self._runner.done():
|
|
196
|
+
self._runner.cancel()
|
|
197
|
+
elif self._runner is None and self._cleanup_task is None:
|
|
198
|
+
self._cleanup_task = asyncio.create_task(self._finish_orphaned_session())
|
|
199
|
+
|
|
200
|
+
async def wait_finished(self) -> None:
|
|
201
|
+
await self._finished.wait()
|
|
202
|
+
|
|
203
|
+
def _runner_done(self, task: asyncio.Task[None]) -> None: # noqa: ARG002
|
|
204
|
+
if not self._finished.is_set() and self._cleanup_task is None:
|
|
205
|
+
self._cleanup_task = asyncio.create_task(self._finish_orphaned_session())
|
|
206
|
+
|
|
207
|
+
async def _finish_orphaned_session(self) -> None:
|
|
208
|
+
await self._reset_stream()
|
|
209
|
+
self._finish_channel()
|
|
210
|
+
|
|
211
|
+
async def _run(self) -> None:
|
|
212
|
+
input_task = asyncio.create_task(self._pump_input())
|
|
213
|
+
output_task = asyncio.create_task(self._pump_output())
|
|
214
|
+
clean = False
|
|
215
|
+
try:
|
|
216
|
+
await asyncio.gather(input_task, output_task)
|
|
217
|
+
await self._stream.finish()
|
|
218
|
+
clean = True
|
|
219
|
+
except asyncio.CancelledError:
|
|
220
|
+
raise
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
print(
|
|
223
|
+
f"hostbridge mock-ssh TCP forwarding to {self._dest_host}:{self._dest_port} closed: {exc}",
|
|
224
|
+
file=sys.stderr,
|
|
225
|
+
flush=True,
|
|
226
|
+
)
|
|
227
|
+
finally:
|
|
228
|
+
for task in (input_task, output_task):
|
|
229
|
+
if not task.done():
|
|
230
|
+
task.cancel()
|
|
231
|
+
await asyncio.gather(input_task, output_task, return_exceptions=True)
|
|
232
|
+
if not clean:
|
|
233
|
+
await self._reset_stream()
|
|
234
|
+
self._finish_channel()
|
|
235
|
+
|
|
236
|
+
async def _pump_input(self) -> None:
|
|
237
|
+
while True:
|
|
238
|
+
item = await self._next_input()
|
|
239
|
+
if item is _INPUT_EOF:
|
|
240
|
+
await self._stream.send_eof()
|
|
241
|
+
return
|
|
242
|
+
if not isinstance(item, bytes):
|
|
243
|
+
raise TypeError("tunnel input queue contains a non-bytes item")
|
|
244
|
+
try:
|
|
245
|
+
await self._stream.send(item)
|
|
246
|
+
finally:
|
|
247
|
+
self._input_buffered_bytes -= len(item)
|
|
248
|
+
self._resume_input_if_ready()
|
|
249
|
+
|
|
250
|
+
async def _next_input(self) -> bytes | object:
|
|
251
|
+
while not self._input:
|
|
252
|
+
self._input_ready.clear()
|
|
253
|
+
if not self._input:
|
|
254
|
+
await self._input_ready.wait()
|
|
255
|
+
item = self._input.popleft()
|
|
256
|
+
if not self._input:
|
|
257
|
+
self._input_ready.clear()
|
|
258
|
+
return item
|
|
259
|
+
|
|
260
|
+
async def _pump_output(self) -> None:
|
|
261
|
+
if self._channel is None:
|
|
262
|
+
raise RuntimeError("TCP tunnel channel was not initialized")
|
|
263
|
+
while True:
|
|
264
|
+
await self._write_ready.wait()
|
|
265
|
+
chunk = await self._stream.receive()
|
|
266
|
+
if chunk is None:
|
|
267
|
+
self._channel.write_eof()
|
|
268
|
+
return
|
|
269
|
+
self._channel.write(chunk)
|
|
270
|
+
|
|
271
|
+
async def _reset_stream(self) -> None:
|
|
272
|
+
if self._reset_sent:
|
|
273
|
+
return
|
|
274
|
+
self._reset_sent = True
|
|
275
|
+
with suppress(Exception):
|
|
276
|
+
await self._stream.reset("mock-ssh TCP channel closed")
|
|
277
|
+
|
|
278
|
+
def _resume_input_if_ready(self) -> None:
|
|
279
|
+
if self._input_paused and self._input_buffered_bytes <= TUNNEL_LOW_WATER_BYTES and self._channel is not None:
|
|
280
|
+
self._channel.resume_reading()
|
|
281
|
+
self._input_paused = False
|
|
282
|
+
|
|
283
|
+
def _finish_channel(self) -> None:
|
|
284
|
+
if self._finished.is_set():
|
|
285
|
+
return
|
|
286
|
+
self._finished.set()
|
|
287
|
+
if self._channel is not None:
|
|
288
|
+
with suppress(Exception):
|
|
289
|
+
self._channel.close()
|
|
@@ -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")
|