magpie-gemini-web 0.3.3 → 0.4.1
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.
- package/README.en.md +31 -19
- package/README.md +31 -19
- package/docs/backend-release.md +31 -0
- package/index.mjs +15 -1
- package/lib/backend-release.json +36 -0
- package/lib/backend.mjs +5 -3
- package/lib/native.mjs +50 -18
- package/lib/task.mjs +87 -0
- package/native-task/LICENSE +21 -0
- package/native-task/README.md +89 -0
- package/native-task/bridge/gemini_native_bridge/__init__.py +3 -0
- package/native-task/bridge/gemini_native_bridge/__main__.py +235 -0
- package/native-task/bridge/gemini_native_bridge/auth.py +143 -0
- package/native-task/bridge/gemini_native_bridge/config.py +222 -0
- package/native-task/bridge/gemini_native_bridge/contract.py +283 -0
- package/native-task/bridge/gemini_native_bridge/native_auth_keychain.swift +111 -0
- package/native-task/bridge/gemini_native_bridge/oauth.py +154 -0
- package/native-task/bridge/gemini_native_bridge/protocol.py +363 -0
- package/native-task/bridge/gemini_native_bridge/server.py +441 -0
- package/native-task/bridge/gemini_native_bridge/state.py +806 -0
- package/native-task/bridge/gemini_native_bridge/transport.py +531 -0
- package/native-task/index.mjs +76 -0
- package/native-task/lib/runtime.mjs +189 -0
- package/native-task/pyproject.toml +18 -0
- package/native-task/requirements.txt +2 -0
- package/native-task/scripts/bridge.mjs +97 -0
- package/package.json +15 -3
- package/scripts/backend-release.mjs +111 -0
- package/scripts/backend.mjs +4 -1
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""HTTP server implementing local OpenAI-compatible endpoints."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import socket
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import urllib.parse
|
|
11
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .config import RuntimeConfig
|
|
15
|
+
from .contract import BridgeError, MAX_REQUEST_BYTES, json_loads, parse_request
|
|
16
|
+
from .protocol import MODEL_ID
|
|
17
|
+
from .state import Manager
|
|
18
|
+
|
|
19
|
+
DIGITS = re.compile(r"^[0-9]+$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BridgeHandler(BaseHTTPRequestHandler):
|
|
23
|
+
protocol_version = "HTTP/1.1"
|
|
24
|
+
|
|
25
|
+
def setup(self):
|
|
26
|
+
super().setup()
|
|
27
|
+
if hasattr(self, "connection") and hasattr(self.connection, "settimeout"):
|
|
28
|
+
self.connection.settimeout(15.0)
|
|
29
|
+
|
|
30
|
+
def log_message(self, format: str, *args: Any):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def server_instance(self) -> BridgeServer:
|
|
35
|
+
return self.server # type: ignore
|
|
36
|
+
|
|
37
|
+
def _send_error_json(self, status: int, code: str, message: str, sign_in_kept: bool = True):
|
|
38
|
+
body = json.dumps({
|
|
39
|
+
"error": {
|
|
40
|
+
"code": code,
|
|
41
|
+
"type": "invalid_request_error" if status < 500 else "api_error",
|
|
42
|
+
"message": message,
|
|
43
|
+
"param": None,
|
|
44
|
+
}
|
|
45
|
+
}).encode("utf-8")
|
|
46
|
+
try:
|
|
47
|
+
self.send_response(status)
|
|
48
|
+
self.send_header("Content-Type", "application/json")
|
|
49
|
+
self.send_header("Content-Length", str(len(body)))
|
|
50
|
+
self.send_header("Connection", "close")
|
|
51
|
+
if sign_in_kept:
|
|
52
|
+
self.send_header("X-Magpie-Sign-In", "kept")
|
|
53
|
+
else:
|
|
54
|
+
self.send_header("X-Magpie-Sign-In", "expired")
|
|
55
|
+
self.end_headers()
|
|
56
|
+
self.wfile.write(body)
|
|
57
|
+
self.wfile.flush()
|
|
58
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
def _check_auth(self) -> bool:
|
|
62
|
+
auth_hdrs = self.headers.get_all("Authorization")
|
|
63
|
+
if not auth_hdrs or len(auth_hdrs) > 1:
|
|
64
|
+
self._send_error_json(401, "unauthorized", "Missing or duplicate Authorization header.", sign_in_kept=False)
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
auth_hdr = auth_hdrs[0]
|
|
68
|
+
if not auth_hdr.startswith("Bearer "):
|
|
69
|
+
self._send_error_json(401, "unauthorized", "Authorization header must use Bearer scheme.", sign_in_kept=False)
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
token = auth_hdr[7:].strip()
|
|
73
|
+
expected = self.server_instance.config.api_key
|
|
74
|
+
if not hmac.compare_digest(token.encode("utf-8"), expected.encode("utf-8")):
|
|
75
|
+
self._send_error_json(401, "unauthorized", "Invalid local API key.", sign_in_kept=False)
|
|
76
|
+
return False
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
def _read_body_json(self) -> dict | None:
|
|
80
|
+
te_hdrs = self.headers.get_all("Transfer-Encoding")
|
|
81
|
+
if te_hdrs:
|
|
82
|
+
self._send_error_json(400, "unsupported_transfer_encoding", "Transfer-Encoding is not supported.")
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
cl_hdrs = self.headers.get_all("Content-Length")
|
|
86
|
+
if not cl_hdrs:
|
|
87
|
+
self._send_error_json(400, "missing_content_length", "Content-Length header is required.")
|
|
88
|
+
return None
|
|
89
|
+
if len(cl_hdrs) > 1:
|
|
90
|
+
self._send_error_json(400, "duplicate_content_length", "Duplicate Content-Length headers are not permitted.")
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
cl_str = cl_hdrs[0].strip()
|
|
94
|
+
if len(cl_str) > 7:
|
|
95
|
+
self._send_error_json(400, "request_too_large", f"Content-Length exceeds {MAX_REQUEST_BYTES} bytes limit.")
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
if not DIGITS.fullmatch(cl_str):
|
|
99
|
+
self._send_error_json(400, "invalid_content_length", "Content-Length must be an unsigned integer.")
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
length = int(cl_str)
|
|
103
|
+
if length > MAX_REQUEST_BYTES:
|
|
104
|
+
self._send_error_json(400, "request_too_large", f"Content-Length exceeds {MAX_REQUEST_BYTES} bytes limit.")
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
raw = self.rfile.read(length)
|
|
109
|
+
if len(raw) != length:
|
|
110
|
+
self._send_error_json(400, "truncated_body", "Request body was truncated.")
|
|
111
|
+
return None
|
|
112
|
+
parsed = json_loads(raw.decode("utf-8"))
|
|
113
|
+
if parsed is None or not isinstance(parsed, dict):
|
|
114
|
+
self._send_error_json(400, "invalid_json", "Request body must be valid JSON.")
|
|
115
|
+
return None
|
|
116
|
+
return parsed
|
|
117
|
+
except UnicodeError:
|
|
118
|
+
self._send_error_json(400, "invalid_utf8", "Request body must be valid UTF-8.")
|
|
119
|
+
return None
|
|
120
|
+
except Exception:
|
|
121
|
+
self._send_error_json(400, "invalid_json", "Request body must be valid JSON.")
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
def do_GET(self):
|
|
125
|
+
if not self._check_auth():
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
129
|
+
path = parsed.path
|
|
130
|
+
|
|
131
|
+
if path == "/health":
|
|
132
|
+
body = json.dumps({
|
|
133
|
+
"service": "gemini-native-task",
|
|
134
|
+
"version": "0.1.0",
|
|
135
|
+
"instance_id": self.server_instance.config.instance_id,
|
|
136
|
+
"active_tasks": 1 if self.server_instance.manager._active_task_id else 0,
|
|
137
|
+
}).encode("utf-8")
|
|
138
|
+
self.send_response(200)
|
|
139
|
+
self.send_header("Content-Type", "application/json")
|
|
140
|
+
self.send_header("Content-Length", str(len(body)))
|
|
141
|
+
self.send_header("Connection", "close")
|
|
142
|
+
self.end_headers()
|
|
143
|
+
self.wfile.write(body)
|
|
144
|
+
self.wfile.flush()
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
if path == "/v1/models":
|
|
148
|
+
body = json.dumps({
|
|
149
|
+
"object": "list",
|
|
150
|
+
"data": [{
|
|
151
|
+
"id": MODEL_ID,
|
|
152
|
+
"object": "model",
|
|
153
|
+
"created": self.server_instance.created_time,
|
|
154
|
+
"owned_by": "gemini-native-task",
|
|
155
|
+
}],
|
|
156
|
+
}).encode("utf-8")
|
|
157
|
+
self.send_response(200)
|
|
158
|
+
self.send_header("Content-Type", "application/json")
|
|
159
|
+
self.send_header("Content-Length", str(len(body)))
|
|
160
|
+
self.send_header("Connection", "close")
|
|
161
|
+
self.end_headers()
|
|
162
|
+
self.wfile.write(body)
|
|
163
|
+
self.wfile.flush()
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
if path.startswith("/v1/native/sessions/"):
|
|
167
|
+
raw_session = path[len("/v1/native/sessions/"):]
|
|
168
|
+
session_id = urllib.parse.unquote(raw_session)
|
|
169
|
+
try:
|
|
170
|
+
status_data = self.server_instance.manager.status(session_id)
|
|
171
|
+
body = json.dumps(status_data).encode("utf-8")
|
|
172
|
+
self.send_response(200)
|
|
173
|
+
self.send_header("Content-Type", "application/json")
|
|
174
|
+
self.send_header("Content-Length", str(len(body)))
|
|
175
|
+
self.send_header("Connection", "close")
|
|
176
|
+
self.end_headers()
|
|
177
|
+
self.wfile.write(body)
|
|
178
|
+
self.wfile.flush()
|
|
179
|
+
except BridgeError as be:
|
|
180
|
+
self._send_error_json(be.status, be.code, be.message)
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
self._send_error_json(404, "not_found", f"Path {path} not found.")
|
|
184
|
+
|
|
185
|
+
def do_POST(self):
|
|
186
|
+
if not self._check_auth():
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
190
|
+
path = parsed.path
|
|
191
|
+
|
|
192
|
+
if path == "/v1/native/shutdown":
|
|
193
|
+
body_dict = self._read_body_json()
|
|
194
|
+
if body_dict is None:
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
body = json.dumps({"status": "shutting_down"}).encode("utf-8")
|
|
198
|
+
self.send_response(202)
|
|
199
|
+
self.send_header("Content-Type", "application/json")
|
|
200
|
+
self.send_header("Content-Length", str(len(body)))
|
|
201
|
+
self.send_header("Connection", "close")
|
|
202
|
+
self.end_headers()
|
|
203
|
+
self.wfile.write(body)
|
|
204
|
+
self.wfile.flush()
|
|
205
|
+
threading.Thread(target=self.server_instance.stop, daemon=True).start()
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
if path.startswith("/v1/native/sessions/") and path.endswith("/cancel"):
|
|
209
|
+
raw_session = path[len("/v1/native/sessions/"):-len("/cancel")]
|
|
210
|
+
session_id = urllib.parse.unquote(raw_session)
|
|
211
|
+
body_dict = self._read_body_json()
|
|
212
|
+
if body_dict is None:
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
res = self.server_instance.manager.cancel(session_id)
|
|
217
|
+
body = json.dumps(res).encode("utf-8")
|
|
218
|
+
self.send_response(202)
|
|
219
|
+
self.send_header("Content-Type", "application/json")
|
|
220
|
+
self.send_header("Content-Length", str(len(body)))
|
|
221
|
+
self.send_header("Connection", "close")
|
|
222
|
+
self.end_headers()
|
|
223
|
+
self.wfile.write(body)
|
|
224
|
+
self.wfile.flush()
|
|
225
|
+
except BridgeError as be:
|
|
226
|
+
self._send_error_json(be.status, be.code, be.message)
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
if path == "/v1/chat/completions":
|
|
230
|
+
gemini_sessions = self.headers.get_all("X-Gemini-Session") or []
|
|
231
|
+
magpie_sessions = self.headers.get_all("X-Magpie-Session") or []
|
|
232
|
+
|
|
233
|
+
if len(gemini_sessions) > 1 or len(magpie_sessions) > 1:
|
|
234
|
+
self._send_error_json(400, "duplicate_session_header", "Duplicate session headers provided.")
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
sess_gemini = gemini_sessions[0] if gemini_sessions else None
|
|
238
|
+
sess_magpie = magpie_sessions[0] if magpie_sessions else None
|
|
239
|
+
|
|
240
|
+
if sess_gemini and sess_magpie and sess_gemini != sess_magpie:
|
|
241
|
+
self._send_error_json(400, "session_header_conflict", "X-Gemini-Session and X-Magpie-Session must match.")
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
session_id = sess_gemini or sess_magpie
|
|
245
|
+
if not session_id:
|
|
246
|
+
self._send_error_json(400, "missing_session", "Provide a stable session ID via X-Gemini-Session header.")
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
body_dict = self._read_body_json()
|
|
250
|
+
if body_dict is None:
|
|
251
|
+
return
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
comp_req = parse_request(body_dict)
|
|
255
|
+
ticket = self.server_instance.manager.submit(session_id, comp_req)
|
|
256
|
+
except BridgeError as be:
|
|
257
|
+
self._send_error_json(be.status, be.code, be.message)
|
|
258
|
+
return
|
|
259
|
+
except Exception:
|
|
260
|
+
self._send_error_json(409, "submission_failed", "Failed to submit completion request.")
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
if not comp_req.stream:
|
|
264
|
+
try:
|
|
265
|
+
result = ticket.result(timeout=self.server_instance.config.task_timeout)
|
|
266
|
+
resp_bytes = json.dumps(result, ensure_ascii=False).encode("utf-8")
|
|
267
|
+
self.send_response(200)
|
|
268
|
+
self.send_header("Content-Type", "application/json")
|
|
269
|
+
self.send_header("Content-Length", str(len(resp_bytes)))
|
|
270
|
+
self.send_header("Connection", "close")
|
|
271
|
+
self.end_headers()
|
|
272
|
+
self.wfile.write(resp_bytes)
|
|
273
|
+
self.wfile.flush()
|
|
274
|
+
except BridgeError as be:
|
|
275
|
+
self._send_error_json(be.status, be.code, be.message)
|
|
276
|
+
except TimeoutError:
|
|
277
|
+
self.server_instance.manager.abandon_delivery(session_id, ticket.task_id, comp_req.digest)
|
|
278
|
+
self._send_error_json(409, "task_timeout", "Task timed out waiting for completion.")
|
|
279
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
280
|
+
self.server_instance.manager.abandon_delivery(session_id, ticket.task_id, comp_req.digest)
|
|
281
|
+
except Exception:
|
|
282
|
+
self._send_error_json(409, "execution_failed", "Task execution failed.")
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
# Streaming response (SSE)
|
|
286
|
+
try:
|
|
287
|
+
self.send_response(200)
|
|
288
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
289
|
+
self.send_header("Cache-Control", "no-cache")
|
|
290
|
+
self.send_header("Connection", "close")
|
|
291
|
+
self.send_header("X-Magpie-Sign-In", "kept")
|
|
292
|
+
self.end_headers()
|
|
293
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
294
|
+
self.server_instance.manager.abandon_delivery(session_id, ticket.task_id, comp_req.digest)
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
disconnected = False
|
|
298
|
+
stream_deadline = time.monotonic() + self.server_instance.config.task_timeout
|
|
299
|
+
interval = self.server_instance.heartbeat_interval
|
|
300
|
+
|
|
301
|
+
while not ticket.event.wait(timeout=interval):
|
|
302
|
+
if time.monotonic() >= stream_deadline:
|
|
303
|
+
self.server_instance.manager.abandon_delivery(session_id, ticket.task_id, comp_req.digest)
|
|
304
|
+
try:
|
|
305
|
+
err_chunk = f"data: {json.dumps({'error': {'code': 'task_timeout', 'type': 'api_error', 'message': 'Task timed out.'}})}\n\ndata: [DONE]\n\n".encode("utf-8")
|
|
306
|
+
self.wfile.write(err_chunk)
|
|
307
|
+
self.wfile.flush()
|
|
308
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
309
|
+
pass
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
self.wfile.write(b": keepalive\n\n")
|
|
314
|
+
self.wfile.flush()
|
|
315
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
316
|
+
disconnected = True
|
|
317
|
+
break
|
|
318
|
+
|
|
319
|
+
if disconnected:
|
|
320
|
+
self.server_instance.manager.abandon_delivery(session_id, ticket.task_id, comp_req.digest)
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
if ticket.error is not None:
|
|
324
|
+
err_chunk = f"data: {json.dumps(ticket.error.body())}\n\ndata: [DONE]\n\n".encode("utf-8")
|
|
325
|
+
try:
|
|
326
|
+
self.wfile.write(err_chunk)
|
|
327
|
+
self.wfile.flush()
|
|
328
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
329
|
+
self.server_instance.manager.abandon_delivery(session_id, ticket.task_id, comp_req.digest)
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
resp = ticket.response
|
|
333
|
+
if resp is None:
|
|
334
|
+
err_chunk = f"data: {json.dumps({'error': {'code': 'no_response', 'type': 'api_error', 'message': 'Empty ticket response.'}})}\n\ndata: [DONE]\n\n".encode("utf-8")
|
|
335
|
+
try:
|
|
336
|
+
self.wfile.write(err_chunk)
|
|
337
|
+
self.wfile.flush()
|
|
338
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
339
|
+
self.server_instance.manager.abandon_delivery(session_id, ticket.task_id, comp_req.digest)
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
resp_id = resp["id"]
|
|
343
|
+
created = resp["created"]
|
|
344
|
+
choice = resp["choices"][0]
|
|
345
|
+
finish_reason = choice["finish_reason"]
|
|
346
|
+
msg = choice["message"]
|
|
347
|
+
|
|
348
|
+
chunks = [
|
|
349
|
+
{
|
|
350
|
+
"id": resp_id,
|
|
351
|
+
"object": "chat.completion.chunk",
|
|
352
|
+
"created": created,
|
|
353
|
+
"model": MODEL_ID,
|
|
354
|
+
"choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}],
|
|
355
|
+
}
|
|
356
|
+
]
|
|
357
|
+
|
|
358
|
+
if msg.get("tool_calls"):
|
|
359
|
+
tc = msg["tool_calls"][0]
|
|
360
|
+
chunks.append({
|
|
361
|
+
"id": resp_id,
|
|
362
|
+
"object": "chat.completion.chunk",
|
|
363
|
+
"created": created,
|
|
364
|
+
"model": MODEL_ID,
|
|
365
|
+
"choices": [{
|
|
366
|
+
"index": 0,
|
|
367
|
+
"delta": {
|
|
368
|
+
"tool_calls": [{
|
|
369
|
+
"index": 0,
|
|
370
|
+
"id": tc["id"],
|
|
371
|
+
"type": "function",
|
|
372
|
+
"function": {
|
|
373
|
+
"name": tc["function"]["name"],
|
|
374
|
+
"arguments": tc["function"]["arguments"],
|
|
375
|
+
},
|
|
376
|
+
}]
|
|
377
|
+
},
|
|
378
|
+
"finish_reason": None,
|
|
379
|
+
}],
|
|
380
|
+
})
|
|
381
|
+
elif msg.get("content"):
|
|
382
|
+
chunks.append({
|
|
383
|
+
"id": resp_id,
|
|
384
|
+
"object": "chat.completion.chunk",
|
|
385
|
+
"created": created,
|
|
386
|
+
"model": MODEL_ID,
|
|
387
|
+
"choices": [{"index": 0, "delta": {"content": msg["content"]}, "finish_reason": None}],
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
chunks.append({
|
|
391
|
+
"id": resp_id,
|
|
392
|
+
"object": "chat.completion.chunk",
|
|
393
|
+
"created": created,
|
|
394
|
+
"model": MODEL_ID,
|
|
395
|
+
"choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason}],
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
for chunk in chunks:
|
|
400
|
+
self.wfile.write(f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n".encode("utf-8"))
|
|
401
|
+
self.wfile.write(b"data: [DONE]\n\n")
|
|
402
|
+
self.wfile.flush()
|
|
403
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
404
|
+
self.server_instance.manager.abandon_delivery(session_id, ticket.task_id, comp_req.digest)
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
self._send_error_json(404, "not_found", f"Path {path} not found.")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class BridgeServer(ThreadingHTTPServer):
|
|
411
|
+
def __init__(
|
|
412
|
+
self,
|
|
413
|
+
config: RuntimeConfig,
|
|
414
|
+
manager: Manager | None = None,
|
|
415
|
+
bind_port: int | None = None,
|
|
416
|
+
heartbeat_interval: float = 10.0,
|
|
417
|
+
):
|
|
418
|
+
self.config = config
|
|
419
|
+
self.manager = manager or Manager(config)
|
|
420
|
+
self.created_time = int(time.time())
|
|
421
|
+
self.heartbeat_interval = heartbeat_interval
|
|
422
|
+
self._stopped_event = threading.Event()
|
|
423
|
+
self._stop_lock = threading.Lock()
|
|
424
|
+
port = config.port if bind_port is None else bind_port
|
|
425
|
+
super().__init__(("127.0.0.1", port), BridgeHandler)
|
|
426
|
+
self.port = self.server_address[1]
|
|
427
|
+
|
|
428
|
+
def handle_error(self, request, client_address):
|
|
429
|
+
# Override to prevent emitting tracebacks to stdout/stderr
|
|
430
|
+
pass
|
|
431
|
+
|
|
432
|
+
def stop(self):
|
|
433
|
+
with self._stop_lock:
|
|
434
|
+
if self._stopped_event.is_set():
|
|
435
|
+
return
|
|
436
|
+
self._stopped_event.set()
|
|
437
|
+
try:
|
|
438
|
+
self.manager.close()
|
|
439
|
+
finally:
|
|
440
|
+
self.shutdown()
|
|
441
|
+
self.server_close()
|