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,166 @@
1
+ """The server side of the gRPC wire: one bidirectional stream per session, which carries the ``protocol`` frames,
2
+ and the unary keepalive call beside it."""
3
+
4
+ import json
5
+ import logging
6
+ from collections.abc import AsyncIterator, Mapping
7
+ from urllib.parse import parse_qsl
8
+
9
+ import grpc
10
+ import grpc.aio
11
+ from positronic_wire import wire
12
+ from positronic_wire.grpc import (
13
+ KEEPALIVE_METHOD,
14
+ MESSAGE_SIZE_OPTIONS,
15
+ METHOD,
16
+ PING_EVERY_MS,
17
+ SERVICE,
18
+ SESSION_PATH_HEADER,
19
+ SESSION_QUERY_HEADER,
20
+ target,
21
+ )
22
+
23
+ from . import server_wire
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class GrpcServerConnection(server_wire.ServerConnection):
29
+ """A server's end of one gRPC session."""
30
+
31
+ def __init__(
32
+ self,
33
+ requests: AsyncIterator[bytes],
34
+ context: grpc.aio.ServicerContext,
35
+ headers: Mapping[str, str],
36
+ served_address: server_wire.ServedHostPort,
37
+ ):
38
+ self._requests = requests
39
+ self._context = context
40
+ self._headers = headers
41
+ self._served_address = served_address
42
+
43
+ @property
44
+ def peer(self) -> str:
45
+ return self._context.peer()
46
+
47
+ @property
48
+ def served_address(self) -> server_wire.ServedHostPort:
49
+ return self._served_address
50
+
51
+ @property
52
+ def session_path(self) -> str:
53
+ return self._headers.get(SESSION_PATH_HEADER, wire.SESSION_PATH)
54
+
55
+ @property
56
+ def query_params(self) -> list[tuple[str, str]]:
57
+ return parse_qsl(self._headers.get(SESSION_QUERY_HEADER, ''), keep_blank_values=True)
58
+
59
+ async def send(self, message: bytes) -> None:
60
+ try:
61
+ await self._context.write(message)
62
+ except grpc.RpcError as e:
63
+ raise wire.PeerDisconnected(f'{self.peer} ended the session: {e}') from e
64
+
65
+ async def receive(self) -> bytes:
66
+ try:
67
+ return await anext(self._requests)
68
+ except StopAsyncIteration:
69
+ raise wire.PeerDisconnected(f'{self.peer} ended the session') from None
70
+
71
+ async def refuse(self, reason: str) -> None:
72
+ self._context.set_code(grpc.StatusCode.ABORTED)
73
+ self._context.set_details(reason)
74
+
75
+
76
+ def _headers(context: grpc.aio.ServicerContext) -> dict[str, str]:
77
+ """The session metadata, as the header names both wires share. A ``-bin`` key carries no header."""
78
+ return {key: value for key, value in (context.invocation_metadata() or ()) if isinstance(value, str)}
79
+
80
+
81
+ # The shortest ping interval the server answers without a strike: half of `PING_EVERY_MS`, so a client
82
+ # ping that arrives early is never one.
83
+ _PING_TOLERATED_EVERY_MS = PING_EVERY_MS // 2
84
+
85
+
86
+ def _server_options() -> list[tuple[str, int]]:
87
+ return [
88
+ *MESSAGE_SIZE_OPTIONS,
89
+ # gRPC's own defaults, a five-minute floor and two strikes, answer a 20s ping with GOAWAY.
90
+ ('grpc.http2.min_ping_interval_without_data_ms', _PING_TOLERATED_EVERY_MS),
91
+ ('grpc.http2.max_ping_strikes', 0),
92
+ ]
93
+
94
+
95
+ class GrpcWire(server_wire.Wire):
96
+ """The gRPC wire: sessions on a port of their own, one bidirectional stream each, and the keepalive call.
97
+
98
+ ``served_address`` is the host and the port it binds; a port of 0 binds any free one. The port is
99
+ plaintext, and a TLS edge in front of it serves an authenticated endpoint.
100
+ """
101
+
102
+ def __init__(self, served_address: server_wire.ServedHostPort):
103
+ self._binds = served_address
104
+ self._server: grpc.aio.Server | None = None
105
+ self._served_address: server_wire.ServedHostPort | None = None
106
+
107
+ @property
108
+ def served_address(self) -> server_wire.ServedHostPort:
109
+ assert self._served_address is not None, 'The gRPC wire has not started'
110
+ return self._served_address
111
+
112
+ async def start(
113
+ self,
114
+ session: server_wire.SessionHandler,
115
+ keepalive: server_wire.KeepaliveHandler,
116
+ authorized: server_wire.Authorized,
117
+ ) -> None:
118
+ """Bind the gRPC port, which carries sessions and the keepalive call."""
119
+
120
+ async def serve_one(requests: AsyncIterator[bytes], context: grpc.aio.ServicerContext) -> None:
121
+ headers = _headers(context)
122
+ if not authorized(headers):
123
+ await context.abort(grpc.StatusCode.PERMISSION_DENIED, 'Invalid or missing bearer token')
124
+ conn = GrpcServerConnection(requests, context, headers, self.served_address)
125
+ if conn.session_path != wire.SESSION_PATH:
126
+ refusal = f'No session on {conn.session_path!r}; sessions open on {wire.SESSION_PATH}'
127
+ await context.abort(grpc.StatusCode.NOT_FOUND, refusal)
128
+ try:
129
+ await session(conn)
130
+ except Exception as e:
131
+ # The session reports its own errors over the stream. One that reaches here reaches the client
132
+ # as the status alone.
133
+ logger.error(f'Failed gRPC session: {e}', exc_info=True)
134
+ await context.abort(grpc.StatusCode.INTERNAL, str(e))
135
+
136
+ async def answer_keepalive(_request: bytes, context: grpc.aio.ServicerContext) -> bytes:
137
+ if not authorized(_headers(context)):
138
+ await context.abort(grpc.StatusCode.PERMISSION_DENIED, 'Invalid or missing bearer token')
139
+ return json.dumps({wire.ALIVE_SECONDS: keepalive()}).encode()
140
+
141
+ methods = {
142
+ METHOD: grpc.stream_stream_rpc_method_handler(
143
+ serve_one, request_deserializer=None, response_serializer=None
144
+ ),
145
+ KEEPALIVE_METHOD: grpc.unary_unary_rpc_method_handler(
146
+ answer_keepalive, request_deserializer=None, response_serializer=None
147
+ ),
148
+ }
149
+ server = grpc.aio.server(options=_server_options())
150
+ server.add_generic_rpc_handlers((grpc.method_handlers_generic_handler(SERVICE, methods),))
151
+ bound = server.add_insecure_port(target(self._binds.host, self._binds.port))
152
+ if bound == 0:
153
+ # gRPC reports a refused bind as port 0, and a server started on it accepts nothing and says nothing.
154
+ raise OSError(f'gRPC could not bind {target(self._binds.host, self._binds.port)}')
155
+ self._server = server
156
+ self._served_address = server_wire.ServedHostPort(self._binds.host, bound)
157
+ await server.start()
158
+ logger.info(f'gRPC sessions on {self._binds.host}:{bound}')
159
+
160
+ async def serve(self) -> None:
161
+ assert self._server is not None, 'The gRPC wire has not started'
162
+ await self._server.wait_for_termination()
163
+
164
+ async def stop(self) -> None:
165
+ if self._server is not None:
166
+ await self._server.stop(grace=None)
@@ -0,0 +1,10 @@
1
+ """Metadata fields written by the model-server wrapper."""
2
+
3
+ HOST = 'host'
4
+ PORT = 'port'
5
+ # The socket path a server bound instead of a host and a port. One of the two pairs is present, never both.
6
+ UDS = 'uds'
7
+ LOCAL_STACK = 'local_stack'
8
+ MODEL_SERVER_VERSION = 'model_server_version'
9
+ SESSION_PARAMS = 'session_params'
10
+ EFFECTIVE_PARAMS = 'effective_params'
@@ -0,0 +1,60 @@
1
+ """Shared inference message fields, versions, authentication and timing."""
2
+
3
+ from enum import IntEnum, StrEnum
4
+
5
+ AUTH_HEADER = 'Authorization'
6
+
7
+
8
+ def bearer(token: str) -> str:
9
+ """The authorization header value for a bearer token."""
10
+ return f'Bearer {token}'
11
+
12
+
13
+ # The top-level keys of every server-to-client message: ``STATUS`` until the server reports itself ready
14
+ # and hands over its ``META``, then one ``RESULT`` or ``ERROR`` per inference.
15
+ STATUS = 'status'
16
+ META = 'meta'
17
+ RESULT = 'result'
18
+ ERROR = 'error'
19
+ # The ready handshake issues an ID. Requests carry it beside the observation, and an end request
20
+ # is acknowledged with the same ID after the model releases the session's state.
21
+ SESSION_ID = 'session_id'
22
+ OBSERVATION = 'observation'
23
+ END_SESSION = 'end_session'
24
+ PROTOCOL_VERSION = 'protocol_version'
25
+
26
+
27
+ class ProtocolVersion(IntEnum):
28
+ V1 = 1
29
+ V2 = 2
30
+ V3 = 3
31
+
32
+
33
+ # What the server spent on one inference, beside the ``RESULT`` it answers with: durations in
34
+ # milliseconds on the server's own clock. A server that sends none leaves the round trip undivided.
35
+ TIMING = 'timing'
36
+
37
+ # The phases ``TIMING`` reports. `SERVED` brackets the others: it opens on the observation
38
+ # arriving and closes before the answer is encoded.
39
+ TIMING_SERVED = 'served_ms'
40
+ TIMING_DECODE = 'decode_ms'
41
+ TIMING_INFER = 'infer_ms'
42
+ # Time the observation waited for the inference slot, inside `SERVED`.
43
+ TIMING_QUEUED = 'queued_ms'
44
+
45
+
46
+ def timing_key(name: str) -> str:
47
+ return f'{name}_ms'
48
+
49
+
50
+ # The loaded model's call, excluding codec conversions.
51
+ MODEL_CALL = 'model'
52
+ # Time the model's own call took, inside `INFER`.
53
+ TIMING_MODEL = timing_key(MODEL_CALL)
54
+
55
+
56
+ class ServerStatus(StrEnum):
57
+ READY = 'ready'
58
+ WAITING = 'waiting'
59
+ LOADING = 'loading'
60
+ ERROR = 'error'
File without changes
@@ -0,0 +1,144 @@
1
+ """Wire serialization helpers for numpy arrays, images and standard Python types.
2
+
3
+ Supports:
4
+ - built-in scalars: `str`, `int`, `float`, `bool`, `None`
5
+ - containers: `dict` / `list` / `tuple` recursively composed of supported values
6
+ - numeric numpy values: `numpy.ndarray` and `numpy` scalar types
7
+ - images JPEG-encoded with ``encode_jpeg``
8
+
9
+ Nothing here knows a domain type. A boundary that carries one writes its own msgpack hooks and
10
+ delegates to ``pack`` / ``unpack`` for the rest.
11
+ """
12
+
13
+ import collections.abc as cabc
14
+ import functools
15
+ import io
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ import msgpack
20
+ import numpy as np
21
+ from PIL import Image as PilImage
22
+
23
+ # The envelope each encoded value travels in: a marker naming the type, and the fields carrying it.
24
+ _NDARRAY = b'__ndarray__'
25
+ _NPGENERIC = b'__npgeneric__'
26
+ _JPEG = b'__jpeg__'
27
+ _DATA = b'data'
28
+ _DTYPE = b'dtype'
29
+ _SHAPE = b'shape'
30
+ _FRAMES = b'frames'
31
+ _NDIM = b'ndim'
32
+
33
+ DEFAULT_JPEG_QUALITY = 90
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class JpegEncoding:
38
+ """An explicit mapping/list path to an RGB image; an empty path selects the whole value."""
39
+
40
+ path: tuple[str | int, ...]
41
+ quality: int = DEFAULT_JPEG_QUALITY
42
+
43
+ def __post_init__(self) -> None:
44
+ if any(type(part) not in (str, int) for part in self.path):
45
+ raise ValueError('Image paths contain only mapping keys and list indices')
46
+ if type(self.quality) is not int or not 0 <= self.quality <= 100:
47
+ raise ValueError('JPEG quality must be an integer between 0 and 100')
48
+
49
+ def encode(self, value: Any) -> Any:
50
+ """Copy the selected container path and encode its leaf, preserving unrelated values."""
51
+
52
+ def at(node: Any, path: tuple[str | int, ...]) -> Any:
53
+ if not path:
54
+ if not isinstance(node, np.ndarray):
55
+ raise ValueError(f'JPEG path {self.path!r} does not select an array')
56
+ return encode_jpeg(node, self.quality)
57
+ key, *rest = path
58
+ if isinstance(node, cabc.Mapping):
59
+ return {**node, key: at(node[key], tuple(rest))}
60
+ if isinstance(node, list | tuple) and type(key) is int:
61
+ copy = list(node)
62
+ copy[key] = at(node[key], tuple(rest))
63
+ return tuple(copy) if isinstance(node, tuple) else copy
64
+ raise ValueError(f'JPEG path {self.path!r} does not match the value')
65
+
66
+ return at(value, self.path)
67
+
68
+
69
+ def encode_images(value: Any, encodings: cabc.Sequence[JpegEncoding]) -> Any:
70
+ """Encode only the explicitly selected images, in observations or model results."""
71
+ for encoding in encodings:
72
+ value = encoding.encode(value)
73
+ return value
74
+
75
+
76
+ def encode_jpeg(image: np.ndarray, quality: int = DEFAULT_JPEG_QUALITY) -> dict[bytes, Any]:
77
+ """JPEG-encode RGB images shaped ``(..., H, W, 3)``, casting pixels to uint8.
78
+
79
+ Nonempty 3D/4D inputs use the v1/v2 marker. Extra leading dimensions and empty batches carry
80
+ their full shape. JPEG changes pixels; ordinary arrays passed to ``serialise`` stay lossless.
81
+ """
82
+ if image.ndim < 3 or image.shape[-1] != 3 or min(image.shape[-3:-1]) < 1:
83
+ raise ValueError('JPEG images must have shape (..., H, W, 3) with positive height and width')
84
+ if type(quality) is not int or not 0 <= quality <= 100:
85
+ raise ValueError('JPEG quality must be an integer between 0 and 100')
86
+ frames = image.reshape((-1, *image.shape[-3:]))
87
+ bufs = []
88
+ for frame in frames:
89
+ buf = io.BytesIO()
90
+ PilImage.fromarray(np.ascontiguousarray(frame, dtype=np.uint8)).save(buf, format='JPEG', quality=quality)
91
+ bufs.append(buf.getvalue())
92
+ if image.ndim > 4 or not bufs:
93
+ return {_JPEG: True, _FRAMES: bufs, _SHAPE: image.shape}
94
+ return {_JPEG: True, _FRAMES: bufs, _NDIM: int(image.ndim)}
95
+
96
+
97
+ def _decode_jpeg(marker: dict) -> np.ndarray:
98
+ """Inverse of ``encode_jpeg``: decode per-frame JPEGs and restore the original shape."""
99
+ if _SHAPE in marker and not marker[_FRAMES]:
100
+ shape = tuple(marker[_SHAPE])
101
+ if len(shape) < 4 or shape[-1] != 3 or min(shape[-3:-1]) < 1 or 0 not in shape[:-3]:
102
+ raise ValueError('An empty JPEG batch must have an empty leading dimension')
103
+ return np.empty(shape, dtype=np.uint8)
104
+ frames = np.stack([np.asarray(PilImage.open(io.BytesIO(buf))) for buf in marker[_FRAMES]])
105
+ if _SHAPE in marker:
106
+ return frames.reshape(marker[_SHAPE])
107
+ return frames if marker[_NDIM] == 4 else frames[0]
108
+
109
+
110
+ def pack(obj):
111
+ """msgpack's ``default`` hook: one value in its wire form, or unchanged when msgpack handles it."""
112
+ if isinstance(obj, cabc.Mapping):
113
+ return dict(obj)
114
+ if isinstance(obj, np.ndarray | np.generic) and obj.dtype.kind in ('V', 'O', 'c'):
115
+ raise ValueError(f'Unsupported dtype: {obj.dtype}')
116
+ if isinstance(obj, np.ndarray):
117
+ return {_NDARRAY: True, _DATA: obj.tobytes(), _DTYPE: obj.dtype.str, _SHAPE: obj.shape}
118
+ if isinstance(obj, np.generic):
119
+ return {_NPGENERIC: True, _DATA: obj.item(), _DTYPE: obj.dtype.str}
120
+ return obj
121
+
122
+
123
+ def unpack(obj):
124
+ """msgpack's ``object_hook``: one decoded mapping restored to the value it encodes."""
125
+ if _NDARRAY in obj:
126
+ return np.ndarray(buffer=obj[_DATA], dtype=np.dtype(obj[_DTYPE]), shape=obj[_SHAPE])
127
+ if _NPGENERIC in obj:
128
+ return np.dtype(obj[_DTYPE]).type(obj[_DATA])
129
+ if _JPEG in obj:
130
+ return _decode_jpeg(obj)
131
+ return obj
132
+
133
+
134
+ def serialise(obj: Any) -> bytes:
135
+ packed = msgpack.packb(obj, default=pack)
136
+ assert packed is not None
137
+ return packed
138
+
139
+
140
+ deserialise = functools.partial(msgpack.unpackb, object_hook=unpack)
141
+
142
+ # Aliases for consistency
143
+ serialize = serialise
144
+ deserialize = deserialise
@@ -0,0 +1,309 @@
1
+ """Serve a warmed native model and its sessions in one process."""
2
+
3
+ import asyncio
4
+ import hmac
5
+ import json
6
+ import logging
7
+ import math
8
+ import time
9
+ from abc import ABC, abstractmethod
10
+ from collections.abc import Callable, Mapping, Sequence
11
+ from concurrent.futures import Future, ThreadPoolExecutor
12
+ from contextlib import asynccontextmanager
13
+ from importlib.metadata import version
14
+ from typing import Any, TypeVar
15
+ from uuid import uuid4
16
+
17
+ from positronic_wire import wire
18
+
19
+ from . import keys, protocol, serialization, server_wire, spec
20
+
21
+ logger = logging.getLogger(__name__)
22
+ T = TypeVar('T')
23
+
24
+
25
+ class Session(ABC):
26
+ """A prepared, warm session that owns its state while sharing loaded weights.
27
+
28
+ ``close`` releases that state. ``output_images`` selects result values for JPEG serialization.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ client_stack: dict[str, Any],
34
+ *,
35
+ metadata: dict[str, Any] | None = None,
36
+ output_images: Sequence[serialization.JpegEncoding] = (),
37
+ ):
38
+ self.client_stack = client_stack
39
+ self.metadata = {} if metadata is None else metadata
40
+ self.output_images = output_images
41
+
42
+ @abstractmethod
43
+ def infer(self, observation: Any) -> Any: ...
44
+
45
+ def close(self) -> None:
46
+ """Release this session's resources after its operations finish."""
47
+ return None
48
+
49
+
50
+ class Model(ABC):
51
+ """Loaded, warm model resources and the operation that prepares each session.
52
+
53
+ ``parameters`` declares accepted session names and their defaults. ``prepare_session`` receives
54
+ resolved values, validates their model-specific meaning and returns only once that session is warm.
55
+ A preparation that raises must release any resources it acquired before returning a session.
56
+ """
57
+
58
+ def __init__(self, *, parameters: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None):
59
+ self.parameters = {} if parameters is None else parameters
60
+ self.metadata = {} if metadata is None else metadata
61
+
62
+ @abstractmethod
63
+ def prepare_session(self, params: dict[str, Any]) -> Session: ...
64
+
65
+ def close(self) -> None:
66
+ """Release the model's resources after its sessions finish."""
67
+ return None
68
+
69
+
70
+ class ModelServer:
71
+ """One model, serialized operations, and independently configured transport listeners.
72
+
73
+ Loading, session preparation, inference and cleanup all run on one worker thread. The network
74
+ loop remains available during these operations. The factory must finish shared warm-up before
75
+ returning its model; listeners bind afterwards. Each session must retain its own parameters and
76
+ history, and its warm-up must leave the session in its intended initial state.
77
+ """
78
+
79
+ WAITING_INTERVAL_SEC = 5.0
80
+
81
+ def __init__(
82
+ self, load_model: Callable[[], Model], *, idle_timeout_min: float | None = None, auth_token: str | None = None
83
+ ):
84
+ if auth_token is not None and not (auth_token and all('!' <= c <= '~' for c in auth_token)):
85
+ raise ValueError('auth_token must be non-empty printable ASCII without spaces; pass None to serve open')
86
+ if idle_timeout_min is not None and not 0 < idle_timeout_min < math.inf:
87
+ raise ValueError('idle_timeout_min must be positive and finite, or None')
88
+ self._load_model = load_model
89
+ self._auth_token = auth_token
90
+ self._idle_seconds = None if idle_timeout_min is None else idle_timeout_min * 60
91
+ self._model: Model | None = None
92
+ self._worker: ThreadPoolExecutor | None = None
93
+ self._stop: Future[None] = Future()
94
+ self._sessions: set[asyncio.Task] = set()
95
+ self._last_activity = time.monotonic()
96
+
97
+ def _authorized(self, headers: Mapping[str, str]) -> bool:
98
+ if self._auth_token is None:
99
+ return True
100
+ value = headers.get(protocol.AUTH_HEADER.lower(), '')
101
+ return hmac.compare_digest(value.encode(), protocol.bearer(self._auth_token).encode())
102
+
103
+ def _keepalive(self) -> int | None:
104
+ self._last_activity = time.monotonic()
105
+ return None if self._idle_seconds is None else math.floor(self._idle_seconds)
106
+
107
+ async def _call(self, function: Callable[[], T]) -> T:
108
+ """Keep an operation alive until it finishes, including while its caller is cancelled."""
109
+ assert self._worker is not None
110
+ pending = asyncio.get_running_loop().run_in_executor(self._worker, function)
111
+ try:
112
+ return await asyncio.shield(pending)
113
+ except asyncio.CancelledError:
114
+ await pending
115
+ raise
116
+
117
+ async def _answer(self, conn: server_wire.ServerConnection, session: Session, session_id: str) -> None:
118
+ while True:
119
+ raw = await conn.receive()
120
+ self._last_activity = time.monotonic()
121
+ started = time.perf_counter_ns()
122
+ request = serialization.deserialise(raw)
123
+ if request[protocol.SESSION_ID] != session_id:
124
+ raise ValueError('The session ID does not belong to this connection')
125
+ if request.get(protocol.END_SESSION) is True:
126
+ return
127
+ observation = request[protocol.OBSERVATION]
128
+ decoded = time.perf_counter_ns()
129
+ await conn.send(await self._infer_response(session, observation, started, decoded))
130
+
131
+ async def _infer_response(self, session: Session, observation: Any, started: int, decoded: int) -> bytes:
132
+ timing = {protocol.TIMING_DECODE: (decoded - started) / 1e6}
133
+
134
+ def infer() -> bytes:
135
+ called = time.perf_counter_ns()
136
+ timing[protocol.TIMING_QUEUED] = (called - decoded) / 1e6
137
+ try:
138
+ result = session.infer(observation)
139
+ finally:
140
+ timing[protocol.TIMING_MODEL] = timing[protocol.TIMING_INFER] = (time.perf_counter_ns() - called) / 1e6
141
+ timing[protocol.TIMING_SERVED] = (time.perf_counter_ns() - started) / 1e6
142
+ # Pack before releasing the worker: native results can reference reusable model buffers.
143
+ result = serialization.encode_images(result, session.output_images)
144
+ return serialization.serialise({protocol.RESULT: result, protocol.TIMING: timing})
145
+
146
+ try:
147
+ return await self._call(infer)
148
+ except Exception as error:
149
+ logger.exception('Inference failed')
150
+ return serialization.serialise({protocol.ERROR: str(error)})
151
+
152
+ async def _prepare(self, conn: server_wire.ServerConnection, prepare: Callable[[], None]) -> None:
153
+ pending = asyncio.create_task(self._call(prepare))
154
+ try:
155
+ while not pending.done():
156
+ await conn.send(serialization.serialise({protocol.STATUS: protocol.ServerStatus.WAITING}))
157
+ await asyncio.wait({pending}, timeout=self.WAITING_INTERVAL_SEC)
158
+ await pending
159
+ finally:
160
+ # A disconnected opener still owns the session being prepared until its cleanup completes.
161
+ await asyncio.shield(pending)
162
+
163
+ @asynccontextmanager
164
+ async def _prepared_session(self, conn: server_wire.ServerConnection, params: dict[str, Any]):
165
+ model = self._model
166
+ assert model is not None
167
+ session: Session | None = None
168
+
169
+ def prepare() -> None:
170
+ nonlocal session
171
+ session = model.prepare_session(json.loads(json.dumps(params, allow_nan=False)))
172
+ spec.validate(session.client_stack)
173
+
174
+ try:
175
+ await self._prepare(conn, prepare)
176
+ assert session is not None
177
+ yield session
178
+ finally:
179
+ if session is not None:
180
+ await self._call(session.close)
181
+
182
+ async def _exchange_session(self, conn: server_wire.ServerConnection) -> None:
183
+ model = self._model
184
+ assert model is not None
185
+ requested = spec.parse_params(conn.query_params)
186
+ params = spec.resolve_params(model.parameters, requested)
187
+ session_id = uuid4().hex
188
+ async with self._prepared_session(conn, params) as session:
189
+ metadata = {
190
+ **conn.served_address.meta,
191
+ **model.metadata,
192
+ **session.metadata,
193
+ keys.LOCAL_STACK: session.client_stack,
194
+ keys.SESSION_PARAMS: requested,
195
+ keys.EFFECTIVE_PARAMS: params,
196
+ keys.MODEL_SERVER_VERSION: version('positronic-model-server'),
197
+ }
198
+ await conn.send(
199
+ serialization.serialise({
200
+ protocol.STATUS: protocol.ServerStatus.READY,
201
+ protocol.PROTOCOL_VERSION: protocol.ProtocolVersion.V3,
202
+ protocol.SESSION_ID: session_id,
203
+ protocol.META: metadata,
204
+ })
205
+ )
206
+ await self._answer(conn, session, session_id)
207
+ await conn.send(serialization.serialise({protocol.SESSION_ID: session_id, protocol.END_SESSION: True}))
208
+
209
+ async def _serve_session(self, conn: server_wire.ServerConnection) -> None:
210
+ task = asyncio.current_task()
211
+ assert task is not None
212
+ self._sessions.add(task)
213
+ self._last_activity = time.monotonic()
214
+ try:
215
+ await self._exchange_session(conn)
216
+ except wire.PeerDisconnected:
217
+ logger.info('Session disconnected: %s', conn.peer)
218
+ except Exception as error:
219
+ logger.exception('Session failed: %s', conn.peer)
220
+ try:
221
+ await conn.send(
222
+ serialization.serialise({protocol.STATUS: protocol.ServerStatus.ERROR, protocol.ERROR: str(error)})
223
+ )
224
+ await conn.refuse(str(error))
225
+ except wire.PeerDisconnected:
226
+ logger.info('Session disconnected before receiving its error: %s', conn.peer)
227
+ finally:
228
+ self._sessions.discard(task)
229
+ self._last_activity = time.monotonic()
230
+
231
+ async def _idle_watchdog(self) -> None:
232
+ assert self._idle_seconds is not None
233
+ while True:
234
+ await asyncio.sleep(min(self._idle_seconds, 30))
235
+ if not self._sessions and time.monotonic() - self._last_activity >= self._idle_seconds:
236
+ return
237
+
238
+ def _load(self) -> None:
239
+ self._model = self._load_model()
240
+ spec.resolve_params(self._model.parameters, {})
241
+
242
+ async def _run(self, wires: Sequence[server_wire.Wire], on_ready: Callable[[], None] | None) -> None:
243
+ started: list[server_wire.Wire] = []
244
+ serving: list[asyncio.Task] = []
245
+ ending: list[asyncio.Future] = []
246
+
247
+ try:
248
+ await self._call(self._load)
249
+ if self._stop.cancelled():
250
+ return
251
+ for transport in wires:
252
+ started.append(transport)
253
+ await transport.start(self._serve_session, self._keepalive, self._authorized)
254
+ self._last_activity = time.monotonic()
255
+ serving = [asyncio.create_task(transport.serve()) for transport in started]
256
+ ending = [asyncio.wrap_future(self._stop)]
257
+ if self._idle_seconds is not None:
258
+ ending.append(asyncio.create_task(self._idle_watchdog()))
259
+ if on_ready is not None:
260
+ on_ready()
261
+ await asyncio.wait(serving + ending, return_when=asyncio.FIRST_COMPLETED)
262
+ finally:
263
+ for task in ending:
264
+ task.cancel()
265
+ await asyncio.gather(*ending, return_exceptions=True)
266
+ await self._finish(started, serving)
267
+
268
+ async def _finish(self, started: Sequence[server_wire.Wire], serving: Sequence[asyncio.Task]) -> None:
269
+ stops = await asyncio.gather(*(transport.stop() for transport in started), return_exceptions=True)
270
+ for stopped, task in zip(stops, serving, strict=False):
271
+ if isinstance(stopped, BaseException):
272
+ task.cancel()
273
+ outcomes = await asyncio.gather(*serving, return_exceptions=True)
274
+ for task in self._sessions:
275
+ if not task.cancelling():
276
+ task.cancel()
277
+ sessions = await asyncio.gather(*self._sessions, return_exceptions=True)
278
+ failures = [value for value in [*stops, *sessions, *outcomes] if isinstance(value, Exception)]
279
+ try:
280
+ if self._model is not None:
281
+ await self._call(self._model.close)
282
+ except Exception as error:
283
+ failures.append(error)
284
+ finally:
285
+ self._model = None
286
+ for error in failures[1:]:
287
+ logger.error('Additional shutdown failure', exc_info=error)
288
+ if failures:
289
+ raise failures[0]
290
+
291
+ def serve(self, wires: Sequence[server_wire.Wire], on_ready: Callable[[], None] | None = None) -> None:
292
+ """Load and warm once, then serve until shutdown, idle expiry, or a transport ends."""
293
+ if not wires:
294
+ raise ValueError('At least one transport is required')
295
+ if self._worker is not None:
296
+ raise RuntimeError('This server is already running')
297
+ with ThreadPoolExecutor(max_workers=1, thread_name_prefix='model') as worker:
298
+ self._worker = worker
299
+ try:
300
+ asyncio.run(self._run(wires, on_ready))
301
+ except KeyboardInterrupt:
302
+ logger.info('Server interrupted')
303
+ finally:
304
+ self._worker = None
305
+ self._stop = Future()
306
+
307
+ def shutdown(self) -> None:
308
+ """Request shutdown from any thread; running model work finishes before cleanup."""
309
+ self._stop.cancel()