coakka-http-runtime-python 1.1.2__cp311-abi3-win_amd64.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.
- coakka_http/__init__.py +6 -0
- coakka_http/_native.py +192 -0
- coakka_http/_package.json +10 -0
- coakka_http/_projection.py +64 -0
- coakka_http/_service_cpu.py +63 -0
- coakka_http/native/_coakka_http_projection.pyd +0 -0
- coakka_http/native/coakka_http_host.dll +0 -0
- coakka_http/py.typed +0 -0
- coakka_http/runtime.py +555 -0
- coakka_http/service.py +789 -0
- coakka_http/values.py +250 -0
- coakka_http_runtime_python-1.1.2.dist-info/METADATA +63 -0
- coakka_http_runtime_python-1.1.2.dist-info/RECORD +19 -0
- coakka_http_runtime_python-1.1.2.dist-info/WHEEL +4 -0
- coakka_http_runtime_python-1.1.2.dist-info/assets/coakka-logo.png +0 -0
- coakka_http_runtime_python-1.1.2.dist-info/licenses/LICENSE +202 -0
- coakka_http_runtime_python-1.1.2.dist-info/licenses/NATIVE-LICENSE.md +138 -0
- coakka_http_runtime_python-1.1.2.dist-info/licenses/NOTICE +913 -0
- coakka_http_runtime_python-1.1.2.dist-info/licenses/PACKAGE-LICENSE.md +31 -0
coakka_http/service.py
ADDED
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
'Bounded request/reply and response-stream service over the Runtime ABI.'
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import io
|
|
4
|
+
import queue
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections import deque
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from dataclasses import dataclass,replace
|
|
11
|
+
from functools import partial
|
|
12
|
+
from typing import Final,Iterator,NoReturn,Self,TypeAlias
|
|
13
|
+
from.runtime import InspectionOptions,NativeError,Runtime,plan_service
|
|
14
|
+
from.import _projection as managed_projection
|
|
15
|
+
from._service_cpu import _ServiceCpuOwner
|
|
16
|
+
from.values import TIMEOUT_FOREVER,BodyDelivery,BodyPolicy,Capability,Compression,CpuPolicy,_EMPTY_HEADERS,DrainOutcome,DrainState,EventKind,ExchangeId,FileAuthority,FileResponse,Headers,Health,IoBackend,Limits,Listener,ListenerProtocol,MonitorApplyOutcome,TlsCredentials,TlsReloadOutcome,TlsReloadRequest,MonitorConfig,MonitorEventPage,MonitorOptions,MonitorPolicy,MonitorSnapshot,OutboundId,OutboundIdentity,OutboundRequest,OutboundTarget,OutboundTerminal,OutboundTrust,Request,Response,ResultCode,Route,RouteApiMetadata,RouteSnapshot,RouteRebind,RouteRebindOutcome,RoutePublication,RoutePublicationCode,RoutePublicationOutcome,RuntimeInfo,RuntimeConfig,ServicePlan,SseEvent,StaticMount,TransportSecurity,WebSocketEvent,WebSocketEventKind,WebSocketId
|
|
17
|
+
ResponseStreamProducer=Callable[['ResponseStreamWriter'],Headers]
|
|
18
|
+
ServerSentEventsProducer=Callable[['ServerSentEventsWriter'],None]
|
|
19
|
+
@dataclass(frozen=True,slots=True)
|
|
20
|
+
class StreamingResponse:
|
|
21
|
+
'Response head plus a bounded single-writer body producer.';producer:ResponseStreamProducer;status:int=200;headers:Headers=_EMPTY_HEADERS
|
|
22
|
+
def __post_init__(self)->None:
|
|
23
|
+
'Reject invalid stream declarations before Runtime sees the head.'
|
|
24
|
+
if not callable(self.producer):raise TypeError('response stream producer is not callable')
|
|
25
|
+
if isinstance(self.status,bool)or not 100<=self.status<=599:raise ValueError('response status is outside the HTTP range')
|
|
26
|
+
if not isinstance(self.headers,Headers):raise TypeError('response stream headers must be Headers')
|
|
27
|
+
@dataclass(frozen=True,slots=True)
|
|
28
|
+
class ServerSentEventsResponse:
|
|
29
|
+
'Runtime-defined SSE head plus a bounded typed event producer.';producer:ServerSentEventsProducer;headers:Headers=_EMPTY_HEADERS
|
|
30
|
+
def __post_init__(self)->None:
|
|
31
|
+
'Reject invalid SSE declarations before Runtime sees the head.'
|
|
32
|
+
if not callable(self.producer):raise TypeError('SSE producer is not callable')
|
|
33
|
+
if not isinstance(self.headers,Headers):raise TypeError('SSE headers must be Headers')
|
|
34
|
+
WebSocketHandler=Callable[['WebSocketSession',WebSocketEvent],None]
|
|
35
|
+
@dataclass(frozen=True,slots=True)
|
|
36
|
+
class WebSocketResponse:
|
|
37
|
+
'Declared WebSocket upgrade plus one ordered event handler.';handler:WebSocketHandler;subprotocol:str=''
|
|
38
|
+
def __post_init__(self)->None:
|
|
39
|
+
'Validate the callback and optional RFC token before startup.'
|
|
40
|
+
if not callable(self.handler):raise TypeError('WebSocket handler is not callable')
|
|
41
|
+
try:encoded=self.subprotocol.encode('ascii','strict')
|
|
42
|
+
except(AttributeError,UnicodeEncodeError)as error:raise ValueError('WebSocket subprotocol is invalid')from error
|
|
43
|
+
if len(encoded)>255 or any(byte not in _METHOD_BYTES for byte in encoded):raise ValueError('WebSocket subprotocol is invalid')
|
|
44
|
+
Handler=Callable[[Request],Response|FileResponse|StreamingResponse|ServerSentEventsResponse|WebSocketResponse]
|
|
45
|
+
_METHOD_BYTES:Final=frozenset(b"!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
|
|
46
|
+
_DEFAULT_WORKERS:Final=1
|
|
47
|
+
_DEFAULT_QUEUE_CAPACITY:Final=256
|
|
48
|
+
_DEFAULT_RETAINED_BYTES:Final=8<<20
|
|
49
|
+
_DEFAULT_CLOSE_TIMEOUT_MS:Final=30000
|
|
50
|
+
_MAX_WORKERS:Final=1024
|
|
51
|
+
_MAX_ROUTES:Final=1024
|
|
52
|
+
_MAX_HANDLER_BINDINGS:Final=4096
|
|
53
|
+
_MAX_STATIC_MOUNTS:Final=16
|
|
54
|
+
_MAX_FILE_AUTHORITIES:Final=16
|
|
55
|
+
_MAX_OUTBOUND_TARGETS:Final=32
|
|
56
|
+
_MAX_OUTBOUND_TRUSTS:Final=16
|
|
57
|
+
_MAX_OUTBOUND_IDENTITIES:Final=16
|
|
58
|
+
_DEFAULT_HANDLER_BINDINGS:Final=1024
|
|
59
|
+
_ERROR_CAPACITY:Final=64
|
|
60
|
+
_ERROR_DETAIL_BYTES:Final=1024
|
|
61
|
+
class _ResponseStreamState:
|
|
62
|
+
__slots__='_condition','_terminal','_writable'
|
|
63
|
+
def __init__(self)->None:'Create one capacity-one writable notification state.';self._condition=threading.Condition();(self._terminal):BaseException|None=None;self._writable=False
|
|
64
|
+
def grant_writable(self)->None:
|
|
65
|
+
'Publish initial or post-pressure writable readiness.'
|
|
66
|
+
with self._condition:
|
|
67
|
+
if self._terminal is None:self._writable=True
|
|
68
|
+
self._condition.notify()
|
|
69
|
+
def terminate(self,error:BaseException)->None:
|
|
70
|
+
'Wake the writer with the first terminal outcome.'
|
|
71
|
+
with self._condition:
|
|
72
|
+
if self._terminal is None:self._terminal=error
|
|
73
|
+
self._condition.notify()
|
|
74
|
+
def await_writable(self,timeout_ms:int)->None:
|
|
75
|
+
'Wait on monotonic time for readiness or terminal failure.';deadline=time.monotonic()+timeout_ms/1000
|
|
76
|
+
with self._condition:
|
|
77
|
+
while True:
|
|
78
|
+
if self._terminal is not None:raise self._terminal
|
|
79
|
+
if self._writable:self._writable=False;return
|
|
80
|
+
remaining=deadline-time.monotonic()
|
|
81
|
+
if remaining<=0:raise TimeoutError('response stream writable-credit wait timed out')
|
|
82
|
+
self._condition.wait(remaining)
|
|
83
|
+
@dataclass(frozen=True,slots=True)
|
|
84
|
+
class _RequestBodyData:data:bytes
|
|
85
|
+
@dataclass(frozen=True,slots=True)
|
|
86
|
+
class _RequestBodyTrailers:trailers:Headers
|
|
87
|
+
@dataclass(frozen=True,slots=True)
|
|
88
|
+
class _RequestBodyFailure:error:BaseException
|
|
89
|
+
class _RequestBodyEnd:pass
|
|
90
|
+
_REQUEST_BODY_END:Final=_RequestBodyEnd()
|
|
91
|
+
_RequestBodyItem:TypeAlias=_RequestBodyData|_RequestBodyTrailers|_RequestBodyFailure|_RequestBodyEnd
|
|
92
|
+
class _RequestBodyReader(io.RawIOBase):
|
|
93
|
+
'Sole-reader stream with a bounded copied-event queue.'
|
|
94
|
+
def __init__(self,request:Request,retained_item_capacity:int,initial_sequence:int)->None:'Adopt one request and reserve its copied-event capacity.';super().__init__();self._request=request;self._capacity=retained_item_capacity;(self._events):queue.Queue[_RequestBodyItem]=queue.Queue(retained_item_capacity+1);self._lock=threading.Lock();self._expected_sequence=initial_sequence+1;self._terminal=False;self._current=b'';self._offset=0;self._eof=False;(self._read_failure):BaseException|None=None
|
|
95
|
+
def readable(self)->bool:'Report that the handler may read request body bytes.';return True
|
|
96
|
+
def offer(self,sequence:int,item:_RequestBodyItem)->bool:
|
|
97
|
+
'Publish one copied Runtime event without blocking the event reader.'
|
|
98
|
+
with self._lock:
|
|
99
|
+
if self._terminal:return True
|
|
100
|
+
if sequence!=self._expected_sequence:self._fail_locked(RuntimeError('request body stream sequence is discontinuous'));return False
|
|
101
|
+
self._expected_sequence+=1
|
|
102
|
+
if item is _REQUEST_BODY_END:self._terminal=True;self._events.put_nowait(item);return True
|
|
103
|
+
if self._events.qsize()>=self._capacity:self._fail_locked(RuntimeError('request body stream queue is full'));return False
|
|
104
|
+
self._events.put_nowait(item);return True
|
|
105
|
+
def finish(self,error:BaseException)->None:
|
|
106
|
+
'Wake a blocked reader with the first terminal failure.'
|
|
107
|
+
with self._lock:
|
|
108
|
+
if self._terminal:return
|
|
109
|
+
self._fail_locked(error)
|
|
110
|
+
def _fail_locked(self,error:BaseException)->None:
|
|
111
|
+
self._terminal=True
|
|
112
|
+
while True:
|
|
113
|
+
try:self._events.get_nowait()
|
|
114
|
+
except queue.Empty:break
|
|
115
|
+
self._events.put_nowait(_RequestBodyFailure(error))
|
|
116
|
+
def read(self,size:int=-1)->bytes:
|
|
117
|
+
'Read one available prefix, or all remaining bytes when size is -1.'
|
|
118
|
+
if size==0:return b''
|
|
119
|
+
if size<-1:raise ValueError('request body read size is invalid')
|
|
120
|
+
target=bytearray()
|
|
121
|
+
while size<0 or len(target)<size:
|
|
122
|
+
if self._read_failure is not None:raise self._read_failure
|
|
123
|
+
if self._offset<len(self._current):
|
|
124
|
+
count=len(self._current)-self._offset
|
|
125
|
+
if size>=0:count=min(count,size-len(target))
|
|
126
|
+
target.extend(self._current[self._offset:self._offset+count]);self._offset+=count
|
|
127
|
+
if self._offset==len(self._current):self._current=b'';self._offset=0
|
|
128
|
+
if size>=0 and len(target)==size:break
|
|
129
|
+
continue
|
|
130
|
+
if self._eof:break
|
|
131
|
+
item=self._events.get()
|
|
132
|
+
if isinstance(item,_RequestBodyData):self._current=item.data
|
|
133
|
+
elif isinstance(item,_RequestBodyTrailers):self._request._set_stream_trailers(item.trailers)
|
|
134
|
+
elif isinstance(item,_RequestBodyFailure):self._read_failure=item.error
|
|
135
|
+
elif item is _REQUEST_BODY_END:self._eof=True
|
|
136
|
+
else:self._read_failure=RuntimeError('unknown request body stream item')
|
|
137
|
+
return bytes(target)
|
|
138
|
+
def close(self)->None:'Release the handler reader and wake any concurrent blocked read.';self.finish(RuntimeError('request body stream was closed'));super().close()
|
|
139
|
+
class ResponseStreamWriter:
|
|
140
|
+
'Single-writer response sink valid only during its producer callback.';__slots__='_closed','_runtime','_exchange','_lock','_state','_timeout_ms'
|
|
141
|
+
def __init__(self,runtime:Runtime,exchange:ExchangeId,state:_ResponseStreamState,timeout_ms:int)->None:'Borrow one active Runtime exchange for the producer lifetime.';self._runtime=runtime;self._exchange=exchange;self._state=state;self._timeout_ms=timeout_ms;self._lock=threading.Lock();self._closed=False
|
|
142
|
+
def write(self,data:bytes)->int:
|
|
143
|
+
'Copy one chunk, retrying pressure only after Runtime recovery credit.'
|
|
144
|
+
if not isinstance(data,bytes):raise TypeError('response stream chunks must be bytes')
|
|
145
|
+
if not data:return 0
|
|
146
|
+
with self._lock:
|
|
147
|
+
if self._closed:raise RuntimeError('response stream writer is closed')
|
|
148
|
+
while True:
|
|
149
|
+
try:self._runtime.write_response_stream(self._exchange,data);return len(data)
|
|
150
|
+
except NativeError as error:
|
|
151
|
+
if error.status!=int(ResultCode.CAPACITY_EXHAUSTED):raise
|
|
152
|
+
self._state.await_writable(self._timeout_ms)
|
|
153
|
+
def _close(self)->None:
|
|
154
|
+
with self._lock:self._closed=True
|
|
155
|
+
class ServerSentEventsWriter:
|
|
156
|
+
'Single-writer SSE sink valid only during its producer callback.';__slots__='_closed','_runtime','_exchange','_lock','_state','_timeout_ms'
|
|
157
|
+
def __init__(self,runtime:Runtime,exchange:ExchangeId,state:_ResponseStreamState,timeout_ms:int)->None:'Borrow one active SSE exchange for the producer lifetime.';self._runtime=runtime;self._exchange=exchange;self._state=state;self._timeout_ms=timeout_ms;self._lock=threading.Lock();self._closed=False
|
|
158
|
+
def write(self,event:SseEvent)->None:
|
|
159
|
+
'Copy one event, retrying pressure only after recovery readiness.'
|
|
160
|
+
if not isinstance(event,SseEvent):raise TypeError('event must be an SseEvent')
|
|
161
|
+
with self._lock:
|
|
162
|
+
if self._closed:raise RuntimeError('SSE writer is closed')
|
|
163
|
+
while True:
|
|
164
|
+
try:self._runtime.write_sse(self._exchange,event);return
|
|
165
|
+
except NativeError as error:
|
|
166
|
+
if error.status!=int(ResultCode.CAPACITY_EXHAUSTED):raise
|
|
167
|
+
self._state.await_writable(self._timeout_ms)
|
|
168
|
+
def _close(self)->None:
|
|
169
|
+
with self._lock:self._closed=True
|
|
170
|
+
class WebSocketSession:
|
|
171
|
+
'Generation-safe Service-owned WebSocket session passed to callbacks.';__slots__='_runtime','_id','_lock','_open','subprotocol'
|
|
172
|
+
def __init__(self,runtime:Runtime,session:WebSocketId,subprotocol:str)->None:'Adopt one opened Runtime session.';self._runtime=runtime;self._id=session;self.subprotocol=subprotocol;self._lock=threading.Lock();self._open=True
|
|
173
|
+
@property
|
|
174
|
+
def open(self)->bool:
|
|
175
|
+
'Return whether this Service still owns an open session.'
|
|
176
|
+
with self._lock:return self._open
|
|
177
|
+
def send_text(self,value:str)->None:
|
|
178
|
+
'Copy one UTF-8 text frame into Runtime.'
|
|
179
|
+
if not isinstance(value,str):raise TypeError('WebSocket text must be str')
|
|
180
|
+
self._send(WebSocketEventKind.TEXT,value.encode('utf-8'))
|
|
181
|
+
def send_bytes(self,value:bytes)->None:
|
|
182
|
+
'Copy one binary frame into Runtime.'
|
|
183
|
+
if not isinstance(value,bytes):raise TypeError('WebSocket binary data must be bytes')
|
|
184
|
+
self._send(WebSocketEventKind.BINARY,value)
|
|
185
|
+
def ping(self,value:bytes=b'')->None:'Copy one ping control frame into Runtime.';self._send(WebSocketEventKind.PING,value)
|
|
186
|
+
def pong(self,value:bytes=b'')->None:'Copy one pong control frame into Runtime.';self._send(WebSocketEventKind.PONG,value)
|
|
187
|
+
def _send(self,kind:WebSocketEventKind,value:bytes)->None:
|
|
188
|
+
if not isinstance(value,bytes):raise TypeError('WebSocket data must be bytes')
|
|
189
|
+
with self._lock:
|
|
190
|
+
if not self._open:raise RuntimeError('WebSocket session is closed')
|
|
191
|
+
self._runtime.send_websocket(self._id,kind,value)
|
|
192
|
+
def close(self,code:int=1000,reason:str='')->None:
|
|
193
|
+
'Start a bounded close handshake.'
|
|
194
|
+
if not isinstance(reason,str):raise TypeError('WebSocket close reason must be str')
|
|
195
|
+
encoded_reason=reason.encode('utf-8')
|
|
196
|
+
with self._lock:
|
|
197
|
+
if self._open:self._runtime.close_websocket(self._id,code,encoded_reason)
|
|
198
|
+
def _mark_closed(self)->None:
|
|
199
|
+
with self._lock:self._open=False
|
|
200
|
+
@dataclass(frozen=True,slots=True)
|
|
201
|
+
class _WebSocketEntry:session:WebSocketSession;response:WebSocketResponse
|
|
202
|
+
class _WebSocketDispatcher:
|
|
203
|
+
'Own bounded upgrade plans and generation-checked live sessions.';__slots__='_capacity','_closed','_runtime','_errors','_lock','_pending','_sessions'
|
|
204
|
+
def __init__(self,runtime:Runtime,capacity:int,errors:Callable[[BaseException],None])->None:'Create one sole-reader dispatcher for a started Runtime.';self._runtime=runtime;self._capacity=capacity;self._errors=errors;self._lock=threading.Lock();(self._pending):dict[ExchangeId,WebSocketResponse]={};(self._sessions):dict[WebSocketId,_WebSocketEntry]={};self._closed=False
|
|
205
|
+
def accept(self,exchange:ExchangeId,response:WebSocketResponse)->None:
|
|
206
|
+
'Retain one response plan and submit its upgrade to Runtime.'
|
|
207
|
+
with self._lock:
|
|
208
|
+
if self._closed or len(self._pending)>=self._capacity:raise RuntimeError('WebSocket upgrade capacity is exhausted')
|
|
209
|
+
if exchange in self._pending:raise RuntimeError('WebSocket upgrade is already pending')
|
|
210
|
+
self._pending[exchange]=response
|
|
211
|
+
try:self._runtime.accept_websocket(exchange,response.subprotocol)
|
|
212
|
+
except BaseException:
|
|
213
|
+
with self._lock:self._pending.pop(exchange,None)
|
|
214
|
+
raise
|
|
215
|
+
def handle(self,event:WebSocketEvent)->None:
|
|
216
|
+
'Apply one copied event and invoke its callback outside the lock.';entry:_WebSocketEntry|None=None;diagnostic:BaseException|None=None;close_unowned=False
|
|
217
|
+
with self._lock:
|
|
218
|
+
if self._closed:return
|
|
219
|
+
if event.kind is WebSocketEventKind.ACCEPT_FAILED:self._pending.pop(event.origin_exchange,None);diagnostic=RuntimeError('WebSocket upgrade failed')
|
|
220
|
+
elif event.kind is WebSocketEventKind.OPEN:
|
|
221
|
+
response=self._pending.pop(event.origin_exchange,None)
|
|
222
|
+
if response is None or len(self._sessions)>=self._capacity:close_unowned=True
|
|
223
|
+
else:session=WebSocketSession(self._runtime,event.session,event.data.decode('utf-8','strict'));entry=_WebSocketEntry(session,response);self._sessions[event.session]=entry
|
|
224
|
+
else:
|
|
225
|
+
entry=self._sessions.get(event.session)
|
|
226
|
+
if entry is not None and event.kind is WebSocketEventKind.CLOSE:self._sessions.pop(event.session,None);entry.session._mark_closed()
|
|
227
|
+
if diagnostic is not None:self._errors(diagnostic);return
|
|
228
|
+
if close_unowned:
|
|
229
|
+
try:self._runtime.close_websocket(event.session,1013,b'upgrade owner unavailable')
|
|
230
|
+
except BaseException as error:self._errors(error)
|
|
231
|
+
return
|
|
232
|
+
if entry is None:return
|
|
233
|
+
try:entry.response.handler(entry.session,event)
|
|
234
|
+
except BaseException as error:
|
|
235
|
+
self._errors(error)
|
|
236
|
+
try:entry.session.close(1011,'handler failed')
|
|
237
|
+
except BaseException as close_error:self._errors(close_error)
|
|
238
|
+
def shutdown(self)->None:
|
|
239
|
+
'Reject new upgrades and invalidate all language-owned sessions.'
|
|
240
|
+
with self._lock:self._closed=True;self._pending.clear();sessions=tuple(self._sessions.values());self._sessions.clear()
|
|
241
|
+
for entry in sessions:entry.session._mark_closed()
|
|
242
|
+
class ServiceError(RuntimeError):
|
|
243
|
+
'Bounded Python-owned diagnostic retained by :class:`Service`.'
|
|
244
|
+
def __init__(self,original_type:str,detail:str)->None:'Create a traceback-free diagnostic from bounded copied text.';self.original_type=original_type;self.detail=detail;super().__init__(f"{original_type}: {detail}"if detail else original_type)
|
|
245
|
+
class ServiceCloseError(RuntimeError):
|
|
246
|
+
'Core did not drain gracefully; cleanup success does not erase this cause.'
|
|
247
|
+
def __init__(self,outcome:DrainOutcome,*,runtime_retained:bool)->None:"Keep Core's typed observation and whether cleanup can be retried.";self.outcome=outcome;self.runtime_retained=runtime_retained;super().__init__(f"Core drain did not complete gracefully (state={outcome.state})")
|
|
248
|
+
class ServiceStartError(RuntimeError):
|
|
249
|
+
'Failed startup plus failed cleanup, retaining the exact retry owner.'
|
|
250
|
+
def __init__(self,startup_error:BaseException,cleanup_error:BaseException,runtime:Runtime)->None:"Preserve both causes; call this error's close() to retry all owners.";self.startup_error=startup_error;self.cleanup_error=cleanup_error;self.runtime=runtime;(self._cpu_owner):_ServiceCpuOwner|None=None;super().__init__('service startup and cleanup both failed; runtime retained')
|
|
251
|
+
def close(self,timeout_ms:int=30000)->None:
|
|
252
|
+
'Retry cleanup, then join the retained CPU owner with a finite budget.'
|
|
253
|
+
if isinstance(timeout_ms,bool)or not isinstance(timeout_ms,int)or not 1<=timeout_ms<=60000:raise ValueError('close timeout is invalid')
|
|
254
|
+
deadline=time.monotonic()+timeout_ms/1000;self.runtime.close()
|
|
255
|
+
if self._cpu_owner is not None:self._cpu_owner.close(max(.0,deadline-time.monotonic()))
|
|
256
|
+
def _retired_submission(error:BaseException)->bool:'Recognize only explicit native retirement, without connector inference.';return isinstance(error,NativeError)and error.status in(int(ResultCode.CLOSED),int(ResultCode.STALE_EXCHANGE))
|
|
257
|
+
_WorkItem:TypeAlias=tuple[Handler,Request,int]
|
|
258
|
+
class Builder:
|
|
259
|
+
'Single-use builder for one bounded native-owned HTTP service.\n\n The facade copies a leased request into Python-owned values, releases the\n native lease, and then admits the request to a finite Python worker queue.\n Request/response streaming, SSE, WebSocket and handler publication are\n available directly on this facade, including logical-target outbound calls.\n ';__slots__='_close_timeout_ms','_compression','_cpu_policy','_file_authorities','_handlers','_limits','_listener','_io_backend','_max_handler_bindings','_max_retained_bytes','_monitor_options','_next_route_id','_outbound_identities','_outbound_targets','_outbound_trusts','_queue_capacity','_routes','_started','_static_mounts','_websocket_routes','_workers'
|
|
260
|
+
def __init__(self)->None:'Create a loopback HTTP/1.1 builder with finite dispatch defaults.';self._listener=Listener();self._compression=Compression();self._cpu_policy=CpuPolicy.AUTO;self._io_backend=IoBackend.PLATFORM_DEFAULT;self._limits=Limits();self._workers=_DEFAULT_WORKERS;self._websocket_routes=0;self._queue_capacity=_DEFAULT_QUEUE_CAPACITY;self._max_retained_bytes=_DEFAULT_RETAINED_BYTES;self._max_handler_bindings=_DEFAULT_HANDLER_BINDINGS;(self._monitor_options):MonitorOptions|None=None;self._close_timeout_ms=_DEFAULT_CLOSE_TIMEOUT_MS;(self._routes):list[Route]=[];(self._static_mounts):list[StaticMount]=[];(self._file_authorities):list[FileAuthority]=[];(self._outbound_targets):list[OutboundTarget]=[];(self._outbound_trusts):list[tuple[int,bytes]]=[];(self._outbound_identities):list[tuple[int,bytes,bytes]]=[];(self._handlers):dict[int,Handler]={};self._next_route_id=1;self._started=False
|
|
261
|
+
def listener(self,listener:Listener)->Self:
|
|
262
|
+
'Set the complete listener declaration, including TLS fields.';self._ensure_mutable()
|
|
263
|
+
if not isinstance(listener,Listener):raise TypeError('listener must be a Listener value')
|
|
264
|
+
self._listener=listener;return self
|
|
265
|
+
def listen(self,host:str,port:int)->Self:
|
|
266
|
+
'Replace only the bind address and port of the current listener.';self._ensure_mutable()
|
|
267
|
+
if not isinstance(host,str)or not host.strip()or'\x00'in host:raise ValueError('listener host is invalid')
|
|
268
|
+
if isinstance(port,bool)or not isinstance(port,int)or not 0<=port<=65535:raise ValueError('listener port is invalid')
|
|
269
|
+
self._listener=replace(self._listener,bind_address=host,port=port);return self
|
|
270
|
+
def concurrency(self,workers:int)->Self:
|
|
271
|
+
'Set the finite number of Python handler worker threads.';self._ensure_mutable()
|
|
272
|
+
if isinstance(workers,bool)or not isinstance(workers,int)or not 1<=workers<=_MAX_WORKERS:raise ValueError('worker count is invalid')
|
|
273
|
+
self._workers=workers;return self
|
|
274
|
+
def cpu(self,policy:CpuPolicy)->Self:
|
|
275
|
+
"Submit Core placement intent; AUTO prefers two allowed logical CPUs.\n\n This does not set worker count, remove the GIL or impose a process quota.\n The dedicated lifecycle owner and its children are the only scope.\n Runtime info reports Core's actual selection or unsupported placement.\n ";self._ensure_mutable()
|
|
276
|
+
if not isinstance(policy,CpuPolicy):raise TypeError('policy must be a CpuPolicy')
|
|
277
|
+
self._cpu_policy=policy;return self
|
|
278
|
+
def dispatch_queue(self,capacity:int)->Self:
|
|
279
|
+
'Set the bounded copied-request queue capacity.';self._ensure_mutable()
|
|
280
|
+
if isinstance(capacity,bool)or not isinstance(capacity,int)or not 1<=capacity<=(1<<31)-1:raise ValueError('dispatch queue capacity is invalid')
|
|
281
|
+
self._queue_capacity=capacity;return self
|
|
282
|
+
def dispatch_bytes(self,maximum:int)->Self:
|
|
283
|
+
'Set the aggregate byte ceiling for queued and running requests.';self._ensure_mutable()
|
|
284
|
+
if isinstance(maximum,bool)or not isinstance(maximum,int)or not 1<=maximum<=(1<<63)-1:raise ValueError('dispatch byte capacity is invalid')
|
|
285
|
+
self._max_retained_bytes=maximum;return self
|
|
286
|
+
def handler_bindings(self,maximum:int)->Self:
|
|
287
|
+
'Set the total lifetime capacity for single-assignment handler IDs.';self._ensure_mutable()
|
|
288
|
+
if isinstance(maximum,bool)or not isinstance(maximum,int)or not 1<=maximum<=_MAX_HANDLER_BINDINGS:raise ValueError('handler binding capacity is invalid')
|
|
289
|
+
self._max_handler_bindings=maximum;return self
|
|
290
|
+
def close_timeout(self,timeout_ms:int)->Self:
|
|
291
|
+
'Set the monotonic facade drain/join timeout in milliseconds.';self._ensure_mutable()
|
|
292
|
+
if isinstance(timeout_ms,bool)or not isinstance(timeout_ms,int)or not 1<=timeout_ms<=60000:raise ValueError('close timeout is invalid')
|
|
293
|
+
self._close_timeout_ms=timeout_ms;return self
|
|
294
|
+
def limits(self,limits:Limits)->Self:
|
|
295
|
+
'Replace the complete construction-time Runtime resource ceilings.';self._ensure_mutable()
|
|
296
|
+
if not isinstance(limits,Limits):raise TypeError('limits must be a Limits value')
|
|
297
|
+
self._limits=limits;return self
|
|
298
|
+
def monitor(self,options:MonitorOptions)->Self:
|
|
299
|
+
'Reserve bounded Runtime monitor storage and its initial policy.';self._ensure_mutable()
|
|
300
|
+
if not isinstance(options,MonitorOptions):raise TypeError('monitor must be a MonitorOptions value')
|
|
301
|
+
self._monitor_options=options;return self
|
|
302
|
+
def compression(self,policy:Compression)->Self:
|
|
303
|
+
'Set the bounded native response-compression policy.';self._ensure_mutable()
|
|
304
|
+
if not isinstance(policy,Compression):raise TypeError('policy must be a Compression value')
|
|
305
|
+
self._compression=policy;return self
|
|
306
|
+
def io_backend(self,backend:IoBackend)->Self:
|
|
307
|
+
'Opt in to a native backend request; the default remains platform I/O.';self._ensure_mutable()
|
|
308
|
+
if not isinstance(backend,IoBackend):raise TypeError('backend must be an IoBackend value')
|
|
309
|
+
self._io_backend=backend;return self
|
|
310
|
+
def static_mount(self,mount:StaticMount)->Self:
|
|
311
|
+
'Add one Runtime-owned static tree with optional index and SPA policy.';self._ensure_mutable()
|
|
312
|
+
if not isinstance(mount,StaticMount):raise TypeError('mount must be a StaticMount value')
|
|
313
|
+
if len(self._static_mounts)>=_MAX_STATIC_MOUNTS:raise ValueError('static mount count exceeds the Runtime bound')
|
|
314
|
+
self._static_mounts.append(mount);return self
|
|
315
|
+
def file_authority(self,authority:FileAuthority)->Self:
|
|
316
|
+
'Add one bounded root for application-selected file responses.';self._ensure_mutable()
|
|
317
|
+
if not isinstance(authority,FileAuthority):raise TypeError('authority must be a FileAuthority value')
|
|
318
|
+
if len(self._file_authorities)>=_MAX_FILE_AUTHORITIES:raise ValueError('file authority count exceeds the Runtime bound')
|
|
319
|
+
self._file_authorities.append(authority);return self
|
|
320
|
+
def outbound_target(self,target:OutboundTarget)->Self:
|
|
321
|
+
'Add one immutable logical outbound target generation.';self._ensure_mutable()
|
|
322
|
+
if not isinstance(target,OutboundTarget):raise TypeError('target must be an OutboundTarget value')
|
|
323
|
+
if len(self._outbound_targets)>=_MAX_OUTBOUND_TARGETS:raise ValueError('outbound target count exceeds the Runtime bound')
|
|
324
|
+
self._outbound_targets.append(target);return self
|
|
325
|
+
def outbound_trust(self,generation:int,ca_pem:bytes)->Self:
|
|
326
|
+
'Add one generation-keyed outbound CA bundle copied by Runtime.';self._ensure_mutable()
|
|
327
|
+
if len(self._outbound_trusts)>=_MAX_OUTBOUND_TRUSTS:raise ValueError('outbound trust count exceeds the Runtime bound')
|
|
328
|
+
if isinstance(generation,bool)or generation<=0 or not ca_pem:raise ValueError('outbound trust is invalid')
|
|
329
|
+
self._outbound_trusts.append((generation,bytes(ca_pem)));return self
|
|
330
|
+
def outbound_identity(self,generation:int,certificate_chain_pem:bytes,private_key_pem:bytes)->Self:
|
|
331
|
+
'Add one outbound mutual-TLS identity generation copied by Runtime.';self._ensure_mutable()
|
|
332
|
+
if len(self._outbound_identities)>=_MAX_OUTBOUND_IDENTITIES:raise ValueError('outbound identity count exceeds the Runtime bound')
|
|
333
|
+
if isinstance(generation,bool)or generation<=0 or not certificate_chain_pem or not private_key_pem:raise ValueError('outbound identity is invalid')
|
|
334
|
+
self._outbound_identities.append((generation,bytes(certificate_chain_pem),bytes(private_key_pem)));return self
|
|
335
|
+
def add_route(self,route:Route,handler:Handler)->Self:
|
|
336
|
+
'Register an explicit route and its Python handler binding.';self._ensure_mutable()
|
|
337
|
+
if not isinstance(route,Route):raise TypeError('route must be a Route value')
|
|
338
|
+
if not callable(handler):raise TypeError('route handler is not callable')
|
|
339
|
+
if len(self._routes)>=_MAX_ROUTES:raise ValueError('route count exceeds the facade bound')
|
|
340
|
+
binding_id=route.handler_binding_id or route.id
|
|
341
|
+
if binding_id<=0 or binding_id in self._handlers:raise ValueError('handler binding identity is invalid or duplicated')
|
|
342
|
+
if any(existing.id==route.id for existing in self._routes):raise ValueError('route identity is duplicated')
|
|
343
|
+
effective=replace(route,handler_binding_id=binding_id);self._routes.append(effective);self._handlers[binding_id]=handler;self._next_route_id=max(self._next_route_id,route.id+1);return self
|
|
344
|
+
def route(self,method:str,pattern:str,handler:Handler,*,body_delivery:BodyDelivery=BodyDelivery.INLINE,body_policy:BodyPolicy|None=None)->Self:
|
|
345
|
+
'Register a generated-identity route and handler.';self._ensure_mutable();normalized=method.strip().upper()if isinstance(method,str)else''
|
|
346
|
+
try:method_bytes=normalized.encode('ascii','strict')
|
|
347
|
+
except UnicodeEncodeError as error:raise ValueError('route method is invalid')from error
|
|
348
|
+
if not method_bytes or any(byte not in _METHOD_BYTES for byte in method_bytes):raise ValueError('route method is invalid')
|
|
349
|
+
if not isinstance(pattern,str)or not pattern.startswith('/')or'\x00'in pattern:raise ValueError('route pattern is invalid')
|
|
350
|
+
if not isinstance(body_delivery,BodyDelivery):raise TypeError('body_delivery must be a BodyDelivery value')
|
|
351
|
+
policy=BodyPolicy()if body_policy is None else body_policy
|
|
352
|
+
if not isinstance(policy,BodyPolicy):raise TypeError('body_policy must be a BodyPolicy value')
|
|
353
|
+
route=Route(id=self._next_route_id,method=normalized,pattern=pattern,handler_binding_id=self._next_route_id,body_delivery=body_delivery,body_policy=policy);return self.add_route(route,handler)
|
|
354
|
+
def document(self,metadata:RouteApiMetadata)->Self:
|
|
355
|
+
'Attach immutable documentation to the most recently added route.';self._ensure_mutable()
|
|
356
|
+
if not isinstance(metadata,RouteApiMetadata):raise TypeError('metadata must be a RouteApiMetadata value')
|
|
357
|
+
if not self._routes:raise ValueError('route documentation requires a preceding route')
|
|
358
|
+
self._routes[-1]=replace(self._routes[-1],api_metadata=metadata);return self
|
|
359
|
+
def get(self,pattern:str,handler:Handler)->Self:'Register a buffered GET route.';return self.route('GET',pattern,handler)
|
|
360
|
+
def post(self,pattern:str,handler:Handler)->Self:'Register a buffered POST route.';return self.route('POST',pattern,handler)
|
|
361
|
+
def put(self,pattern:str,handler:Handler)->Self:'Register a buffered PUT route.';return self.route('PUT',pattern,handler)
|
|
362
|
+
def patch(self,pattern:str,handler:Handler)->Self:'Register a buffered PATCH route.';return self.route('PATCH',pattern,handler)
|
|
363
|
+
def delete(self,pattern:str,handler:Handler)->Self:'Register a buffered DELETE route.';return self.route('DELETE',pattern,handler)
|
|
364
|
+
def websocket(self,pattern:str,handler:Handler)->Self:
|
|
365
|
+
'Register one declared GET WebSocket upgrade route.';before=len(self._routes);self.route('GET',pattern,handler)
|
|
366
|
+
if len(self._routes)==before+1:self._routes[-1]=replace(self._routes[-1],websocket=True);self._websocket_routes+=1
|
|
367
|
+
return self
|
|
368
|
+
def route_stream(self,method:str,pattern:str,handler:Handler,*,body_policy:BodyPolicy|None=None)->Self:'Register a generated-identity incremental request-body route.';return self.route(method,pattern,handler,body_delivery=BodyDelivery.STREAM,body_policy=body_policy)
|
|
369
|
+
def get_stream(self,pattern:str,handler:Handler)->Self:'Register a streamed GET request-body handler.';return self.route_stream('GET',pattern,handler)
|
|
370
|
+
def post_stream(self,pattern:str,handler:Handler)->Self:'Register a streamed POST request-body handler.';return self.route_stream('POST',pattern,handler)
|
|
371
|
+
def put_stream(self,pattern:str,handler:Handler)->Self:'Register a streamed PUT request-body handler.';return self.route_stream('PUT',pattern,handler)
|
|
372
|
+
def patch_stream(self,pattern:str,handler:Handler)->Self:'Register a streamed PATCH request-body handler.';return self.route_stream('PATCH',pattern,handler)
|
|
373
|
+
def delete_stream(self,pattern:str,handler:Handler)->Self:'Register a streamed DELETE request-body handler.';return self.route_stream('DELETE',pattern,handler)
|
|
374
|
+
def start(self,*,inspection:InspectionOptions|None=None)->Service:'Start native admission, bounded dispatch, and optional inspection.';self._ensure_mutable();start=self._start_native if inspection is None else partial(self._start_native,inspection=inspection);return _ServiceCpuOwner(self._cpu_policy).start(start,self._close_timeout_ms)
|
|
375
|
+
def _start_native(self,*,inspection:InspectionOptions|None=None)->Service:
|
|
376
|
+
if managed_projection.PROJECTION is None:raise RuntimeError('managed service requires its native projection image')
|
|
377
|
+
if not self._routes and not self._static_mounts and not self._outbound_targets:raise ValueError('at least one route, static mount, or outbound target is required')
|
|
378
|
+
if not self._outbound_targets and(self._outbound_trusts or self._outbound_identities):raise ValueError('outbound options and credentials require a logical target')
|
|
379
|
+
if len(self._handlers)>self._max_handler_bindings:raise ValueError('route count exceeds the handler binding capacity')
|
|
380
|
+
self._started=True;required=Capability.INBOUND|Capability.ROUTE_REBIND|Capability.ROUTE_PUBLICATION|Capability.HEALTH|Capability.RESPONSE_STREAM|Capability.RESPONSE_TRAILERS|Capability.RESPONSE_WRITE_TIMEOUT|Capability.SERVER_SENT_EVENTS
|
|
381
|
+
if any(route.body_delivery is BodyDelivery.STREAM for route in self._routes):required|=Capability.REQUEST_STREAM
|
|
382
|
+
if self._listener.protocol is ListenerProtocol.HTTP_2:required|=Capability.HTTP_2
|
|
383
|
+
elif self._listener.protocol is ListenerProtocol.HTTP_3:required|=Capability.HTTP_3
|
|
384
|
+
if self._listener.security is TransportSecurity.TLS:required|=Capability.TLS
|
|
385
|
+
elif self._listener.security is TransportSecurity.MUTUAL_TLS:required|=Capability.MUTUAL_TLS
|
|
386
|
+
if self._monitor_options is not None:required|=Capability.MONITOR
|
|
387
|
+
if self._websocket_routes:required|=Capability.WEBSOCKET
|
|
388
|
+
if self._static_mounts:required|=Capability.STATIC_FILES|Capability.STATIC_FRONTEND
|
|
389
|
+
if self._file_authorities:required|=Capability.APPLICATION_FILES
|
|
390
|
+
if self._outbound_targets:required|=Capability.OUTBOUND|Capability.OUTBOUND_LOGICAL_TARGET
|
|
391
|
+
plan=plan_service(required)
|
|
392
|
+
if not plan.supported:raise RuntimeError(f"Runtime cannot provide the declared service (missing={int(plan.missing_capabilities):#x})")
|
|
393
|
+
create_runtime=Runtime if inspection is None else partial(Runtime,inspection=inspection);runtime=create_runtime(RuntimeConfig(listener=self._listener,routes=tuple(self._routes),limits=self._limits,monitor=self._monitor_options or MonitorOptions(),static_mounts=tuple(self._static_mounts),file_authorities=tuple(self._file_authorities),compression=self._compression,io_backend=self._io_backend,outbound_targets=tuple(self._outbound_targets),outbound_trusts=tuple(OutboundTrust(generation,ca_pem)for(generation,ca_pem)in self._outbound_trusts),outbound_identities=tuple(OutboundIdentity(generation,certificate,key)for(generation,certificate,key)in self._outbound_identities),omit_buffered_success_events=True))
|
|
394
|
+
try:accepted=runtime.effective_limits();runtime.start();return Service(runtime,dict(self._handlers),tuple(self._routes),plan=plan,worker_count=self._workers,queue_capacity=self._queue_capacity,max_retained_bytes=self._max_retained_bytes,max_handler_bindings=self._max_handler_bindings,close_timeout_ms=self._close_timeout_ms,response_write_timeout_ms=accepted.response_write_timeout_ms,request_stream_capacity=accepted.request_stream_max_retained_items,web_socket_capacity=accepted.max_active_requests if self._websocket_routes else 0)
|
|
395
|
+
except BaseException as startup_error:
|
|
396
|
+
try:runtime.close()
|
|
397
|
+
except BaseException as cleanup_error:raise ServiceStartError(startup_error,cleanup_error,runtime)from startup_error
|
|
398
|
+
raise
|
|
399
|
+
def _ensure_mutable(self)->None:
|
|
400
|
+
if self._started:raise RuntimeError('builder has already started a service')
|
|
401
|
+
class Service:
|
|
402
|
+
'Lifecycle owner for a Runtime plus one reader and finite worker set.';__slots__='_close_lock','_close_serial','_close_timeout_ms','_closed','_closed_error','_cpu_owner','_draining','_runtime','_error_lock','_errors','_handler_lock','_handlers','_max_handler_bindings','_monitor_condition','_monitor_waits','_outbound_condition','_outbound_waits','_retention_lock','_plan','_queue','_reader','_request_stream_capacity','_request_stream_lock','_request_streams','_response_stream_lock','_response_streams','_response_write_timeout_ms','_drain_outcome','_workers_signalled','_retained_byte_capacity','_retained_bytes','_routes','_stopping','_web_socket_dispatcher','_web_socket_reader','_workers'
|
|
403
|
+
def __init__(self,runtime:Runtime,handlers:dict[int,Handler],routes:tuple[Route,...],*,plan:ServicePlan,worker_count:int,queue_capacity:int,max_retained_bytes:int,max_handler_bindings:int,close_timeout_ms:int,response_write_timeout_ms:int,request_stream_capacity:int,web_socket_capacity:int)->None:
|
|
404
|
+
'Adopt a started Runtime and start its sole inbound reader.';self._runtime=runtime;(self._cpu_owner):_ServiceCpuOwner|None=None;self._handlers=handlers;self._handler_lock=threading.Lock();self._max_handler_bindings=max_handler_bindings;self._routes=routes;self._plan=plan;(self._queue):queue.Queue[_WorkItem|None]=queue.Queue(queue_capacity);self._stopping=threading.Event();self._draining=threading.Event();self._close_lock=threading.Lock();self._close_serial=threading.Lock();self._error_lock=threading.Lock();self._retention_lock=threading.Lock();self._monitor_condition=threading.Condition();self._outbound_condition=threading.Condition();(self._errors):deque[ServiceError]=deque(maxlen=_ERROR_CAPACITY);self._retained_bytes=0;self._retained_byte_capacity=max_retained_bytes;self._response_stream_lock=threading.Lock();(self._response_streams):dict[ExchangeId,_ResponseStreamState]={};self._response_write_timeout_ms=response_write_timeout_ms;(self._drain_outcome):DrainOutcome|None=None;self._workers_signalled=0;self._request_stream_lock=threading.Lock();(self._request_streams):dict[ExchangeId,_RequestBodyReader]={};self._request_stream_capacity=request_stream_capacity;self._monitor_waits=0;self._outbound_waits=0;self._web_socket_dispatcher=_WebSocketDispatcher(runtime,web_socket_capacity,self._record)if web_socket_capacity else None;self._closed=False;(self._closed_error):BaseException|None=None;self._close_timeout_ms=close_timeout_ms;self._reader=threading.Thread(target=self._read_events,name='coakka-http-events');self._workers=tuple(threading.Thread(target=self._run_worker,name=f"coakka-http-handler-{index}")for index in range(worker_count));self._web_socket_reader=threading.Thread(target=self._read_web_sockets,name='coakka-http-websocket-events')if self._web_socket_dispatcher is not None else None;started:list[threading.Thread]=[]
|
|
405
|
+
try:
|
|
406
|
+
for worker in self._workers:worker.start();started.append(worker)
|
|
407
|
+
self._reader.start();started.append(self._reader)
|
|
408
|
+
if self._web_socket_reader is not None:self._web_socket_reader.start();started.append(self._web_socket_reader)
|
|
409
|
+
except BaseException:
|
|
410
|
+
self._stopping.set();worker_count=sum(thread in self._workers for thread in started)
|
|
411
|
+
for _worker in range(worker_count):self._queue.put(None)
|
|
412
|
+
if self._reader in started:self._runtime.interrupt()
|
|
413
|
+
for thread in started:thread.join()
|
|
414
|
+
raise
|
|
415
|
+
@property
|
|
416
|
+
def port(self)->int:
|
|
417
|
+
'Return the actual bound port of the native listener.'
|
|
418
|
+
with self._close_lock:
|
|
419
|
+
if self._closed:raise RuntimeError('service is closed')
|
|
420
|
+
return self._runtime.port
|
|
421
|
+
@property
|
|
422
|
+
def routes(self)->RouteSnapshot:
|
|
423
|
+
"Pull Core's coherent immutable route cut independently of monitoring.\n\n Inspection failure propagates; local handler-retention declarations\n never substitute for effective Core state.\n "
|
|
424
|
+
with self._close_lock:
|
|
425
|
+
if self._closed:raise RuntimeError('service is closed')
|
|
426
|
+
return self._runtime.route_snapshot()
|
|
427
|
+
@property
|
|
428
|
+
def plan(self)->ServicePlan:"Return Runtime's provider decision captured before listener startup.";return self._plan
|
|
429
|
+
def health(self)->Health:
|
|
430
|
+
'Copy the current Runtime health snapshot.'
|
|
431
|
+
with self._close_lock:
|
|
432
|
+
if self._closed:raise RuntimeError('service is closed')
|
|
433
|
+
return self._runtime.health()
|
|
434
|
+
def runtime_info(self)->RuntimeInfo:
|
|
435
|
+
'Copy capability and effective backend truth from Runtime.'
|
|
436
|
+
with self._close_lock:
|
|
437
|
+
if self._closed:raise RuntimeError('service is closed')
|
|
438
|
+
return replace(self._runtime.runtime_info(),cpu=self._cpu_owner.info if self._cpu_owner is not None else None)
|
|
439
|
+
def effective_limits(self)->Limits:
|
|
440
|
+
"Copy Core's accepted startup ceilings and deadlines unchanged."
|
|
441
|
+
with self._close_lock:
|
|
442
|
+
if self._closed:raise RuntimeError('service is closed')
|
|
443
|
+
return self._runtime.effective_limits()
|
|
444
|
+
def probe_liveness(self,timeout_ms:int=1000)->Health:
|
|
445
|
+
'Wait for fresh Runtime progress evidence within ``timeout_ms``.'
|
|
446
|
+
with self._close_lock:
|
|
447
|
+
if self._closed:raise RuntimeError('service is closed')
|
|
448
|
+
return self._runtime.probe_liveness(timeout_ms)
|
|
449
|
+
def outbound_submit(self,request:OutboundRequest)->OutboundId:
|
|
450
|
+
'Submit one buffered call to a startup-declared logical target.'
|
|
451
|
+
with self._close_lock:
|
|
452
|
+
if self._closed or self._stopping.is_set():raise RuntimeError('service is closing or closed')
|
|
453
|
+
return self._runtime.outbound_submit(request)
|
|
454
|
+
def outbound_cancel(self,call:OutboundId)->None:
|
|
455
|
+
'Request cancellation of an admitted outbound call.'
|
|
456
|
+
with self._close_lock:
|
|
457
|
+
if self._closed or self._stopping.is_set():raise RuntimeError('service is closing or closed')
|
|
458
|
+
self._runtime.outbound_cancel(call)
|
|
459
|
+
def take_outbound(self,timeout_ms:int=TIMEOUT_FOREVER)->OutboundTerminal|None:
|
|
460
|
+
'Wait without holding the lifecycle lock and copy one terminal.'
|
|
461
|
+
with self._close_lock:
|
|
462
|
+
if self._closed or self._stopping.is_set():raise RuntimeError('service is closing or closed')
|
|
463
|
+
runtime=self._runtime
|
|
464
|
+
with self._outbound_condition:self._outbound_waits+=1
|
|
465
|
+
try:return runtime.take_outbound(timeout_ms)
|
|
466
|
+
finally:
|
|
467
|
+
with self._outbound_condition:self._outbound_waits-=1;self._outbound_condition.notify_all()
|
|
468
|
+
def tls_credentials(self)->TlsCredentials:
|
|
469
|
+
'Read the effective credential generation of this secure service.'
|
|
470
|
+
with self._close_lock:
|
|
471
|
+
if self._closed:raise RuntimeError('service is closed')
|
|
472
|
+
return self._runtime.tls_credentials()
|
|
473
|
+
def tls_reload(self,request:TlsReloadRequest)->TlsReloadOutcome:
|
|
474
|
+
"Submit a TLS credential replacement and return Core's outcome."
|
|
475
|
+
with self._close_lock:
|
|
476
|
+
if self._closed:raise RuntimeError('service is closed')
|
|
477
|
+
return self._runtime.tls_reload(request)
|
|
478
|
+
def monitor_config(self)->MonitorConfig:
|
|
479
|
+
'Return the effective pointer-free monitor configuration.'
|
|
480
|
+
with self._close_lock:
|
|
481
|
+
if self._closed:raise RuntimeError('service is closed')
|
|
482
|
+
return self._runtime.monitor_config()
|
|
483
|
+
def monitor_snapshot(self)->MonitorSnapshot:
|
|
484
|
+
'Return a bounded aggregate monitor snapshot safe to retain.'
|
|
485
|
+
with self._close_lock:
|
|
486
|
+
if self._closed:raise RuntimeError('service is closed')
|
|
487
|
+
return self._runtime.monitor_snapshot()
|
|
488
|
+
def monitor_apply(self,expected_generation:int,policy:MonitorPolicy)->MonitorApplyOutcome:
|
|
489
|
+
'Apply a live policy and return its typed outcome.'
|
|
490
|
+
with self._close_lock:
|
|
491
|
+
if self._closed:raise RuntimeError('service is closed')
|
|
492
|
+
return self._runtime.monitor_apply(expected_generation,policy)
|
|
493
|
+
def monitor_read(self,after_sequence:int,maximum_events:int)->MonitorEventPage:
|
|
494
|
+
'Copy a bounded page of newer operational monitor events.'
|
|
495
|
+
with self._close_lock:
|
|
496
|
+
if self._closed:raise RuntimeError('service is closed')
|
|
497
|
+
return self._runtime.monitor_read(after_sequence,maximum_events)
|
|
498
|
+
def monitor_wait(self,timeout_ms:int)->bool:
|
|
499
|
+
'Wait without holding the lifecycle lock; false means timeout.'
|
|
500
|
+
with self._close_lock:
|
|
501
|
+
if self._closed:raise RuntimeError('service is closed')
|
|
502
|
+
runtime=self._runtime
|
|
503
|
+
with self._monitor_condition:self._monitor_waits+=1
|
|
504
|
+
try:return runtime.monitor_wait(timeout_ms)
|
|
505
|
+
finally:
|
|
506
|
+
with self._monitor_condition:self._monitor_waits-=1;self._monitor_condition.notify_all()
|
|
507
|
+
def monitor_interrupt(self)->None:
|
|
508
|
+
'Wake the sole blocking monitor waiter.'
|
|
509
|
+
with self._close_lock:
|
|
510
|
+
if self._closed:raise RuntimeError('service is closed')
|
|
511
|
+
self._runtime.monitor_interrupt()
|
|
512
|
+
def prepare_handler(self,binding_id:int,handler:Handler)->None:
|
|
513
|
+
'Register one single-assignment binding without publishing it.'
|
|
514
|
+
if isinstance(binding_id,bool)or not isinstance(binding_id,int)or binding_id<=0 or not callable(handler):raise ValueError('handler binding is invalid')
|
|
515
|
+
with self._close_lock:
|
|
516
|
+
if self._closed or self._stopping.is_set():raise RuntimeError('service is closing or closed')
|
|
517
|
+
with self._handler_lock:
|
|
518
|
+
if binding_id in self._handlers:raise ValueError('handler binding identity was already used')
|
|
519
|
+
if len(self._handlers)>=self._max_handler_bindings:raise RuntimeError('handler binding capacity is exhausted')
|
|
520
|
+
self._handlers[binding_id]=handler
|
|
521
|
+
def rebind_handler(self,request:RouteRebind)->RouteRebindOutcome:
|
|
522
|
+
"Submit a bounded binding update and return Core's effective outcome.\n\n The public operation is synchronous; it does not expose a separate\n caller timeout or imply cancellation of an accepted publication.\n "
|
|
523
|
+
if not isinstance(request,RouteRebind):raise TypeError('request must be a RouteRebind')
|
|
524
|
+
with self._close_lock:
|
|
525
|
+
if self._closed or self._stopping.is_set():raise RuntimeError('service is closing or closed')
|
|
526
|
+
with self._handler_lock:prepared=request.new_handler_binding_id in self._handlers
|
|
527
|
+
if not prepared:raise ValueError('handler binding was not prepared')
|
|
528
|
+
outcome=self._runtime.rebind(request)
|
|
529
|
+
if outcome.code is RoutePublicationCode.APPLIED and self._current_control_cut(outcome.replayed,outcome.route_generation,outcome.binding_change_sequence):self._routes=tuple(replace(route,handler_binding_id=outcome.effective_handler_binding_id)if route.id==outcome.route_id else route for route in self._routes)
|
|
530
|
+
return outcome
|
|
531
|
+
def _current_control_cut(self,replayed:bool,generation:int,sequence:int,metadata_generation:int|None=None)->bool:
|
|
532
|
+
if not replayed:return True
|
|
533
|
+
current=self._runtime.route_snapshot().identity;return current.route_generation==generation and current.binding_change_sequence==sequence and(metadata_generation is None or current.metadata_generation==metadata_generation)
|
|
534
|
+
def publish_routes(self,request:RoutePublication)->RoutePublicationOutcome:
|
|
535
|
+
"Submit a complete generation and return Core's synchronous outcome."
|
|
536
|
+
if not isinstance(request,RoutePublication):raise TypeError('request must be a RoutePublication')
|
|
537
|
+
routes=tuple(replace(route,handler_binding_id=route.handler_binding_id or route.id)for route in request.routes)
|
|
538
|
+
if len(routes)>self._max_handler_bindings:raise ValueError('route generation exceeds the service bound')
|
|
539
|
+
with self._close_lock:
|
|
540
|
+
if self._closed or self._stopping.is_set():raise RuntimeError('service is closing or closed')
|
|
541
|
+
with self._handler_lock:
|
|
542
|
+
if any(route.handler_binding_id not in self._handlers for route in routes):raise ValueError('route references an unprepared handler binding')
|
|
543
|
+
outcome=self._runtime.publish_routes(replace(request,routes=routes))
|
|
544
|
+
if outcome.code is RoutePublicationCode.APPLIED and self._current_control_cut(outcome.replayed,outcome.effective_route_generation,outcome.effective_binding_change_sequence,outcome.effective_metadata_generation):self._routes=routes
|
|
545
|
+
return outcome
|
|
546
|
+
def poll_error(self)->ServiceError|None:
|
|
547
|
+
'Remove the oldest bounded reader, handler, or submission error.'
|
|
548
|
+
with self._error_lock:return self._errors.popleft()if self._errors else None
|
|
549
|
+
def close(self,timeout_ms:int|None=None)->None:
|
|
550
|
+
"Observe Core drain, stop native work, join Python owners, then destroy.\n\n A caller wait expiry retains the owner for retry. Core EXPIRED/ABORTED/\n FAILED stays a ServiceCloseError even after cleanup and repeated close.\n Call from an external lifecycle owner, never from this service's handler\n or reader. Arbitrary Python code cannot be safely force-cancelled.\n ";timeout=self._close_timeout_ms if timeout_ms is None else timeout_ms
|
|
551
|
+
if isinstance(timeout,bool)or not isinstance(timeout,int)or not 1<=timeout<=60000:raise ValueError('close timeout is invalid')
|
|
552
|
+
if threading.current_thread()in(*self._workers,self._reader,self._web_socket_reader):raise RuntimeError("close must run outside this service's reader/handler threads")
|
|
553
|
+
with self._close_attempt(timeout)as deadline:
|
|
554
|
+
if self._closed:
|
|
555
|
+
if self._closed_error is not None:raise self._closed_error
|
|
556
|
+
self._raise_drain_failure(runtime_retained=False);return
|
|
557
|
+
if not self._stopping.is_set():
|
|
558
|
+
with self._close_lock:
|
|
559
|
+
if not self._draining.is_set():self._runtime.drain();self._draining.set()
|
|
560
|
+
remaining_ms=max(0,int((deadline-time.monotonic())*1000));self._drain_outcome=self._runtime.wait_drained(remaining_ms)
|
|
561
|
+
if self._drain_outcome.state is DrainState.PENDING:raise TimeoutError('timed out draining Python service')
|
|
562
|
+
try:self._runtime.stop()
|
|
563
|
+
except BaseException as error:self._raise_cleanup_failure(error)
|
|
564
|
+
self._stopping.set();self._finish_request_streams(RuntimeError('service has stopped'))
|
|
565
|
+
with self._response_stream_lock:
|
|
566
|
+
for state in self._response_streams.values():state.terminate(NativeError(int(ResultCode.CLOSED),'Core has stopped'))
|
|
567
|
+
with self._outbound_condition:
|
|
568
|
+
while self._outbound_waits!=0:
|
|
569
|
+
remaining=deadline-time.monotonic()
|
|
570
|
+
if remaining<=0:raise TimeoutError('timed out stopping outbound waiter')
|
|
571
|
+
self._outbound_condition.wait(remaining)
|
|
572
|
+
with self._monitor_condition:
|
|
573
|
+
while self._monitor_waits!=0:
|
|
574
|
+
remaining=deadline-time.monotonic()
|
|
575
|
+
if remaining<=0:raise TimeoutError('timed out stopping monitor waiter')
|
|
576
|
+
self._monitor_condition.wait(remaining)
|
|
577
|
+
self._join(self._reader,deadline,'Runtime event reader')
|
|
578
|
+
if self._web_socket_reader is not None:self._join(self._web_socket_reader,deadline,'Runtime WebSocket reader')
|
|
579
|
+
while self._workers_signalled<len(self._workers):
|
|
580
|
+
remaining=deadline-time.monotonic()
|
|
581
|
+
if remaining<=0:raise TimeoutError('timed out stopping Python handler workers')
|
|
582
|
+
try:self._queue.put(None,timeout=remaining);self._workers_signalled+=1
|
|
583
|
+
except queue.Full as error:raise TimeoutError('timed out stopping Python handler workers')from error
|
|
584
|
+
for worker in self._workers:self._join(worker,deadline,'Python handler worker')
|
|
585
|
+
with self._close_lock:
|
|
586
|
+
try:self._runtime.close()
|
|
587
|
+
except BaseException as error:self._raise_cleanup_failure(error)
|
|
588
|
+
if self._web_socket_dispatcher is not None:self._web_socket_dispatcher.shutdown()
|
|
589
|
+
try:
|
|
590
|
+
if self._cpu_owner is not None:self._cpu_owner.close(max(.0,deadline-time.monotonic()))
|
|
591
|
+
except TimeoutError:raise
|
|
592
|
+
except BaseException as cleanup_error:
|
|
593
|
+
try:self._raise_drain_failure(runtime_retained=False)
|
|
594
|
+
except ServiceCloseError as drain_error:self._closed_error=BaseExceptionGroup('drain and CPU cleanup failed',[drain_error,cleanup_error])
|
|
595
|
+
else:self._closed_error=cleanup_error
|
|
596
|
+
with self._close_lock:self._closed=True
|
|
597
|
+
raise self._closed_error
|
|
598
|
+
with self._close_lock:self._closed=True
|
|
599
|
+
self._raise_drain_failure(runtime_retained=False)
|
|
600
|
+
@contextmanager
|
|
601
|
+
def _close_attempt(self,timeout_ms:int)->Iterator[float]:
|
|
602
|
+
"Include waiting behind another close in this caller's finite budget.";deadline=time.monotonic()+timeout_ms/1000
|
|
603
|
+
if not self._close_serial.acquire(timeout=timeout_ms/1000):raise TimeoutError('timed out waiting for another close attempt')
|
|
604
|
+
try:yield deadline
|
|
605
|
+
finally:self._close_serial.release()
|
|
606
|
+
def _raise_drain_failure(self,*,runtime_retained:bool)->None:
|
|
607
|
+
if self._drain_outcome is not None and(self._drain_outcome.state is not DrainState.COMPLETE or self._drain_outcome.status!=int(ResultCode.OK)):raise ServiceCloseError(self._drain_outcome,runtime_retained=runtime_retained)
|
|
608
|
+
def _raise_cleanup_failure(self,cleanup_error:BaseException)->NoReturn:
|
|
609
|
+
'Keep a refused cleanup owner and both independent causal failures.'
|
|
610
|
+
try:self._raise_drain_failure(runtime_retained=True)
|
|
611
|
+
except ServiceCloseError as drain_error:raise BaseExceptionGroup('drain and native cleanup failed',[drain_error,cleanup_error])from None
|
|
612
|
+
raise cleanup_error
|
|
613
|
+
def __enter__(self)->Self:
|
|
614
|
+
'Return this running service for scoped ownership.'
|
|
615
|
+
with self._close_lock:
|
|
616
|
+
if self._closed:raise RuntimeError('service is closed')
|
|
617
|
+
return self
|
|
618
|
+
def __exit__(self,_type:object,_value:object,_traceback:object)->None:'Drain and destroy this service on scope exit.';self.close()
|
|
619
|
+
def _record(self,error:BaseException)->None:
|
|
620
|
+
original_type=type(error).__name__[:128]
|
|
621
|
+
try:encoded=str(error).encode('utf-8','replace')[:_ERROR_DETAIL_BYTES];detail=encoded.decode('utf-8','replace')
|
|
622
|
+
except BaseException:detail='diagnostic conversion failed'
|
|
623
|
+
with self._error_lock:self._errors.append(ServiceError(original_type,detail))
|
|
624
|
+
def _read_events(self)->None:
|
|
625
|
+
try:
|
|
626
|
+
with self._runtime._service_event_reader()as native_service:
|
|
627
|
+
while not self._stopping.is_set():
|
|
628
|
+
item=self._runtime._take_service_event_borrowed(native_service)
|
|
629
|
+
if item is None:continue
|
|
630
|
+
if isinstance(item,tuple):request,sequence,retained_bytes=item;self._open_request_stream(request,sequence);self._admit(request,retained_bytes);continue
|
|
631
|
+
if item.kind in(EventKind.TERMINAL,EventKind.REQUEST_CANCELLED):self._signal_response_stream(item.kind,item.exchange);self._signal_request_stream(item.kind,item.exchange,item.sequence,None)
|
|
632
|
+
elif item.kind is EventKind.REQUEST_DATA:self._signal_request_stream(item.kind,item.exchange,item.sequence,_RequestBodyData(item.data))
|
|
633
|
+
elif item.kind is EventKind.REQUEST_TRAILERS:self._signal_request_stream(item.kind,item.exchange,item.sequence,_RequestBodyTrailers(item.stream_trailers))
|
|
634
|
+
elif item.kind is EventKind.REQUEST_END:self._signal_request_stream(item.kind,item.exchange,item.sequence,_REQUEST_BODY_END)
|
|
635
|
+
elif item.kind is EventKind.RESPONSE_WRITABLE:self._signal_response_stream(item.kind,item.exchange)
|
|
636
|
+
except NativeError as error:
|
|
637
|
+
if self._stopping.is_set()or self._draining.is_set()and error.status==int(ResultCode.CLOSED):return
|
|
638
|
+
self._fail_reader(error)
|
|
639
|
+
except BaseException as error:self._fail_reader(error)
|
|
640
|
+
def _read_web_sockets(self)->None:
|
|
641
|
+
dispatcher=self._web_socket_dispatcher
|
|
642
|
+
if dispatcher is None:return
|
|
643
|
+
while not self._stopping.is_set():
|
|
644
|
+
try:
|
|
645
|
+
event=self._runtime.take_websocket(100)
|
|
646
|
+
if event is not None:dispatcher.handle(event)
|
|
647
|
+
except NativeError as error:
|
|
648
|
+
if self._stopping.is_set()or self._draining.is_set()and error.status==int(ResultCode.CLOSED):return
|
|
649
|
+
self._record(error);return
|
|
650
|
+
except BaseException as error:self._record(error);return
|
|
651
|
+
def _fail_reader(self,error:BaseException)->None:
|
|
652
|
+
self._record(error);self._finish_request_streams(RuntimeError('request event reader failed'))
|
|
653
|
+
try:self._runtime.drain()
|
|
654
|
+
except BaseException as drain_error:self._record(drain_error)
|
|
655
|
+
def _open_request_stream(self,request:Request,sequence:int)->None:
|
|
656
|
+
if request.body_delivery is not BodyDelivery.STREAM:return
|
|
657
|
+
stream=_RequestBodyReader(request,self._request_stream_capacity,sequence)
|
|
658
|
+
with self._request_stream_lock:
|
|
659
|
+
if request.exchange in self._request_streams:raise RuntimeError('request body stream is already registered')
|
|
660
|
+
self._request_streams[request.exchange]=stream
|
|
661
|
+
request._attach_body_reader(stream)
|
|
662
|
+
def _signal_request_stream(self,kind:EventKind,exchange:ExchangeId,sequence:int,item:_RequestBodyItem|None)->None:
|
|
663
|
+
with self._request_stream_lock:
|
|
664
|
+
stream=self._request_streams.get(exchange)
|
|
665
|
+
if kind in(EventKind.REQUEST_END,EventKind.REQUEST_CANCELLED,EventKind.TERMINAL):self._request_streams.pop(exchange,None)
|
|
666
|
+
if stream is None:
|
|
667
|
+
if kind not in(EventKind.REQUEST_CANCELLED,EventKind.TERMINAL):self._record(RuntimeError('request body event has no active stream'))
|
|
668
|
+
return
|
|
669
|
+
if kind in(EventKind.REQUEST_CANCELLED,EventKind.TERMINAL):detail='request body stream was cancelled'if kind is EventKind.REQUEST_CANCELLED else'request body stream reached terminal state';stream.finish(RuntimeError(detail))
|
|
670
|
+
elif item is None:self._record(RuntimeError('request body data event is empty'))
|
|
671
|
+
elif not stream.offer(sequence,item):
|
|
672
|
+
self._record(RuntimeError('request body stream rejected an event'))
|
|
673
|
+
try:self._runtime.respond(exchange,Response(status=500,body=b'internal server error'))
|
|
674
|
+
except BaseException as error:self._record(error)
|
|
675
|
+
def _finish_request_streams(self,error:BaseException)->None:
|
|
676
|
+
with self._request_stream_lock:streams=tuple(self._request_streams.values())
|
|
677
|
+
for stream in streams:stream.finish(error)
|
|
678
|
+
def _admit(self,request:Request,retained_bytes:int)->None:
|
|
679
|
+
with self._handler_lock:handler=self._handlers.get(request.handler_binding_id)
|
|
680
|
+
if handler is None:self._reject(request,RuntimeError('request has no Python handler'));return
|
|
681
|
+
with self._retention_lock:
|
|
682
|
+
if retained_bytes>self._retained_byte_capacity or self._retained_bytes>self._retained_byte_capacity-retained_bytes:overloaded=True
|
|
683
|
+
else:overloaded=False;self._retained_bytes+=retained_bytes
|
|
684
|
+
if overloaded:self._reject(request,RuntimeError('Python dispatch byte capacity is full'),503);return
|
|
685
|
+
work:_WorkItem=(handler,request,retained_bytes)
|
|
686
|
+
try:self._queue.put(work,block=False)
|
|
687
|
+
except queue.Full:
|
|
688
|
+
with self._retention_lock:self._retained_bytes-=retained_bytes
|
|
689
|
+
self._reject(request,RuntimeError('Python dispatch queue is full'),503)
|
|
690
|
+
def _reject(self,request:Request,error:BaseException,status:int=500)->None:
|
|
691
|
+
self._record(error)
|
|
692
|
+
try:detail=b'service unavailable'if status==503 else b'internal server error';self._runtime.respond(request.exchange,Response(status=status,body=detail))
|
|
693
|
+
except BaseException as submission_error:
|
|
694
|
+
if not _retired_submission(submission_error):self._record(submission_error)
|
|
695
|
+
def _run_worker(self)->None:
|
|
696
|
+
while True:
|
|
697
|
+
work=self._queue.get()
|
|
698
|
+
if work is None:return
|
|
699
|
+
try:
|
|
700
|
+
handler,request,_retained_bytes=work
|
|
701
|
+
try:
|
|
702
|
+
response=handler(request)
|
|
703
|
+
if not isinstance(response,(Response,FileResponse,StreamingResponse,ServerSentEventsResponse,WebSocketResponse)):raise TypeError('a CoAkka HTTP handler returned an unsupported response')
|
|
704
|
+
except BaseException as error:self._reject(request,error)
|
|
705
|
+
else:
|
|
706
|
+
try:
|
|
707
|
+
if isinstance(response,WebSocketResponse):
|
|
708
|
+
dispatcher=self._web_socket_dispatcher
|
|
709
|
+
if dispatcher is None:raise RuntimeError('handler returned an undeclared WebSocket response')
|
|
710
|
+
try:dispatcher.accept(request.exchange,response)
|
|
711
|
+
except BaseException as error:self._reject(request,error)
|
|
712
|
+
elif isinstance(response,FileResponse):self._runtime.respond_file(request.exchange,response)
|
|
713
|
+
elif isinstance(response,ServerSentEventsResponse):self._server_sent_events_response(request.exchange,response)
|
|
714
|
+
elif isinstance(response,StreamingResponse):self._stream_response(request.exchange,response)
|
|
715
|
+
else:self._runtime.respond(request.exchange,response)
|
|
716
|
+
except BaseException as error:
|
|
717
|
+
if not _retired_submission(error):self._record(error)
|
|
718
|
+
finally:
|
|
719
|
+
work[1].body_reader.close()
|
|
720
|
+
with self._retention_lock:self._retained_bytes-=work[2]
|
|
721
|
+
def _signal_response_stream(self,kind:EventKind,exchange:ExchangeId)->None:
|
|
722
|
+
with self._response_stream_lock:
|
|
723
|
+
state=self._response_streams.get(exchange)
|
|
724
|
+
if kind is EventKind.TERMINAL:self._response_streams.pop(exchange,None)
|
|
725
|
+
if state is None:return
|
|
726
|
+
if kind is EventKind.RESPONSE_WRITABLE:state.grant_writable()
|
|
727
|
+
elif kind is EventKind.REQUEST_CANCELLED:state.terminate(RuntimeError('response stream request was cancelled'))
|
|
728
|
+
else:state.terminate(RuntimeError('response stream reached terminal state'))
|
|
729
|
+
def _stream_response(self,exchange:ExchangeId,response:StreamingResponse)->None:
|
|
730
|
+
state=_ResponseStreamState()
|
|
731
|
+
with self._response_stream_lock:
|
|
732
|
+
if exchange in self._response_streams:raise RuntimeError('response stream is already registered')
|
|
733
|
+
self._response_streams[exchange]=state
|
|
734
|
+
try:
|
|
735
|
+
try:self._runtime.start_response_stream(exchange,Response(status=response.status,headers=response.headers))
|
|
736
|
+
except BaseException as error:
|
|
737
|
+
if _retired_submission(error):return
|
|
738
|
+
self._record(error)
|
|
739
|
+
try:self._runtime.respond(exchange,Response(status=500,body=b'internal server error'))
|
|
740
|
+
except BaseException as fallback_error:self._record(fallback_error)
|
|
741
|
+
return
|
|
742
|
+
state.await_writable(self._response_write_timeout_ms);writer=ResponseStreamWriter(self._runtime,exchange,state,self._response_write_timeout_ms)
|
|
743
|
+
try:
|
|
744
|
+
trailers=response.producer(writer)
|
|
745
|
+
if not isinstance(trailers,Headers):raise TypeError('response stream producer must return Headers')
|
|
746
|
+
except BaseException as error:self._record(error);trailers=_EMPTY_HEADERS
|
|
747
|
+
finally:writer._close()
|
|
748
|
+
try:self._finish_response_stream(exchange,trailers,state)
|
|
749
|
+
except BaseException as error:
|
|
750
|
+
if not _retired_submission(error):self._record(error)
|
|
751
|
+
finally:
|
|
752
|
+
with self._response_stream_lock:
|
|
753
|
+
if self._response_streams.get(exchange)is state:del self._response_streams[exchange]
|
|
754
|
+
def _server_sent_events_response(self,exchange:ExchangeId,response:ServerSentEventsResponse)->None:
|
|
755
|
+
state=_ResponseStreamState()
|
|
756
|
+
with self._response_stream_lock:
|
|
757
|
+
if exchange in self._response_streams:raise RuntimeError('response stream is already registered')
|
|
758
|
+
self._response_streams[exchange]=state
|
|
759
|
+
try:
|
|
760
|
+
try:self._runtime.start_sse(exchange,response.headers)
|
|
761
|
+
except BaseException as error:
|
|
762
|
+
self._record(error)
|
|
763
|
+
try:self._runtime.respond(exchange,Response(status=500,body=b'internal server error'))
|
|
764
|
+
except BaseException as fallback_error:self._record(fallback_error)
|
|
765
|
+
return
|
|
766
|
+
state.await_writable(self._response_write_timeout_ms);writer=ServerSentEventsWriter(self._runtime,exchange,state,self._response_write_timeout_ms)
|
|
767
|
+
try:response.producer(writer)
|
|
768
|
+
except BaseException as error:self._record(error)
|
|
769
|
+
finally:writer._close()
|
|
770
|
+
try:self._finish_response_stream(exchange,_EMPTY_HEADERS,state)
|
|
771
|
+
except BaseException as error:
|
|
772
|
+
if not _retired_submission(error):self._record(error)
|
|
773
|
+
finally:
|
|
774
|
+
with self._response_stream_lock:
|
|
775
|
+
if self._response_streams.get(exchange)is state:del self._response_streams[exchange]
|
|
776
|
+
def _finish_response_stream(self,exchange:ExchangeId,trailers:Headers,state:_ResponseStreamState)->None:
|
|
777
|
+
'Retry only Core-declared FINAL pressure after its recovery doorbell.'
|
|
778
|
+
while True:
|
|
779
|
+
try:self._runtime.finish_response_stream(exchange,trailers);return
|
|
780
|
+
except NativeError as error:
|
|
781
|
+
if error.status!=int(ResultCode.CAPACITY_EXHAUSTED):raise
|
|
782
|
+
state.await_writable(self._response_write_timeout_ms)
|
|
783
|
+
@staticmethod
|
|
784
|
+
def _join(thread:threading.Thread,deadline:float,owner:str)->None:
|
|
785
|
+
if not thread.is_alive():return
|
|
786
|
+
remaining=deadline-time.monotonic()
|
|
787
|
+
if remaining<=0:raise TimeoutError(f"timed out joining {owner}")
|
|
788
|
+
thread.join(remaining)
|
|
789
|
+
if thread.is_alive():raise TimeoutError(f"timed out joining {owner}")
|