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.
- positronic_model_server/grpc_wire.py +166 -0
- positronic_model_server/keys.py +10 -0
- positronic_model_server/protocol.py +60 -0
- positronic_model_server/py.typed +0 -0
- positronic_model_server/serialization.py +144 -0
- positronic_model_server/server.py +309 -0
- positronic_model_server/server_wire.py +106 -0
- positronic_model_server/spec.py +78 -0
- positronic_model_server/tests/test_model_server.py +472 -0
- positronic_model_server/tests/test_serialization.py +115 -0
- positronic_model_server/tests/test_spec.py +85 -0
- positronic_model_server/websocket_wire.py +337 -0
- positronic_model_server-0.2.2.dist-info/METADATA +214 -0
- positronic_model_server-0.2.2.dist-info/RECORD +15 -0
- positronic_model_server-0.2.2.dist-info/WHEEL +4 -0
|
@@ -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))
|