@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.
Files changed (48) hide show
  1. package/README.md +24 -15
  2. package/bin/hostbridge.js +80 -9
  3. package/docs/ARCHITECTURE.md +62 -0
  4. package/examples/claude_desktop_config.json +1 -1
  5. package/examples/codex.config.toml +1 -1
  6. package/examples/hosts.example.json +1 -0
  7. package/package.json +2 -1
  8. package/pyproject.toml +5 -4
  9. package/src/server_control_mcp/__init__.py +60 -24
  10. package/src/server_control_mcp/async_lifecycle.py +41 -0
  11. package/src/server_control_mcp/cli.py +2 -2
  12. package/src/server_control_mcp/client.py +345 -238
  13. package/src/server_control_mcp/config.py +18 -6
  14. package/src/server_control_mcp/daemon.py +309 -412
  15. package/src/server_control_mcp/doctor.py +41 -5
  16. package/src/server_control_mcp/hosts.py +252 -24
  17. package/src/server_control_mcp/mock_ssh.py +97 -960
  18. package/src/server_control_mcp/mock_ssh_exec.py +299 -0
  19. package/src/server_control_mcp/mock_ssh_session.py +69 -0
  20. package/src/server_control_mcp/mock_ssh_sftp.py +604 -0
  21. package/src/server_control_mcp/mock_ssh_tunnel.py +289 -0
  22. package/src/server_control_mcp/mux_connection.py +326 -0
  23. package/src/server_control_mcp/mux_daemon.py +186 -0
  24. package/src/server_control_mcp/mux_protocol.py +279 -0
  25. package/src/server_control_mcp/mux_records.py +71 -0
  26. package/src/server_control_mcp/mux_rpc.py +1779 -0
  27. package/src/server_control_mcp/mux_service.py +506 -0
  28. package/src/server_control_mcp/mux_stream.py +283 -0
  29. package/src/server_control_mcp/mux_sync_client.py +224 -0
  30. package/src/server_control_mcp/policy.py +86 -14
  31. package/src/server_control_mcp/remote_agent_bundle.py +56 -0
  32. package/src/server_control_mcp/remote_mux_agent.py +769 -0
  33. package/src/server_control_mcp/remote_task_agent.py +102 -0
  34. package/src/server_control_mcp/runtime.py +3 -2
  35. package/src/server_control_mcp/runtime_services.py +862 -0
  36. package/src/server_control_mcp/secrets.py +6 -6
  37. package/src/server_control_mcp/secure_files.py +121 -0
  38. package/src/server_control_mcp/server.py +53 -20
  39. package/src/server_control_mcp/services.py +3 -469
  40. package/src/server_control_mcp/transports/__init__.py +4 -8
  41. package/src/server_control_mcp/transports/base.py +60 -38
  42. package/src/server_control_mcp/transports/ssh.py +315 -84
  43. package/src/server_control_mcp/tunnel_manager.py +1245 -0
  44. package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
  45. package/src/server_control_mcp/tunnel_providers.py +20 -0
  46. package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
  47. package/src/server_control_mcp/protocol.py +0 -362
  48. 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")