positronic-model-server 0.2.2__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.
@@ -0,0 +1,106 @@
1
+ """The server side of the transports a session arrives on: one wire per transport, one connection per session.
2
+
3
+ The client side, and the facts both ends share, are ``positronic_wire``.
4
+ """
5
+
6
+ import abc
7
+ import dataclasses
8
+ from collections.abc import Awaitable, Callable, Mapping
9
+ from typing import Any
10
+
11
+ from . import keys
12
+
13
+
14
+ class ServedAddress(abc.ABC):
15
+ """Where a wire serves, and how a session served there names that in its own metadata.
16
+
17
+ A wire that binds a host and a port and one that binds a socket answer with different keys, so each
18
+ writes its own; nothing reads one kind of address and decides.
19
+ """
20
+
21
+ @property
22
+ @abc.abstractmethod
23
+ def meta(self) -> dict[str, Any]:
24
+ """The metadata keys this address writes into every session served on it."""
25
+
26
+
27
+ @dataclasses.dataclass(frozen=True)
28
+ class ServedHostPort(ServedAddress):
29
+ """A wire serving on a host and a port. The port is known once the wire has bound."""
30
+
31
+ host: str
32
+ port: int
33
+
34
+ @property
35
+ def meta(self) -> dict[str, Any]:
36
+ return {keys.HOST: self.host, keys.PORT: self.port}
37
+
38
+
39
+ class ServerConnection(abc.ABC):
40
+ """A server's end of one open session."""
41
+
42
+ @property
43
+ @abc.abstractmethod
44
+ def peer(self) -> str:
45
+ """Whom this session serves, for the log."""
46
+
47
+ @property
48
+ @abc.abstractmethod
49
+ def served_address(self) -> ServedAddress:
50
+ """Where the wire that accepted this session serves."""
51
+
52
+ @property
53
+ @abc.abstractmethod
54
+ def query_params(self) -> list[tuple[str, str]]:
55
+ """The session params the client asked for."""
56
+
57
+ @abc.abstractmethod
58
+ async def send(self, message: bytes) -> None:
59
+ """Raises ``positronic_wire.wire.PeerDisconnected`` once the client ends the session."""
60
+
61
+ @abc.abstractmethod
62
+ async def receive(self) -> bytes:
63
+ """The next message. Raises ``positronic_wire.wire.PeerDisconnected`` once the client ends the session."""
64
+
65
+ @abc.abstractmethod
66
+ async def refuse(self, reason: str) -> None:
67
+ """End a session the server cannot serve, and tell the client why."""
68
+
69
+
70
+ # What a wire hands the server for each session it accepts.
71
+ SessionHandler = Callable[[ServerConnection], Awaitable[None]]
72
+
73
+ # What a wire calls for each keepalive call: it resets the idle timer, and returns the seconds the server
74
+ # stays alive after the call, or ``None`` for a server with no idle timeout.
75
+ KeepaliveHandler = Callable[[], int | None]
76
+
77
+ # Whether the session headers carry a credential the server accepts. Header names are lower case.
78
+ Authorized = Callable[[Mapping[str, str]], bool]
79
+
80
+
81
+ class Wire(abc.ABC):
82
+ """One transport that sessions and keepalive calls arrive on.
83
+
84
+ A wire refuses an unauthorized peer before the session opens.
85
+ """
86
+
87
+ @property
88
+ @abc.abstractmethod
89
+ def served_address(self) -> ServedAddress:
90
+ """Where this wire serves, known once ``start`` returns: a bound port, or the socket path."""
91
+
92
+ @abc.abstractmethod
93
+ async def start(self, session: SessionHandler, keepalive: KeepaliveHandler, authorized: Authorized) -> None:
94
+ """Bind, give every accepted session to ``session``, and answer every keepalive call from ``keepalive``.
95
+
96
+ Raises when the address is taken: a port another process holds, or a socket path a live
97
+ server is already serving on.
98
+ """
99
+
100
+ @abc.abstractmethod
101
+ async def serve(self) -> None:
102
+ """Carry sessions until ``stop``, or until the wire ends for its own reason."""
103
+
104
+ @abc.abstractmethod
105
+ async def stop(self) -> None:
106
+ """End the wire, and every session on it."""
@@ -0,0 +1,78 @@
1
+ """Plain-data component descriptions and session parameter utilities."""
2
+
3
+ import json
4
+ from collections.abc import Sequence
5
+ from typing import Any
6
+
7
+ SEQ = 'seq'
8
+ PAR = 'par'
9
+ NAME = 'name'
10
+ ARGS = 'args'
11
+ VERSION = 'version'
12
+
13
+
14
+ def component(name: str, /, *, version: int = 1, **args: Any) -> dict[str, Any]:
15
+ """Describe a component without importing it; arguments must survive a JSON round trip."""
16
+ if not isinstance(name, str) or not name:
17
+ raise ValueError('A component needs a nonempty name')
18
+ if type(version) is not int or version < 1:
19
+ raise ValueError('A component version must be a positive integer')
20
+ # JSON normalizes tuples to lists and rejects Python objects and nonfinite numbers.
21
+ return {NAME: name, VERSION: version, ARGS: json.loads(json.dumps(args, allow_nan=False))}
22
+
23
+
24
+ def sequence(*parts: dict[str, Any]) -> dict[str, Any]:
25
+ """Compose at least one processor or codec in execution order."""
26
+ if not parts:
27
+ raise ValueError('A sequential spec must contain at least one component')
28
+ return {SEQ: list(parts)}
29
+
30
+
31
+ def parallel(*parts: dict[str, Any]) -> dict[str, Any]:
32
+ """Describe codecs applied in parallel; the client checks that each component is a codec."""
33
+ if not parts:
34
+ raise ValueError('Parallel specs require at least one codec')
35
+ return {PAR: list(parts)}
36
+
37
+
38
+ def validate(node: dict[str, Any]) -> None:
39
+ """Check description structure without resolving client components."""
40
+ if not isinstance(node, dict):
41
+ raise ValueError('A component description must be a mapping')
42
+ compositions = {SEQ, PAR} & node.keys()
43
+ if compositions:
44
+ if len(node) != 1:
45
+ raise ValueError('A composition must contain only seq or par')
46
+ parts = node[next(iter(compositions))]
47
+ if not isinstance(parts, list) or not parts:
48
+ raise ValueError('A composition needs a nonempty list of components')
49
+ for part in parts:
50
+ validate(part)
51
+ else:
52
+ if node.keys() - {NAME, VERSION, ARGS} or not isinstance(node.get(ARGS, {}), dict):
53
+ raise ValueError('A component contains invalid fields or arguments')
54
+ name = node.get(NAME)
55
+ if not isinstance(name, str):
56
+ raise ValueError('A component needs a name')
57
+ component(name, version=node.get(VERSION, 1), **node.get(ARGS, {}))
58
+
59
+
60
+ def resolve_params(defaults: dict[str, Any], requested: dict[str, Any]) -> dict[str, Any]:
61
+ """Apply declared session overrides, returning independent JSON-compatible values."""
62
+ unknown = requested.keys() - defaults.keys()
63
+ if unknown:
64
+ raise ValueError(f'Unknown session parameters: {sorted(unknown)}')
65
+ return json.loads(json.dumps({**defaults, **requested}, allow_nan=False))
66
+
67
+
68
+ def parse_params(items: Sequence[tuple[str, str]]) -> dict[str, Any]:
69
+ """Decode query values as JSON or plain strings, rejecting duplicate parameter names."""
70
+ result = {}
71
+ for name, value in items:
72
+ if name in result:
73
+ raise ValueError(f'Duplicate session parameter: {name}')
74
+ try:
75
+ result[name] = json.loads(value)
76
+ except json.JSONDecodeError:
77
+ result[name] = value
78
+ return result
@@ -0,0 +1,472 @@
1
+ """Native model lifecycle over independently installed transports, without Positronic."""
2
+
3
+ import asyncio
4
+ import os
5
+ import socket
6
+ import threading
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from contextlib import closing, suppress
9
+ from dataclasses import dataclass, field
10
+ from http import HTTPStatus
11
+ from http.client import HTTPConnection
12
+ from importlib.util import find_spec
13
+ from typing import Any
14
+
15
+ import numpy as np
16
+ import pytest
17
+ from positronic_model_server import keys, protocol, serialization, server_wire, spec
18
+ from positronic_model_server.server import Model, ModelServer, Session
19
+ from positronic_wire import wire
20
+
21
+ TRANSPORTS = []
22
+ if find_spec('uvicorn') is not None and find_spec('websockets') is not None:
23
+ from positronic_model_server.websocket_wire import WebsocketWire
24
+ from positronic_wire.websocket import WebsocketClientWire
25
+
26
+ TRANSPORTS.append(pytest.param((WebsocketWire, WebsocketClientWire), id='websocket'))
27
+ if find_spec('grpc') is not None:
28
+ from positronic_model_server.grpc_wire import GrpcWire
29
+ from positronic_wire.grpc import GrpcClientWire
30
+
31
+ TRANSPORTS.append(pytest.param((GrpcWire, GrpcClientWire), id='grpc'))
32
+
33
+
34
+ class Probe(Model):
35
+ def __init__(self):
36
+ super().__init__(parameters={'scale': 1}, metadata={'checkpoint': 'fake'})
37
+ self.calls = []
38
+ self.loading = threading.Event()
39
+ self.load_release = threading.Event()
40
+ self.preparing = threading.Event()
41
+ self.prepare_release = threading.Event()
42
+ self.inferring = threading.Event()
43
+ self.infer_release = threading.Event()
44
+ self.cleaned = threading.Event()
45
+ for gate in (self.load_release, self.prepare_release, self.infer_release):
46
+ gate.set()
47
+
48
+ def record(self, operation: str) -> None:
49
+ self.calls.append((operation, os.getpid(), threading.get_ident()))
50
+
51
+ def load(self) -> Model:
52
+ self.record('load')
53
+ self.loading.set()
54
+ assert self.load_release.wait(10)
55
+ return self
56
+
57
+ def close(self) -> None:
58
+ self.record('close_model')
59
+
60
+ def prepare_session(self, params: dict[str, Any]) -> Session:
61
+ self.record('prepare')
62
+ self.preparing.set()
63
+ assert self.prepare_release.wait(10)
64
+ if not isinstance(params['scale'], int):
65
+ raise ValueError('scale must be an integer')
66
+ return ProbeSession(self, params['scale'])
67
+
68
+
69
+ class ProbeSession(Session):
70
+ def __init__(self, probe: Probe, scale: int):
71
+ super().__init__(spec.component('chunked_schedule', version=2, fps=20))
72
+ self._probe = probe
73
+ self._scale = scale
74
+ self._count = 0
75
+
76
+ def infer(self, observation):
77
+ self._probe.record('infer')
78
+ self._probe.inferring.set()
79
+ assert self._probe.infer_release.wait(10)
80
+ if observation.get('fail'):
81
+ raise ValueError('model rejected observation')
82
+ self._count += 1
83
+ return {'count': self._count, 'scaled': self._scale * observation.get('value', 1), 'native': observation}
84
+
85
+ def close(self):
86
+ self._probe.record('close_session')
87
+ self._probe.cleaned.set()
88
+
89
+
90
+ @dataclass
91
+ class Peer:
92
+ connection: wire.ClientConnection
93
+ session_id: str
94
+ metadata: dict
95
+
96
+ def infer(self, observation: Any) -> dict:
97
+ self.connection.send(
98
+ serialization.serialise({protocol.SESSION_ID: self.session_id, protocol.OBSERVATION: observation})
99
+ )
100
+ return serialization.deserialise(self.connection.recv(timeout=5))
101
+
102
+ def close(self) -> None:
103
+ message = {protocol.SESSION_ID: self.session_id, protocol.END_SESSION: True}
104
+ with suppress(wire.PeerDisconnected):
105
+ self.connection.send(serialization.serialise(message))
106
+ assert serialization.deserialise(self.connection.recv(timeout=5)) == message
107
+ self.connection.close()
108
+
109
+
110
+ @dataclass
111
+ class Running:
112
+ probe: Probe
113
+ server: ModelServer
114
+ transport: server_wire.Wire
115
+ client_wire: wire.ClientWire
116
+ ready: threading.Event
117
+ thread: threading.Thread
118
+ errors: list[BaseException]
119
+ connections: list[wire.ClientConnection] = field(default_factory=list)
120
+
121
+ def connect(self, query: str = '', headers: dict[str, str] | None = None) -> wire.ClientConnection:
122
+ address = self.transport.served_address
123
+ assert isinstance(address, server_wire.ServedHostPort)
124
+ connection = self.client_wire.dial(
125
+ wire.HostPortAddress(address.host, address.port, wire.SESSION_PATH, query), headers=headers, open_timeout=5
126
+ )
127
+ self.connections.append(connection)
128
+ return connection
129
+
130
+ def session(self, query: str = '', headers: dict[str, str] | None = None) -> Peer:
131
+ connection = self.connect(query, headers)
132
+ while True:
133
+ message = serialization.deserialise(connection.recv(timeout=5))
134
+ if protocol.ERROR in message:
135
+ raise RuntimeError(message[protocol.ERROR])
136
+ if message[protocol.STATUS] == protocol.ServerStatus.READY:
137
+ assert message[protocol.PROTOCOL_VERSION] == protocol.ProtocolVersion.V3
138
+ return Peer(connection, message[protocol.SESSION_ID], message[protocol.META])
139
+ assert message[protocol.STATUS] == protocol.ServerStatus.WAITING
140
+
141
+
142
+ @pytest.fixture(params=TRANSPORTS)
143
+ def transport_types(request):
144
+ return request.param
145
+
146
+
147
+ @pytest.fixture
148
+ def start(transport_types):
149
+ servers: list[Running] = []
150
+
151
+ def launch(probe: Probe | None = None, *, wait: bool = True, **kwargs) -> Running:
152
+ probe = Probe() if probe is None else probe
153
+ transport_type, client_type = transport_types
154
+ transport = transport_type(server_wire.ServedHostPort('127.0.0.1', 0))
155
+ server = ModelServer(probe.load, **kwargs)
156
+ server.WAITING_INTERVAL_SEC = 0.02
157
+ ready = threading.Event()
158
+ errors: list[BaseException] = []
159
+
160
+ def run():
161
+ try:
162
+ server.serve([transport], ready.set)
163
+ except BaseException as error:
164
+ errors.append(error)
165
+
166
+ thread = threading.Thread(target=run, daemon=True)
167
+ running = Running(probe, server, transport, client_type(), ready, thread, errors)
168
+ servers.append(running)
169
+ thread.start()
170
+ if wait:
171
+ assert ready.wait(5), errors
172
+ return running
173
+
174
+ yield launch
175
+ for running in servers:
176
+ for gate in (running.probe.load_release, running.probe.prepare_release, running.probe.infer_release):
177
+ gate.set()
178
+ for connection in running.connections:
179
+ connection.close()
180
+ running.server.shutdown()
181
+ running.thread.join(10)
182
+ assert not running.thread.is_alive(), 'Server failed to stop'
183
+ assert not running.errors
184
+
185
+
186
+ def test_sessions_keep_parameters_history_and_native_results(start):
187
+ running = start()
188
+ first = running.session('scale=3')
189
+ second = running.session()
190
+ assert first.metadata[keys.SESSION_PARAMS] == {'scale': 3}
191
+ assert first.metadata[keys.EFFECTIVE_PARAMS] == {'scale': 3}
192
+ assert second.metadata[keys.SESSION_PARAMS] == {}
193
+ assert second.metadata[keys.EFFECTIVE_PARAMS] == {'scale': 1}
194
+ assert first.session_id != second.session_id
195
+ native = {'value': 2, 'robot_command': {'vendor': 'opaque'}, b'__cmd__': 'opaque', 'array': np.arange(6)}
196
+ answer = first.infer(native)
197
+ assert answer[protocol.RESULT]['scaled'] == 6
198
+ np.testing.assert_array_equal(answer[protocol.RESULT]['native']['array'], native['array'])
199
+ assert answer[protocol.RESULT]['native'][b'__cmd__'] == 'opaque'
200
+ assert first.infer({})[protocol.RESULT]['count'] == 2
201
+ assert second.infer({})[protocol.RESULT]['count'] == 1
202
+ first.close()
203
+ second.close()
204
+ running.server.shutdown()
205
+ running.thread.join(5)
206
+ operations, processes, threads = zip(*running.probe.calls, strict=True)
207
+ assert operations.count('load') == operations.count('close_model') == 1
208
+ assert operations.count('close_session') == 2
209
+ assert set(processes) == {os.getpid()}
210
+ assert len(set(threads)) == 1
211
+
212
+
213
+ @pytest.mark.parametrize('query', ['unknown=1', 'scale=1&scale=2', 'scale=NaN', 'scale="wrong"'])
214
+ def test_invalid_session_parameters_are_refused(start, query):
215
+ running = start()
216
+ with pytest.raises(RuntimeError):
217
+ running.session(query)
218
+ assert running.session().infer({})[protocol.RESULT]['scaled'] == 1
219
+
220
+
221
+ def test_inference_error_keeps_the_session_usable(start):
222
+ peer = start().session()
223
+ assert 'model rejected' in peer.infer({'fail': True})[protocol.ERROR]
224
+ assert peer.infer({})[protocol.RESULT]['count'] == 1
225
+ peer.close()
226
+
227
+
228
+ def test_a_wrong_session_id_closes_only_the_requesting_session(start):
229
+ running = start()
230
+ first, second = running.session(), running.session()
231
+ with suppress(wire.PeerDisconnected):
232
+ first.connection.send(
233
+ serialization.serialise({protocol.SESSION_ID: second.session_id, protocol.OBSERVATION: {}})
234
+ )
235
+ message = serialization.deserialise(first.connection.recv(timeout=5))
236
+ assert message[protocol.STATUS] == protocol.ServerStatus.ERROR
237
+ assert 'session ID' in message[protocol.ERROR]
238
+ assert second.infer({})[protocol.RESULT]['count'] == 1
239
+ second.close()
240
+
241
+
242
+ def test_shared_warmup_precedes_binding_and_shutdown_is_not_lost(start):
243
+ probe = Probe()
244
+ probe.load_release.clear()
245
+ running = start(probe, wait=False)
246
+ assert probe.loading.wait(5)
247
+ assert not running.ready.is_set()
248
+ with pytest.raises(AssertionError, match='not started'):
249
+ _ = running.transport.served_address
250
+ running.server.shutdown()
251
+ probe.load_release.set()
252
+ running.thread.join(5)
253
+ assert not running.thread.is_alive()
254
+ assert [name for name, *_ in probe.calls] == ['load', 'close_model']
255
+
256
+
257
+ def test_shutdown_before_the_event_loop_starts_is_not_lost(start, monkeypatch):
258
+ entered = threading.Event()
259
+ release = threading.Event()
260
+ run = asyncio.run
261
+
262
+ def delayed_run(coro):
263
+ entered.set()
264
+ assert release.wait(5)
265
+ return run(coro)
266
+
267
+ monkeypatch.setattr(asyncio, 'run', delayed_run)
268
+ running = start(wait=False)
269
+ try:
270
+ assert entered.wait(5)
271
+ running.server.shutdown()
272
+ finally:
273
+ release.set()
274
+ running.thread.join(5)
275
+
276
+ assert not running.thread.is_alive()
277
+ assert not running.ready.is_set()
278
+ assert [name for name, *_ in running.probe.calls] == ['load', 'close_model']
279
+
280
+
281
+ def test_a_server_can_serve_again_after_shutdown(transport_types):
282
+ probe = Probe()
283
+ server = ModelServer(probe.load)
284
+ transport_type, _ = transport_types
285
+
286
+ def ready():
287
+ probe.record('ready')
288
+ server.shutdown()
289
+ server.shutdown()
290
+
291
+ for _ in range(2):
292
+ server.serve([transport_type(server_wire.ServedHostPort('127.0.0.1', 0))], ready)
293
+
294
+ assert [name for name, *_ in probe.calls] == ['load', 'ready', 'close_model'] * 2
295
+
296
+
297
+ def test_partial_listener_startup_releases_all_ports_and_model(transport_types, monkeypatch):
298
+ transport_type, _ = transport_types
299
+ listeners = [transport_type(server_wire.ServedHostPort('127.0.0.1', 0)) for _ in range(2)]
300
+ start = listeners[1].start
301
+
302
+ async def failing_start(*args):
303
+ await start(*args)
304
+ raise RuntimeError('listener startup failed')
305
+
306
+ monkeypatch.setattr(listeners[1], 'start', failing_start)
307
+ probe = Probe()
308
+ with pytest.raises(RuntimeError, match='listener startup failed'):
309
+ ModelServer(probe.load).serve(listeners)
310
+ assert [name for name, *_ in probe.calls] == ['load', 'close_model']
311
+ for listener in listeners:
312
+ address = listener.served_address
313
+ with socket.socket() as reclaimed:
314
+ reclaimed.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
315
+ reclaimed.bind((address.host, address.port))
316
+
317
+
318
+ def test_waiting_keeps_preparation_alive_and_keepalive_answers(start):
319
+ probe = Probe()
320
+ probe.prepare_release.clear()
321
+ running = start(probe, idle_timeout_min=1)
322
+ connection = running.connect()
323
+ for _ in range(3):
324
+ assert serialization.deserialise(connection.recv(timeout=2))[protocol.STATUS] == protocol.ServerStatus.WAITING
325
+ assert probe.preparing.wait(5)
326
+ address = running.transport.served_address
327
+ assert isinstance(address, server_wire.ServedHostPort)
328
+ assert (
329
+ running.client_wire.keepalive(
330
+ wire.HostPortAddress(address.host, address.port, wire.SESSION_PATH, ''), headers=None, timeout=5
331
+ )
332
+ == 60
333
+ )
334
+ probe.prepare_release.set()
335
+ while True:
336
+ message = serialization.deserialise(connection.recv(timeout=5))
337
+ if message[protocol.STATUS] == protocol.ServerStatus.READY:
338
+ break
339
+ peer = Peer(connection, message[protocol.SESSION_ID], message[protocol.META])
340
+ assert peer.infer({})[protocol.RESULT]['count'] == 1
341
+ peer.close()
342
+
343
+
344
+ def test_shutdown_waits_for_inference_before_session_and_model_cleanup(start):
345
+ probe = Probe()
346
+ running = start(probe)
347
+ peer = running.session()
348
+ probe.infer_release.clear()
349
+ peer.connection.send(serialization.serialise({protocol.SESSION_ID: peer.session_id, protocol.OBSERVATION: {}}))
350
+ assert probe.inferring.wait(5)
351
+ running.server.shutdown()
352
+ assert not probe.cleaned.wait(0.05)
353
+ probe.infer_release.set()
354
+ running.thread.join(10)
355
+ assert not running.thread.is_alive()
356
+ assert [name for name, *_ in probe.calls][-3:] == ['infer', 'close_session', 'close_model']
357
+
358
+
359
+ def test_disconnect_during_preparation_releases_the_prepared_session(start):
360
+ probe = Probe()
361
+ probe.prepare_release.clear()
362
+ running = start(probe)
363
+ connection = running.connect()
364
+ assert probe.preparing.wait(5)
365
+ connection.close()
366
+ probe.prepare_release.set()
367
+ assert probe.cleaned.wait(5)
368
+
369
+
370
+ def test_sessions_serialize_inference_and_keep_independent_state(start):
371
+ running = start()
372
+ peers = [running.session(f'scale={value}') for value in range(1, 5)]
373
+ with ThreadPoolExecutor(max_workers=4) as clients:
374
+ answers = list(clients.map(lambda peer: peer.infer({}), peers))
375
+ assert [answer[protocol.RESULT]['scaled'] for answer in answers] == [1, 2, 3, 4]
376
+ assert all(answer[protocol.RESULT]['count'] == 1 for answer in answers)
377
+ assert len({thread for _, _, thread in running.probe.calls}) == 1
378
+
379
+
380
+ def test_result_encoding_keeps_model_buffers_exclusive_and_keepalive_available(start, monkeypatch):
381
+ running = start()
382
+ first, second = running.session(), running.session()
383
+ encoding = threading.Event()
384
+ release = running.probe.infer_release
385
+
386
+ def encode(value, encodings):
387
+ release.clear()
388
+ encoding.set()
389
+ assert release.wait(10)
390
+ return value
391
+
392
+ monkeypatch.setattr(serialization, 'encode_images', encode)
393
+ first.connection.send(serialization.serialise({protocol.SESSION_ID: first.session_id, protocol.OBSERVATION: {}}))
394
+ assert encoding.wait(5)
395
+ second.connection.send(serialization.serialise({protocol.SESSION_ID: second.session_id, protocol.OBSERVATION: {}}))
396
+ address = running.transport.served_address
397
+ assert isinstance(address, server_wire.ServedHostPort)
398
+ running.client_wire.keepalive(
399
+ wire.HostPortAddress(address.host, address.port, wire.SESSION_PATH, ''), headers=None, timeout=5
400
+ )
401
+ assert [name for name, *_ in running.probe.calls].count('infer') == 1
402
+ monkeypatch.undo()
403
+ release.set()
404
+ for peer in (first, second):
405
+ assert protocol.RESULT in serialization.deserialise(peer.connection.recv(timeout=5))
406
+ peer.close()
407
+
408
+
409
+ def test_authentication_gates_sessions(start):
410
+ running = start(auth_token='secret')
411
+ with pytest.raises(wire.ConnectRefused):
412
+ running.session()
413
+ address = running.transport.served_address
414
+ assert isinstance(address, server_wire.ServedHostPort)
415
+ endpoint = wire.HostPortAddress(address.host, address.port, wire.SESSION_PATH, '')
416
+ with pytest.raises(wire.ConnectRefused):
417
+ running.client_wire.keepalive(endpoint, headers=None, timeout=5)
418
+ assert (
419
+ running.client_wire.keepalive(endpoint, headers={protocol.AUTH_HEADER: protocol.bearer('secret')}, timeout=5)
420
+ is None
421
+ )
422
+ peer = running.session(headers={protocol.AUTH_HEADER: protocol.bearer('secret')})
423
+ assert peer.infer({})[protocol.RESULT]['count'] == 1
424
+ peer.close()
425
+
426
+
427
+ @pytest.mark.parametrize(
428
+ 'path,method,token,status',
429
+ [
430
+ (wire.KEEPALIVE_PATH, 'POST', 'secret', HTTPStatus.OK),
431
+ (wire.KEEPALIVE_PATH, 'POST', 'wrong', HTTPStatus.UNAUTHORIZED),
432
+ (wire.KEEPALIVE_PATH, 'GET', 'secret', HTTPStatus.METHOD_NOT_ALLOWED),
433
+ ('/missing', 'POST', 'secret', HTTPStatus.NOT_FOUND),
434
+ ],
435
+ )
436
+ def test_http_routes_preserve_status_and_do_not_prepare_sessions(start, transport_types, path, method, token, status):
437
+ _, client_type = transport_types
438
+ if client_type.NAME != 'websocket':
439
+ pytest.skip('HTTP routes belong to the WebSocket transport')
440
+ running = start(auth_token='secret')
441
+ address = running.transport.served_address
442
+ assert isinstance(address, server_wire.ServedHostPort)
443
+ with closing(HTTPConnection(address.host, address.port, timeout=5)) as connection:
444
+ connection.request(method, path, headers={protocol.AUTH_HEADER: protocol.bearer(token)})
445
+ response = connection.getresponse()
446
+ assert response.status == status
447
+ if status == HTTPStatus.METHOD_NOT_ALLOWED:
448
+ assert response.getheader('Allow') == 'POST'
449
+ if status == HTTPStatus.OK:
450
+ assert response.getheader('Content-Type') == 'application/json'
451
+ assert [name for name, *_ in running.probe.calls] == ['load']
452
+ endpoint = wire.HostPortAddress(address.host, address.port, wire.SESSION_PATH, '')
453
+ assert running.client_wire.probe(endpoint, headers=None, open_timeout=5) is None
454
+
455
+
456
+ def test_idle_expiry_closes_the_model(start):
457
+ running = start(idle_timeout_min=0.05 / 60)
458
+ running.thread.join(5)
459
+ assert not running.thread.is_alive()
460
+ assert [name for name, *_ in running.probe.calls] == ['load', 'close_model']
461
+
462
+
463
+ def test_shutdown_releases_the_listening_port(start):
464
+ running = start()
465
+ address = running.transport.served_address
466
+ assert isinstance(address, server_wire.ServedHostPort)
467
+ running.server.shutdown()
468
+ running.thread.join(5)
469
+ assert not running.thread.is_alive()
470
+ with socket.socket() as listener:
471
+ listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
472
+ listener.bind((address.host, address.port))