@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,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)
@@ -0,0 +1,279 @@
1
+ """Binary framing primitives for the HostBridge multiplexed protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import struct
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from enum import IntEnum
10
+
11
+ MUX_MAGIC = b"HBM2"
12
+ MUX_PROTOCOL_VERSION = 2
13
+ MAX_FRAME_PAYLOAD_BYTES = 256 * 1024
14
+ MAX_CONTROL_PAYLOAD_BYTES = 64 * 1024
15
+ MAX_SEQUENCE = 0xFFFFFFFF
16
+ MAX_STREAM_ID = 0xFFFFFFFFFFFFFFFF
17
+
18
+ _FRAME_HEADER = struct.Struct("!4sBBHQII")
19
+ FRAME_HEADER_SIZE = _FRAME_HEADER.size
20
+ _WINDOW_UPDATE = struct.Struct("!Q")
21
+
22
+
23
+ class MuxProtocolError(ValueError):
24
+ """Raised when a multiplexed frame violates the wire contract."""
25
+
26
+
27
+ class FrameType(IntEnum):
28
+ HELLO = 1
29
+ HELLO_OK = 2
30
+ GOAWAY = 3
31
+ PING = 4
32
+ PONG = 5
33
+ OPEN = 6
34
+ OPEN_OK = 7
35
+ DATA = 8
36
+ WINDOW_UPDATE = 9
37
+ EOF = 10
38
+ CLOSE = 11
39
+ CLOSE_ACK = 12
40
+ RESET = 13
41
+
42
+
43
+ class StreamParity(IntEnum):
44
+ EVEN = 0
45
+ ODD = 1
46
+
47
+
48
+ _CONNECTION_FRAME_TYPES = {
49
+ FrameType.HELLO,
50
+ FrameType.HELLO_OK,
51
+ FrameType.GOAWAY,
52
+ FrameType.PING,
53
+ FrameType.PONG,
54
+ }
55
+ _STREAM_FRAME_TYPES = {
56
+ FrameType.OPEN,
57
+ FrameType.OPEN_OK,
58
+ FrameType.DATA,
59
+ FrameType.EOF,
60
+ FrameType.CLOSE,
61
+ FrameType.CLOSE_ACK,
62
+ FrameType.RESET,
63
+ }
64
+ _JSON_CONTROL_FRAME_TYPES = {
65
+ FrameType.HELLO,
66
+ FrameType.HELLO_OK,
67
+ FrameType.GOAWAY,
68
+ FrameType.OPEN,
69
+ FrameType.OPEN_OK,
70
+ FrameType.CLOSE,
71
+ FrameType.RESET,
72
+ }
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class MuxFrame:
77
+ frame_type: FrameType
78
+ stream_id: int
79
+ sequence: int
80
+ flags: int = 0
81
+ payload: bytes = b""
82
+
83
+
84
+ def encode_frame(frame: MuxFrame) -> bytes:
85
+ _validate_frame(frame)
86
+ return (
87
+ _FRAME_HEADER.pack(
88
+ MUX_MAGIC,
89
+ MUX_PROTOCOL_VERSION,
90
+ int(frame.frame_type),
91
+ frame.flags,
92
+ frame.stream_id,
93
+ frame.sequence,
94
+ len(frame.payload),
95
+ )
96
+ + frame.payload
97
+ )
98
+
99
+
100
+ class MuxCodec:
101
+ """Incrementally decode complete frames from an arbitrary byte stream."""
102
+
103
+ def __init__(self) -> None:
104
+ self._buffer = bytearray()
105
+
106
+ @property
107
+ def buffered_bytes(self) -> int:
108
+ return len(self._buffer)
109
+
110
+ def feed(self, data: bytes | bytearray) -> list[MuxFrame]:
111
+ if not isinstance(data, bytes | bytearray):
112
+ raise TypeError("mux input must be bytes-like")
113
+ self._buffer.extend(data)
114
+ frames: list[MuxFrame] = []
115
+ offset = 0
116
+ while len(self._buffer) - offset >= FRAME_HEADER_SIZE:
117
+ frame_type, flags, stream_id, sequence, payload_length = _decode_header(self._buffer, offset)
118
+ frame_length = FRAME_HEADER_SIZE + payload_length
119
+ if len(self._buffer) - offset < frame_length:
120
+ break
121
+ payload_start = offset + FRAME_HEADER_SIZE
122
+ payload = bytes(self._buffer[payload_start : offset + frame_length])
123
+ frame = MuxFrame(frame_type, stream_id, sequence, flags=flags, payload=payload)
124
+ _validate_frame(frame)
125
+ frames.append(frame)
126
+ offset += frame_length
127
+ if offset:
128
+ del self._buffer[:offset]
129
+ return frames
130
+
131
+
132
+ def encode_control_payload(value: Mapping[str, object]) -> bytes:
133
+ if not isinstance(value, Mapping):
134
+ raise MuxProtocolError("control payload must be a JSON object")
135
+ try:
136
+ payload = json.dumps(dict(value), separators=(",", ":"), sort_keys=True).encode("utf-8")
137
+ except (TypeError, ValueError) as exc:
138
+ raise MuxProtocolError("control payload must be a JSON object") from exc
139
+ if len(payload) > MAX_CONTROL_PAYLOAD_BYTES:
140
+ raise MuxProtocolError(f"control payload exceeds {MAX_CONTROL_PAYLOAD_BYTES} bytes")
141
+ return payload
142
+
143
+
144
+ def decode_control_payload(payload: bytes) -> dict[str, object]:
145
+ if not isinstance(payload, bytes):
146
+ raise TypeError("control payload must be bytes")
147
+ if len(payload) > MAX_CONTROL_PAYLOAD_BYTES:
148
+ raise MuxProtocolError(f"control payload exceeds {MAX_CONTROL_PAYLOAD_BYTES} bytes")
149
+ try:
150
+ value = json.loads(payload.decode("utf-8"))
151
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
152
+ raise MuxProtocolError("control payload must contain valid JSON") from exc
153
+ if not isinstance(value, dict):
154
+ raise MuxProtocolError("control payload must be a JSON object")
155
+ return value
156
+
157
+
158
+ def encode_window_update(credit: int) -> bytes:
159
+ _validate_credit(credit)
160
+ return _WINDOW_UPDATE.pack(credit)
161
+
162
+
163
+ def decode_window_update(payload: bytes) -> int:
164
+ if not isinstance(payload, bytes):
165
+ raise TypeError("window update payload must be bytes")
166
+ if len(payload) != _WINDOW_UPDATE.size:
167
+ raise MuxProtocolError("window update payload must contain an 8-byte credit")
168
+ (credit,) = _WINDOW_UPDATE.unpack(payload)
169
+ _validate_credit(credit)
170
+ return credit
171
+
172
+
173
+ class StreamIdAllocator:
174
+ def __init__(self, parity: StreamParity, *, first: int | None = None) -> None:
175
+ if not isinstance(parity, StreamParity):
176
+ raise TypeError("stream parity must be a StreamParity")
177
+ candidate = (1 if parity is StreamParity.ODD else 2) if first is None else first
178
+ _validate_stream_id(candidate, allow_zero=False)
179
+ if candidate % 2 != int(parity):
180
+ raise ValueError("first stream ID does not match allocator parity")
181
+ self._next = candidate
182
+
183
+ def allocate(self) -> int:
184
+ if self._next > MAX_STREAM_ID:
185
+ raise MuxProtocolError("stream ID space is exhausted")
186
+ stream_id = self._next
187
+ self._next += 2
188
+ return stream_id
189
+
190
+
191
+ class SequenceTracker:
192
+ """Track independent inbound and outbound sequence spaces."""
193
+
194
+ def __init__(self, *, next_inbound: int = 0, next_outbound: int = 0) -> None:
195
+ _validate_sequence(next_inbound)
196
+ _validate_sequence(next_outbound)
197
+ self._next_inbound = next_inbound
198
+ self._next_outbound = next_outbound
199
+
200
+ def accept_inbound(self, sequence: int) -> None:
201
+ if self._next_inbound > MAX_SEQUENCE:
202
+ raise MuxProtocolError("inbound sequence space is exhausted")
203
+ _validate_sequence(sequence)
204
+ if sequence != self._next_inbound:
205
+ raise MuxProtocolError(f"expected sequence {self._next_inbound}, received {sequence}")
206
+ self._next_inbound += 1
207
+
208
+ def next_outbound(self) -> int:
209
+ if self._next_outbound > MAX_SEQUENCE:
210
+ raise MuxProtocolError("outbound sequence space is exhausted")
211
+ sequence = self._next_outbound
212
+ self._next_outbound += 1
213
+ return sequence
214
+
215
+
216
+ def _decode_header(data: bytes | bytearray, offset: int = 0) -> tuple[FrameType, int, int, int, int]:
217
+ magic, version, raw_type, flags, stream_id, sequence, payload_length = _FRAME_HEADER.unpack_from(data, offset)
218
+ if magic != MUX_MAGIC:
219
+ raise MuxProtocolError("invalid mux frame magic")
220
+ if version != MUX_PROTOCOL_VERSION:
221
+ raise MuxProtocolError(f"unsupported mux protocol version: {version}")
222
+ try:
223
+ frame_type = FrameType(raw_type)
224
+ except ValueError as exc:
225
+ raise MuxProtocolError(f"unsupported mux frame type: {raw_type}") from exc
226
+ if flags != 0:
227
+ raise MuxProtocolError("unsupported mux frame flags")
228
+ if payload_length > MAX_FRAME_PAYLOAD_BYTES:
229
+ raise MuxProtocolError(f"frame payload length exceeds {MAX_FRAME_PAYLOAD_BYTES} bytes")
230
+ return frame_type, flags, stream_id, sequence, payload_length
231
+
232
+
233
+ def _validate_frame(frame: MuxFrame) -> None:
234
+ if not isinstance(frame, MuxFrame):
235
+ raise TypeError("frame must be a MuxFrame")
236
+ if not isinstance(frame.frame_type, FrameType):
237
+ raise MuxProtocolError("invalid mux frame type")
238
+ if not isinstance(frame.flags, int) or isinstance(frame.flags, bool) or frame.flags != 0:
239
+ raise MuxProtocolError("unsupported mux frame flags")
240
+ _validate_stream_id(frame.stream_id, allow_zero=True)
241
+ _validate_sequence(frame.sequence)
242
+ if not isinstance(frame.payload, bytes):
243
+ raise MuxProtocolError("frame payload must be bytes")
244
+ if len(frame.payload) > MAX_FRAME_PAYLOAD_BYTES:
245
+ raise MuxProtocolError(f"frame payload exceeds {MAX_FRAME_PAYLOAD_BYTES} bytes")
246
+ if frame.frame_type in _CONNECTION_FRAME_TYPES and frame.stream_id != 0:
247
+ raise MuxProtocolError(f"{frame.frame_type.name} requires connection stream ID 0")
248
+ if frame.frame_type in _STREAM_FRAME_TYPES and frame.stream_id == 0:
249
+ raise MuxProtocolError(f"{frame.frame_type.name} requires a nonzero stream ID")
250
+ if frame.frame_type is FrameType.DATA and not frame.payload:
251
+ raise MuxProtocolError("DATA requires a non-empty payload")
252
+ if frame.frame_type is FrameType.EOF and frame.payload:
253
+ raise MuxProtocolError(f"{frame.frame_type.name} must not contain a payload")
254
+ if frame.frame_type is FrameType.CLOSE_ACK and frame.payload:
255
+ decode_control_payload(frame.payload)
256
+ if frame.frame_type is FrameType.WINDOW_UPDATE:
257
+ decode_window_update(frame.payload)
258
+ if frame.frame_type in _JSON_CONTROL_FRAME_TYPES:
259
+ decode_control_payload(frame.payload)
260
+ if frame.frame_type in {FrameType.PING, FrameType.PONG} and len(frame.payload) > 8:
261
+ raise MuxProtocolError(f"{frame.frame_type.name} payload exceeds 8 bytes")
262
+
263
+
264
+ def _validate_stream_id(stream_id: int, *, allow_zero: bool) -> None:
265
+ if not isinstance(stream_id, int) or isinstance(stream_id, bool):
266
+ raise MuxProtocolError("stream ID must be an integer")
267
+ minimum = 0 if allow_zero else 1
268
+ if not minimum <= stream_id <= MAX_STREAM_ID:
269
+ raise MuxProtocolError("stream ID is outside the unsigned 64-bit range")
270
+
271
+
272
+ def _validate_sequence(sequence: int) -> None:
273
+ if not isinstance(sequence, int) or isinstance(sequence, bool) or not 0 <= sequence <= MAX_SEQUENCE:
274
+ raise MuxProtocolError("sequence is outside the unsigned 32-bit range")
275
+
276
+
277
+ def _validate_credit(credit: int) -> None:
278
+ if not isinstance(credit, int) or isinstance(credit, bool) or not 1 <= credit <= MAX_STREAM_ID:
279
+ raise MuxProtocolError("credit must be an unsigned nonzero 64-bit integer")
@@ -0,0 +1,71 @@
1
+ """Binary application records carried inside multiplexed DATA frames."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import struct
6
+ from dataclasses import dataclass
7
+ from enum import IntEnum
8
+
9
+ MAX_RECORD_PAYLOAD_BYTES = 256 * 1024
10
+ _HEADER = struct.Struct("!BI")
11
+
12
+
13
+ class MuxRecordError(ValueError):
14
+ pass
15
+
16
+
17
+ class MuxRecordChannel(IntEnum):
18
+ STDOUT = 1
19
+ STDERR = 2
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class MuxRecord:
24
+ channel: MuxRecordChannel
25
+ payload: bytes
26
+
27
+ def __post_init__(self) -> None:
28
+ if not isinstance(self.channel, MuxRecordChannel):
29
+ raise TypeError("record channel must be a MuxRecordChannel")
30
+ if not isinstance(self.payload, bytes):
31
+ raise TypeError("record payload must be bytes")
32
+
33
+
34
+ def encode_record(record: MuxRecord) -> bytes:
35
+ if not isinstance(record, MuxRecord):
36
+ raise TypeError("record must be a MuxRecord")
37
+ if len(record.payload) > MAX_RECORD_PAYLOAD_BYTES:
38
+ raise MuxRecordError(f"record payload exceeds maximum of {MAX_RECORD_PAYLOAD_BYTES} bytes")
39
+ return _HEADER.pack(int(record.channel), len(record.payload)) + record.payload
40
+
41
+
42
+ class MuxRecordDecoder:
43
+ def __init__(self) -> None:
44
+ self._buffer = bytearray()
45
+
46
+ @property
47
+ def buffered_bytes(self) -> int:
48
+ return len(self._buffer)
49
+
50
+ def feed(self, data: bytes) -> list[MuxRecord]:
51
+ if not isinstance(data, bytes):
52
+ raise TypeError("record data must be bytes")
53
+ if data:
54
+ self._buffer.extend(data)
55
+
56
+ records: list[MuxRecord] = []
57
+ while len(self._buffer) >= _HEADER.size:
58
+ channel_value, payload_length = _HEADER.unpack_from(self._buffer)
59
+ try:
60
+ channel = MuxRecordChannel(channel_value)
61
+ except ValueError as exc:
62
+ raise MuxRecordError(f"unknown record channel {channel_value}") from exc
63
+ if payload_length > MAX_RECORD_PAYLOAD_BYTES:
64
+ raise MuxRecordError(f"record payload exceeds maximum of {MAX_RECORD_PAYLOAD_BYTES} bytes")
65
+ record_length = _HEADER.size + payload_length
66
+ if len(self._buffer) < record_length:
67
+ break
68
+ payload = bytes(self._buffer[_HEADER.size : record_length])
69
+ del self._buffer[:record_length]
70
+ records.append(MuxRecord(channel, payload))
71
+ return records