positronic-wire 0.12.0__py3-none-any.whl
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.
- positronic_wire/grpc.py +309 -0
- positronic_wire/py.typed +0 -0
- positronic_wire/registry.py +37 -0
- positronic_wire/roboarena.py +123 -0
- positronic_wire/tests/test_grpc.py +288 -0
- positronic_wire/tests/test_registry.py +72 -0
- positronic_wire/tests/test_roboarena.py +259 -0
- positronic_wire/tests/test_websocket.py +581 -0
- positronic_wire/websocket.py +435 -0
- positronic_wire/wire.py +188 -0
- positronic_wire-0.12.0.dist-info/METADATA +240 -0
- positronic_wire-0.12.0.dist-info/RECORD +13 -0
- positronic_wire-0.12.0.dist-info/WHEEL +4 -0
positronic_wire/grpc.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""The client side of the gRPC wire, and the calls both ends of it agree on.
|
|
2
|
+
|
|
3
|
+
The stream is untyped bytes on both sides. There is no protobuf schema and no generated code.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import queue
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
|
|
12
|
+
import grpc
|
|
13
|
+
from positronic_wire import wire
|
|
14
|
+
|
|
15
|
+
# The one method every session runs on. gRPC routes by this path alone.
|
|
16
|
+
SERVICE = 'positronic.offboard.v1.Inference'
|
|
17
|
+
METHOD = 'Session'
|
|
18
|
+
METHOD_PATH = f'/{SERVICE}/{METHOD}'
|
|
19
|
+
# The unary method beside the session that resets the server's idle timer. Both ends carry JSON on it.
|
|
20
|
+
KEEPALIVE_METHOD = 'KeepAlive'
|
|
21
|
+
KEEPALIVE_METHOD_PATH = f'/{SERVICE}/{KEEPALIVE_METHOD}'
|
|
22
|
+
|
|
23
|
+
# A path no handler serves: a probe of it opens no session on a server that is up.
|
|
24
|
+
PROBE_PATH = f'/{SERVICE}/ChannelProbe'
|
|
25
|
+
|
|
26
|
+
# The session path and the query cross as metadata: a gRPC call carries no URL path of its own.
|
|
27
|
+
SESSION_PATH_HEADER = 'positronic-session-path'
|
|
28
|
+
SESSION_QUERY_HEADER = 'positronic-session-query'
|
|
29
|
+
|
|
30
|
+
MESSAGE_SIZE_OPTIONS = [
|
|
31
|
+
('grpc.max_receive_message_length', wire.MAX_MESSAGE_BYTES),
|
|
32
|
+
('grpc.max_send_message_length', wire.MAX_MESSAGE_BYTES),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# How often the client pings an idle connection. A front drops a connection it reads nothing from.
|
|
36
|
+
PING_EVERY_MS = 20_000
|
|
37
|
+
_PING_ANSWER_TIMEOUT_MS = 10_000
|
|
38
|
+
|
|
39
|
+
# How long ``close`` waits for the server to end the stream and release the session.
|
|
40
|
+
_CLOSE_TIMEOUT_SEC = 5.0
|
|
41
|
+
|
|
42
|
+
# Status details naming a TLS failure that no retry clears: the edge's own configuration, or a certificate
|
|
43
|
+
# that covers an address this client did not dial.
|
|
44
|
+
_UNUSABLE_EDGE_DETAILS = (
|
|
45
|
+
'CERTIFICATE_VERIFY_FAILED',
|
|
46
|
+
'missing selected ALPN property',
|
|
47
|
+
'Hostname Verification Check failed',
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Status details carrying an authoritative answer that the host has no address. A resolver that timed out
|
|
51
|
+
# says something else, which reads as no answer: a name can start resolving, where a misspelt one never does.
|
|
52
|
+
_NO_SUCH_HOST_DETAILS = ('Domain name not found', 'DNS server returned answer with no data')
|
|
53
|
+
|
|
54
|
+
# Status details naming a size limit: the metadata a session opens with, or a frame either end refuses.
|
|
55
|
+
# A retry sends the same oversized request, so no wait fixes it. Capacity exhaustion answers
|
|
56
|
+
# RESOURCE_EXHAUSTED too, says something else, and stays cold.
|
|
57
|
+
_HARD_LIMIT_DETAILS = ('exceeds hard limit', 'message larger than max')
|
|
58
|
+
|
|
59
|
+
# Status details gRPC writes where nothing answered: no address took the connection, or the name lookup timed
|
|
60
|
+
# out. A server that answers ``UNAVAILABLE`` says something else.
|
|
61
|
+
_NO_ANSWER_DETAILS = ('failed to connect to all addresses', 'Timeout while contacting DNS servers')
|
|
62
|
+
|
|
63
|
+
_COLD_CODES = (grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.RESOURCE_EXHAUSTED)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _refusal(status: grpc.RpcError) -> wire.Refusal:
|
|
67
|
+
"""What a status that ended a call before it opened says about the server.
|
|
68
|
+
|
|
69
|
+
``PERMISSION_DENIED`` reads as 403, ``UNAVAILABLE`` as 503, ``RESOURCE_EXHAUSTED`` as 429, and
|
|
70
|
+
``DEADLINE_EXCEEDED`` as no answer before the deadline. Refusals that no wait fixes wear a retryable code
|
|
71
|
+
too — an unusable edge, a host with no address, a breached size limit — and their details tell them from a
|
|
72
|
+
cold backend. So do the details of a connect nothing answered.
|
|
73
|
+
"""
|
|
74
|
+
details = status.details() or ''
|
|
75
|
+
if any(marker in details for marker in _UNUSABLE_EDGE_DETAILS + _NO_SUCH_HOST_DETAILS + _HARD_LIMIT_DETAILS):
|
|
76
|
+
return wire.Refusal.FINAL
|
|
77
|
+
code = status.code()
|
|
78
|
+
if code is grpc.StatusCode.DEADLINE_EXCEEDED or any(marker in details for marker in _NO_ANSWER_DETAILS):
|
|
79
|
+
return wire.Refusal.SILENT
|
|
80
|
+
if code is grpc.StatusCode.PERMISSION_DENIED:
|
|
81
|
+
return wire.Refusal.FORBIDDEN
|
|
82
|
+
return wire.Refusal.COLD if code in _COLD_CODES else wire.Refusal.FINAL
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class GrpcClientConnection(wire.ClientConnection):
|
|
86
|
+
"""A client's end of one gRPC session, over a ready ``channel``.
|
|
87
|
+
|
|
88
|
+
A reader thread drains the response stream into a queue: the stream has no per-message timeout, and
|
|
89
|
+
``recv`` needs one.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, channel: grpc.Channel, target: str, metadata: tuple[tuple[str, str], ...]):
|
|
93
|
+
self._target = target
|
|
94
|
+
self._channel = channel
|
|
95
|
+
self._outbox: queue.SimpleQueue[bytes | None] = queue.SimpleQueue()
|
|
96
|
+
self._inbox: queue.SimpleQueue[bytes | BaseException] = queue.SimpleQueue()
|
|
97
|
+
# One receipt per frame gRPC writes, and a last ``False`` once the call can write no more.
|
|
98
|
+
self._written: queue.SimpleQueue[bool] = queue.SimpleQueue()
|
|
99
|
+
self._closed = False
|
|
100
|
+
self._ended = False
|
|
101
|
+
self._received = False
|
|
102
|
+
self._stopped_by: BaseException | None = None
|
|
103
|
+
call = self._channel.stream_stream(METHOD_PATH, request_serializer=None, response_deserializer=None)
|
|
104
|
+
self._responses = call(self._requests(), metadata=metadata)
|
|
105
|
+
self._reader = threading.Thread(target=self._read, name='grpc-session-reader', daemon=True)
|
|
106
|
+
self._reader.start()
|
|
107
|
+
|
|
108
|
+
def _requests(self):
|
|
109
|
+
"""The outbound frames. ``None`` ends the stream, which half-closes the session."""
|
|
110
|
+
while (message := self._outbox.get()) is not None:
|
|
111
|
+
yield message
|
|
112
|
+
# gRPC asks for the next frame only once it has written this one, so the resume is the receipt.
|
|
113
|
+
self._written.put(True)
|
|
114
|
+
|
|
115
|
+
def _read(self) -> None:
|
|
116
|
+
"""Drain the response stream into the inbox, and end the inbox with what stopped the stream."""
|
|
117
|
+
try:
|
|
118
|
+
for message in self._responses:
|
|
119
|
+
self._inbox.put(message)
|
|
120
|
+
self._stopped_by = wire.PeerDisconnected(f'{self._target} ended the session')
|
|
121
|
+
self._inbox.put(self._stopped_by)
|
|
122
|
+
except Exception as e:
|
|
123
|
+
self._stopped_by = e
|
|
124
|
+
self._inbox.put(e)
|
|
125
|
+
finally:
|
|
126
|
+
self._responses.cancel()
|
|
127
|
+
# The call is over, so no further frame leaves the iterator. Release a send waiting on one.
|
|
128
|
+
self._written.put(False)
|
|
129
|
+
|
|
130
|
+
def send(self, message: bytes) -> None:
|
|
131
|
+
if self._closed or self._ended:
|
|
132
|
+
raise wire.PeerDisconnected(f'The session on {self._target} has ended')
|
|
133
|
+
self._outbox.put(message)
|
|
134
|
+
# The call's end releases this wait, so a dead connection raises here instead of blocking. The end is
|
|
135
|
+
# recorded here too: the receipt queue says it once, and a later send must not wait for it again.
|
|
136
|
+
if not self._written.get():
|
|
137
|
+
self._ended = True
|
|
138
|
+
raise wire.PeerDisconnected(f'{self._target} ended the session: {self._stopped_by}') from self._stopped_by
|
|
139
|
+
|
|
140
|
+
def recv(self, timeout: float | None = None) -> bytes:
|
|
141
|
+
# A reply that arrived during ``close`` sits in the inbox, and would pair one observation's actions
|
|
142
|
+
# with the next observation.
|
|
143
|
+
if self._closed:
|
|
144
|
+
raise wire.PeerDisconnected(f'The session on {self._target} is closed')
|
|
145
|
+
try:
|
|
146
|
+
answer = self._inbox.get(timeout=timeout)
|
|
147
|
+
except queue.Empty:
|
|
148
|
+
raise TimeoutError(f'No message from {self._target} within {timeout}s') from None
|
|
149
|
+
if isinstance(answer, BaseException):
|
|
150
|
+
# What ended the stream is queued once. The caller reads it before ``send`` refuses a write.
|
|
151
|
+
self._ended = True
|
|
152
|
+
if isinstance(answer, grpc.RpcError):
|
|
153
|
+
# A status before any message crossed is the server refusing the call. A status after one is a
|
|
154
|
+
# lost peer, which the connect retry reads as cold.
|
|
155
|
+
if not self._received:
|
|
156
|
+
raise wire.ConnectRefused(_refusal(answer), str(answer)) from answer
|
|
157
|
+
raise wire.PeerDisconnected(f'{self._target} ended the session: {answer}') from answer
|
|
158
|
+
raise answer
|
|
159
|
+
self._received = True
|
|
160
|
+
return answer
|
|
161
|
+
|
|
162
|
+
def close(self) -> str:
|
|
163
|
+
if self._closed:
|
|
164
|
+
return 'already closed'
|
|
165
|
+
self._closed = True
|
|
166
|
+
self._outbox.put(None)
|
|
167
|
+
# The half-close ends the server's session, and the server then ends the stream. A channel closed
|
|
168
|
+
# before that cuts the server's cleanup short.
|
|
169
|
+
self._reader.join(timeout=_CLOSE_TIMEOUT_SEC)
|
|
170
|
+
server_ended_stream = not self._reader.is_alive()
|
|
171
|
+
self._channel.close()
|
|
172
|
+
# A stream the server never ended means the server still holds this session, and the next session's
|
|
173
|
+
# handshake waits on its slot.
|
|
174
|
+
return (
|
|
175
|
+
f'peer had ended the stream {self._ended}, '
|
|
176
|
+
f'server ended it within {_CLOSE_TIMEOUT_SEC}s {server_ended_stream}'
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# The largest share of one connect attempt's budget the refusal probe may spend. Both waits fit inside
|
|
181
|
+
# the caller's ``open_timeout``: a target that drops every connect answers neither.
|
|
182
|
+
_REFUSAL_PROBE_SEC = 1.0
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _client_options() -> list[tuple[str, int]]:
|
|
186
|
+
return [
|
|
187
|
+
*MESSAGE_SIZE_OPTIONS,
|
|
188
|
+
('grpc.keepalive_time_ms', PING_EVERY_MS),
|
|
189
|
+
('grpc.keepalive_timeout_ms', _PING_ANSWER_TIMEOUT_MS),
|
|
190
|
+
# The gRPC default sends two pings without data, five minutes apart.
|
|
191
|
+
('grpc.http2.max_pings_without_data', 0),
|
|
192
|
+
('grpc.http2.min_time_between_pings_ms', PING_EVERY_MS),
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def target(host: str, port: int) -> str:
|
|
197
|
+
"""``host:port`` in gRPC's target syntax, which needs an IPv6 literal in brackets."""
|
|
198
|
+
return f'{wire.bracket_ipv6(host)}:{port}'
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _probe_share(open_timeout: float) -> float:
|
|
202
|
+
"""The share of one connect attempt the refusal probe gets; the readiness wait gets the rest.
|
|
203
|
+
|
|
204
|
+
Half at most: an ``open_timeout`` under ``_REFUSAL_PROBE_SEC`` still waits for a healthy server.
|
|
205
|
+
"""
|
|
206
|
+
return min(_REFUSAL_PROBE_SEC, open_timeout / 2)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _metadata(headers: Mapping[str, str] | None) -> tuple[tuple[str, str], ...]:
|
|
210
|
+
"""``headers`` as gRPC metadata. The keys are lower case, as the server's authorization check reads them."""
|
|
211
|
+
return tuple((key.lower(), value) for key, value in (headers or {}).items())
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _connect_refusal(
|
|
215
|
+
channel: grpc.Channel, metadata: tuple[tuple[str, str], ...], timeout: float
|
|
216
|
+
) -> grpc.RpcError | None:
|
|
217
|
+
"""What gRPC says stopped a call on ``PROBE_PATH``, which a server that is up answers ``UNIMPLEMENTED``."""
|
|
218
|
+
probe = channel.stream_stream(PROBE_PATH, request_serializer=None, response_deserializer=None)
|
|
219
|
+
try:
|
|
220
|
+
next(probe(iter(()), metadata=metadata, timeout=timeout))
|
|
221
|
+
except grpc.RpcError as e:
|
|
222
|
+
return e
|
|
223
|
+
except StopIteration:
|
|
224
|
+
return None
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _ready_channel(channel: grpc.Channel, target: str, open_timeout: float) -> grpc.Channel:
|
|
229
|
+
"""``channel``, once it is ready. Raises ``wire.ConnectRefused`` when it is not within ``open_timeout``."""
|
|
230
|
+
deadline = time.monotonic() + open_timeout
|
|
231
|
+
try:
|
|
232
|
+
grpc.channel_ready_future(channel).result(timeout=open_timeout - _probe_share(open_timeout))
|
|
233
|
+
except grpc.FutureTimeoutError as not_ready:
|
|
234
|
+
refusal = _connect_refusal(channel, (), timeout=max(0.0, deadline - time.monotonic()))
|
|
235
|
+
# An ``UNIMPLEMENTED`` from the probe path means the channel is up: the readiness wait was too short.
|
|
236
|
+
if refusal is not None and refusal.code() is grpc.StatusCode.UNIMPLEMENTED:
|
|
237
|
+
return channel
|
|
238
|
+
channel.close()
|
|
239
|
+
if refusal is None:
|
|
240
|
+
message = f'gRPC channel to {target} is not ready within {open_timeout}s'
|
|
241
|
+
raise wire.ConnectRefused(wire.Refusal.SILENT, message) from not_ready
|
|
242
|
+
raise wire.ConnectRefused(_refusal(refusal), str(refusal)) from refusal
|
|
243
|
+
return channel
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class GrpcClientWire(wire.ClientWire[wire.HostPortAddress]):
|
|
247
|
+
"""The client side of the gRPC wire. Its port carries sessions and the keepalive call. The channel is plaintext."""
|
|
248
|
+
|
|
249
|
+
NAME = 'grpc'
|
|
250
|
+
ADDRESS = wire.HostPortAddress
|
|
251
|
+
DEFAULT_PORT = 80
|
|
252
|
+
|
|
253
|
+
def session_url(self, address: wire.HostPortAddress) -> str:
|
|
254
|
+
"""gRPC dials a target, not a URL: ``host:port`` and the session route, for the log."""
|
|
255
|
+
query = f'?{address.query}' if address.query else ''
|
|
256
|
+
return f'{target(address.host, address.port)}{address.path}{query}'
|
|
257
|
+
|
|
258
|
+
def channel(self, target: str) -> grpc.Channel:
|
|
259
|
+
return grpc.insecure_channel(target, options=_client_options())
|
|
260
|
+
|
|
261
|
+
def dial(
|
|
262
|
+
self, address: wire.HostPortAddress, headers: Mapping[str, str] | None, open_timeout: float
|
|
263
|
+
) -> GrpcClientConnection:
|
|
264
|
+
"""A client's end of one session on ``address``. Raises ``wire.ConnectRefused`` when it does not open."""
|
|
265
|
+
dialled = target(address.host, address.port)
|
|
266
|
+
channel = _ready_channel(self.channel(dialled), dialled, open_timeout)
|
|
267
|
+
metadata = _metadata(headers) + ((SESSION_PATH_HEADER, address.path), (SESSION_QUERY_HEADER, address.query))
|
|
268
|
+
return GrpcClientConnection(channel, dialled, metadata)
|
|
269
|
+
|
|
270
|
+
def keepalive(self, address: wire.HostPortAddress, headers: Mapping[str, str] | None, timeout: float) -> int | None:
|
|
271
|
+
"""Call ``KEEPALIVE_METHOD_PATH`` on its own channel. ``timeout`` bounds the whole call: the channel takes what
|
|
272
|
+
it needs, and the call takes the rest."""
|
|
273
|
+
dialled = target(address.host, address.port)
|
|
274
|
+
deadline = time.monotonic() + timeout
|
|
275
|
+
channel = _ready_channel(self.channel(dialled), dialled, timeout)
|
|
276
|
+
try:
|
|
277
|
+
unary = channel.unary_unary(KEEPALIVE_METHOD_PATH, request_serializer=None, response_deserializer=None)
|
|
278
|
+
answer = unary(b'', metadata=_metadata(headers), timeout=max(0.0, deadline - time.monotonic()))
|
|
279
|
+
except grpc.RpcError as e:
|
|
280
|
+
if e.code() is grpc.StatusCode.UNIMPLEMENTED:
|
|
281
|
+
raise wire.KeepaliveUnsupported(f'{dialled} serves sessions but not {KEEPALIVE_METHOD}') from e
|
|
282
|
+
raise wire.ConnectRefused(_refusal(e), f'{e} (calling {KEEPALIVE_METHOD} on {dialled})') from e
|
|
283
|
+
finally:
|
|
284
|
+
channel.close()
|
|
285
|
+
return json.loads(answer)[wire.ALIVE_SECONDS]
|
|
286
|
+
|
|
287
|
+
def probe(
|
|
288
|
+
self, address: wire.HostPortAddress, headers: Mapping[str, str] | None, open_timeout: float
|
|
289
|
+
) -> wire.Refusal | None:
|
|
290
|
+
"""One call on ``PROBE_PATH``, carrying ``headers``: a server that is up answers it ``UNIMPLEMENTED``."""
|
|
291
|
+
channel = self.channel(target(address.host, address.port))
|
|
292
|
+
try:
|
|
293
|
+
answered = _connect_refusal(channel, _metadata(headers), open_timeout)
|
|
294
|
+
finally:
|
|
295
|
+
channel.close()
|
|
296
|
+
if answered is None or answered.code() is grpc.StatusCode.UNIMPLEMENTED:
|
|
297
|
+
return None
|
|
298
|
+
return _refusal(answered)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class GrpcTlsClientWire(GrpcClientWire):
|
|
302
|
+
"""The gRPC wire through a TLS edge in front of the server's plaintext port."""
|
|
303
|
+
|
|
304
|
+
NAME = 'grpc_tls'
|
|
305
|
+
DEFAULT_PORT = 443
|
|
306
|
+
|
|
307
|
+
def channel(self, target: str) -> grpc.Channel:
|
|
308
|
+
# No roots named: the channel verifies the edge against the system's own roots.
|
|
309
|
+
return grpc.secure_channel(target, grpc.ssl_channel_credentials(), options=_client_options())
|
positronic_wire/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Installed client transports, selected by name."""
|
|
2
|
+
|
|
3
|
+
from importlib.util import find_spec
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from positronic_wire import wire
|
|
7
|
+
|
|
8
|
+
# One stateless wire per name. A wire holds no address: the caller builds the wire's own and hands it in.
|
|
9
|
+
CLIENT_WIRES: dict[str, wire.ClientWire[Any]] = {}
|
|
10
|
+
|
|
11
|
+
if find_spec('websockets') is not None:
|
|
12
|
+
from positronic_wire import roboarena, websocket
|
|
13
|
+
|
|
14
|
+
for _member in (
|
|
15
|
+
websocket.WebsocketClientWire(),
|
|
16
|
+
websocket.WebsocketTlsClientWire(),
|
|
17
|
+
websocket.WebsocketUnixClientWire(),
|
|
18
|
+
roboarena.RoboarenaClientWire(),
|
|
19
|
+
):
|
|
20
|
+
CLIENT_WIRES[_member.NAME] = _member
|
|
21
|
+
|
|
22
|
+
if find_spec('grpc') is not None:
|
|
23
|
+
from positronic_wire import grpc
|
|
24
|
+
|
|
25
|
+
for _member in (grpc.GrpcClientWire(), grpc.GrpcTlsClientWire()):
|
|
26
|
+
CLIENT_WIRES[_member.NAME] = _member
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def client_wire(name: str) -> wire.ClientWire[Any]:
|
|
30
|
+
"""Select an installed wire, or raise with the available names and installation extras."""
|
|
31
|
+
try:
|
|
32
|
+
return CLIENT_WIRES[name]
|
|
33
|
+
except KeyError:
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f'No wire is called {name!r}; the installed wires are {", ".join(CLIENT_WIRES)}. '
|
|
36
|
+
'Install positronic-wire[websocket] or positronic-wire[grpc] for the required transport.'
|
|
37
|
+
) from None
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""The client side of the roboarena wire: each frame carried as bytes, on a websocket at the root."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
from positronic_wire import wire
|
|
9
|
+
from positronic_wire.websocket import WebsocketClientConnection, connected_socket, refusal_of
|
|
10
|
+
from websockets.exceptions import ConnectionClosed, InvalidHandshake
|
|
11
|
+
from websockets.sync.client import connect
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclasses.dataclass(frozen=True)
|
|
15
|
+
class RoboarenaAddress(wire.SessionAddress):
|
|
16
|
+
"""A session on a roboarena server, named by the host and the port the partner published."""
|
|
17
|
+
|
|
18
|
+
host: str
|
|
19
|
+
port: int
|
|
20
|
+
# Bare assignments, so neither is a field: the protocol routes on a key inside each frame, and the
|
|
21
|
+
# server closes any path but the root with `1008 Unsupported WebSocket path`.
|
|
22
|
+
path = ''
|
|
23
|
+
query = ''
|
|
24
|
+
|
|
25
|
+
def at_root(self) -> Self:
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TextAnswer(Exception):
|
|
30
|
+
"""The server answered in text. It serves nothing more on that connection, and a retry does not change the text."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, text: str):
|
|
33
|
+
super().__init__(f'the server answered this text: {text}')
|
|
34
|
+
self.text = text
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RoboarenaClientConnection(WebsocketClientConnection):
|
|
38
|
+
"""A client's end of one roboarena connection: a websocket session whose peer may answer in text."""
|
|
39
|
+
|
|
40
|
+
def recv(self, timeout: float | None = None) -> bytes:
|
|
41
|
+
try:
|
|
42
|
+
message = self._websocket.recv(timeout=timeout)
|
|
43
|
+
except ConnectionClosed as e:
|
|
44
|
+
raise wire.PeerDisconnected(str(e)) from e
|
|
45
|
+
if isinstance(message, str):
|
|
46
|
+
raise TextAnswer(message)
|
|
47
|
+
return message
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RoboarenaClientWire(wire.ClientWire[RoboarenaAddress]):
|
|
51
|
+
"""The client side of the roboarena wire, whose root carries frames alone.
|
|
52
|
+
|
|
53
|
+
It sends the headers it is given on the handshake, and none where it is given none.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
NAME = 'roboarena'
|
|
57
|
+
ADDRESS = RoboarenaAddress
|
|
58
|
+
# A server holds one connection open across a run and sends nothing between inferences, so a shorter
|
|
59
|
+
# pong deadline drops a quiet connection.
|
|
60
|
+
PING_INTERVAL_S = 60.0
|
|
61
|
+
PING_TIMEOUT_S = 600.0
|
|
62
|
+
|
|
63
|
+
def session_url(self, address: RoboarenaAddress) -> str:
|
|
64
|
+
"""The root this wire dials; a roboarena session names no route under it."""
|
|
65
|
+
return f'ws://{wire.bracket_ipv6(address.host)}:{address.port}'
|
|
66
|
+
|
|
67
|
+
def keepalive(self, address: RoboarenaAddress, headers: Mapping[str, str] | None, timeout: float) -> int | None:
|
|
68
|
+
"""Raises ``wire.KeepaliveUnsupported``: a partner's protocol carries no keepalive call."""
|
|
69
|
+
raise wire.KeepaliveUnsupported(f"{self.NAME} carries a partner's frames alone and answers no keepalive")
|
|
70
|
+
|
|
71
|
+
def _open(
|
|
72
|
+
self, address: RoboarenaAddress, headers: Mapping[str, str] | None, open_timeout: float, **settings
|
|
73
|
+
) -> RoboarenaClientConnection:
|
|
74
|
+
"""One opened connection on ``address``. Raises ``wire.ConnectRefused`` when it does not open."""
|
|
75
|
+
url = self.session_url(address)
|
|
76
|
+
try:
|
|
77
|
+
connection = connect(
|
|
78
|
+
url,
|
|
79
|
+
open_timeout=open_timeout,
|
|
80
|
+
additional_headers=headers,
|
|
81
|
+
compression=None,
|
|
82
|
+
ping_interval=self.PING_INTERVAL_S,
|
|
83
|
+
ping_timeout=self.PING_TIMEOUT_S,
|
|
84
|
+
max_size=wire.MAX_MESSAGE_BYTES,
|
|
85
|
+
**settings,
|
|
86
|
+
)
|
|
87
|
+
except (OSError, InvalidHandshake, ConnectionClosed) as e:
|
|
88
|
+
raise wire.ConnectRefused(refusal_of(e), f'{e} (connecting to {url})') from e
|
|
89
|
+
return RoboarenaClientConnection(connection)
|
|
90
|
+
|
|
91
|
+
def dial(
|
|
92
|
+
self, address: RoboarenaAddress, headers: Mapping[str, str] | None, open_timeout: float
|
|
93
|
+
) -> RoboarenaClientConnection:
|
|
94
|
+
"""A client's end of one session on ``address``.
|
|
95
|
+
|
|
96
|
+
The server announces its configuration as the first frame, and ``dial`` leaves it unread.
|
|
97
|
+
"""
|
|
98
|
+
return self._open(address, headers, open_timeout)
|
|
99
|
+
|
|
100
|
+
def probe(
|
|
101
|
+
self, address: RoboarenaAddress, headers: Mapping[str, str] | None, open_timeout: float
|
|
102
|
+
) -> wire.Refusal | None:
|
|
103
|
+
"""Whether a server announces itself at ``address``. Raises ``TextAnswer`` when it answers in text.
|
|
104
|
+
|
|
105
|
+
``open_timeout`` bounds the whole call: the name lookup, the handshake and the announcement.
|
|
106
|
+
"""
|
|
107
|
+
deadline = time.monotonic() + open_timeout
|
|
108
|
+
try:
|
|
109
|
+
sock = connected_socket(address.host, address.port, open_timeout)
|
|
110
|
+
# The probe has nothing to finish, so it does not wait for the server's close.
|
|
111
|
+
connection = self._open(address, headers, max(0.0, deadline - time.monotonic()), sock=sock, close_timeout=0)
|
|
112
|
+
except OSError as e:
|
|
113
|
+
return refusal_of(e)
|
|
114
|
+
except wire.ConnectRefused as e:
|
|
115
|
+
return e.refusal
|
|
116
|
+
try:
|
|
117
|
+
connection.recv(timeout=max(0.0, deadline - time.monotonic()))
|
|
118
|
+
except (TimeoutError, wire.PeerDisconnected):
|
|
119
|
+
# The server upgraded and announced nothing: a backend still starting.
|
|
120
|
+
return wire.Refusal.COLD
|
|
121
|
+
finally:
|
|
122
|
+
connection.close()
|
|
123
|
+
return None
|