fixtureqa 0.1.8__tar.gz → 0.3.0__tar.gz
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.
- {fixtureqa-0.1.8/fixtureqa.egg-info → fixtureqa-0.3.0}/PKG-INFO +2 -2
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/__main__.py +8 -1
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/app.py +43 -2
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/connection_manager.py +95 -6
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/deps.py +12 -0
- fixtureqa-0.3.0/fixture/api/routers/perf.py +245 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/sessions.py +17 -23
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/ws.py +28 -10
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/schemas.py +9 -0
- fixtureqa-0.3.0/fixture/core/atomic_io.py +38 -0
- fixtureqa-0.3.0/fixture/core/auth.py +105 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/config_store.py +25 -6
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/custom_tag_store.py +3 -6
- fixtureqa-0.3.0/fixture/core/db_migrations.py +60 -0
- fixtureqa-0.3.0/fixture/core/fix_application.py +119 -0
- fixtureqa-0.3.0/fixture/core/fix_builder.py +132 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/housekeeping.py +11 -2
- fixtureqa-0.3.0/fixture/core/inbound.py +104 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/message_log.py +4 -1
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/message_store.py +130 -41
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/models.py +23 -0
- fixtureqa-0.3.0/fixture/core/perf_engine.py +741 -0
- fixtureqa-0.3.0/fixture/core/perf_models.py +205 -0
- fixtureqa-0.3.0/fixture/core/perf_payload.py +387 -0
- fixtureqa-0.3.0/fixture/core/perf_stats.py +153 -0
- fixtureqa-0.3.0/fixture/core/perf_store.py +46 -0
- fixtureqa-0.3.0/fixture/core/perf_writer.py +285 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/scenario_runner.py +37 -30
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/scenario_store.py +3 -7
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/session.py +99 -26
- fixtureqa-0.3.0/fixture/core/session_manager.py +258 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/spec_overlay_store.py +3 -6
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/template_store.py +3 -7
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/user_store.py +6 -0
- fixtureqa-0.3.0/fixture/static/assets/index-CLIVc6MX.js +102 -0
- fixtureqa-0.1.8/fixture/static/assets/index-Dv0_KeqF.css → fixtureqa-0.3.0/fixture/static/assets/index-WkbDd3YE.css +1 -1
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/static/index.html +2 -2
- {fixtureqa-0.1.8 → fixtureqa-0.3.0/fixtureqa.egg-info}/PKG-INFO +2 -2
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixtureqa.egg-info/SOURCES.txt +31 -3
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixtureqa.egg-info/requires.txt +1 -1
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/pyproject.toml +2 -2
- fixtureqa-0.3.0/tests/test_atomic_io.py +70 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/tests/test_auth.py +28 -0
- fixtureqa-0.3.0/tests/test_connection_manager.py +87 -0
- fixtureqa-0.3.0/tests/test_db_migrations.py +65 -0
- fixtureqa-0.3.0/tests/test_fix_builder.py +80 -0
- fixtureqa-0.3.0/tests/test_health.py +8 -0
- fixtureqa-0.3.0/tests/test_inbound.py +105 -0
- fixtureqa-0.3.0/tests/test_inbound_validation.py +96 -0
- fixtureqa-0.3.0/tests/test_message_store.py +126 -0
- fixtureqa-0.3.0/tests/test_perf_api.py +135 -0
- fixtureqa-0.3.0/tests/test_perf_engine.py +366 -0
- fixtureqa-0.3.0/tests/test_perf_models.py +65 -0
- fixtureqa-0.3.0/tests/test_perf_payload.py +213 -0
- fixtureqa-0.3.0/tests/test_perf_rehydrate.py +56 -0
- fixtureqa-0.3.0/tests/test_scenarios.py +75 -0
- fixtureqa-0.3.0/tests/test_session_lifecycle.py +82 -0
- fixtureqa-0.3.0/tests/test_session_manager_concurrency.py +90 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/tests/test_sessions.py +44 -0
- fixtureqa-0.3.0/tests/test_ws.py +54 -0
- fixtureqa-0.1.8/fixture/core/auth.py +0 -68
- fixtureqa-0.1.8/fixture/core/fix_application.py +0 -67
- fixtureqa-0.1.8/fixture/core/session_manager.py +0 -173
- fixtureqa-0.1.8/fixture/static/assets/index-DnrYtXs9.js +0 -102
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/LICENSE +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/README.md +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/__init__.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/events.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/fix_specs/FIX42.xml +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/fix_specs/FIX44.xml +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/server.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/setup.cfg +0 -0
- {fixtureqa-0.1.8 → fixtureqa-0.3.0}/tests/test_templates.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fixtureqa
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: FIXture — FIX Protocol Testing Tool
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
License-File: LICENSE
|
|
7
|
-
Requires-Dist: fixcore-engine
|
|
7
|
+
Requires-Dist: fixcore-engine==0.5.0
|
|
8
8
|
Requires-Dist: fastapi>=0.111.0
|
|
9
9
|
Requires-Dist: uvicorn[standard]>=0.29.0
|
|
10
10
|
Requires-Dist: websockets>=12.0
|
|
@@ -31,13 +31,20 @@ def _configure_logging(level_name: str, log_file: str | None = None) -> None:
|
|
|
31
31
|
root = logging.getLogger()
|
|
32
32
|
root.setLevel(level)
|
|
33
33
|
|
|
34
|
-
# Stdout handler
|
|
34
|
+
# Stdout handler. When a log file is also configured, keep the console at
|
|
35
|
+
# WARNING+ so high-rate perf runs (and chatty per-message engine logging)
|
|
36
|
+
# don't flood the terminal — the file still captures everything at `level`.
|
|
35
37
|
sh = logging.StreamHandler()
|
|
36
38
|
sh.setFormatter(formatter)
|
|
39
|
+
if log_file:
|
|
40
|
+
sh.setLevel(logging.WARNING)
|
|
37
41
|
root.addHandler(sh)
|
|
38
42
|
|
|
39
43
|
# Optional rotating file handler
|
|
40
44
|
if log_file:
|
|
45
|
+
log_dir = os.path.dirname(log_file)
|
|
46
|
+
if log_dir:
|
|
47
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
41
48
|
fh = logging.handlers.RotatingFileHandler(
|
|
42
49
|
log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
|
|
43
50
|
)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
import os
|
|
2
3
|
from contextlib import asynccontextmanager
|
|
3
4
|
|
|
@@ -14,19 +15,37 @@ from ..core.spec_overlay_store import SpecOverlayStore
|
|
|
14
15
|
from ..core.template_store import TemplateStore
|
|
15
16
|
from ..core.user_store import UserStore
|
|
16
17
|
from .connection_manager import ConnectionManager
|
|
17
|
-
from .
|
|
18
|
+
from ..core.perf_engine import RunRegistry
|
|
19
|
+
from ..core.perf_writer import PerfWriter
|
|
20
|
+
from ..core.perf_store import PerfStore
|
|
21
|
+
from .routers import sessions, messages, ws, auth, setup, admin, fix_spec, templates, scenarios, branding, custom_tags, spec_overlay, perf
|
|
18
22
|
|
|
19
23
|
|
|
20
24
|
@asynccontextmanager
|
|
21
25
|
async def lifespan(app: FastAPI):
|
|
26
|
+
# Capture the running event loop so worker threads (scenarios, and later
|
|
27
|
+
# the perf injector) can submit coroutines back to it via
|
|
28
|
+
# run_coroutine_threadsafe. get_event_loop() does not work from a worker
|
|
29
|
+
# thread on Python 3.12.
|
|
30
|
+
loop = asyncio.get_running_loop()
|
|
31
|
+
app.state.loop = loop
|
|
32
|
+
app.state.scenario_runner.set_loop(loop)
|
|
22
33
|
# Register event bridge with SessionManager
|
|
23
34
|
sm: SessionManager = app.state.session_manager
|
|
24
35
|
cm: ConnectionManager = app.state.connection_manager
|
|
25
36
|
sm.subscribe(cm.sync_event_handler)
|
|
37
|
+
# Periodic WS message-rate aggregator (emits message_agg under high load)
|
|
38
|
+
agg_task = asyncio.create_task(cm.run_aggregator())
|
|
26
39
|
# Start background housekeeping thread
|
|
27
40
|
app.state.housekeeping.start()
|
|
28
41
|
yield
|
|
42
|
+
agg_task.cancel()
|
|
29
43
|
sm.unsubscribe(cm.sync_event_handler)
|
|
44
|
+
# Stop active perf runs before tearing down sessions, then flush perf writer
|
|
45
|
+
try:
|
|
46
|
+
await app.state.perf_registry.shutdown()
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
30
49
|
# Force-stop all running sessions so their asyncio tasks can be cancelled cleanly
|
|
31
50
|
for sid in list(sm._sessions.keys()):
|
|
32
51
|
try:
|
|
@@ -34,6 +53,7 @@ async def lifespan(app: FastAPI):
|
|
|
34
53
|
except Exception:
|
|
35
54
|
pass
|
|
36
55
|
sm.close() # flush SQLite writer
|
|
56
|
+
app.state.perf_writer.close() # flush perf.db writer
|
|
37
57
|
|
|
38
58
|
|
|
39
59
|
def create_app(
|
|
@@ -50,16 +70,30 @@ def create_app(
|
|
|
50
70
|
lifespan=lifespan,
|
|
51
71
|
)
|
|
52
72
|
|
|
73
|
+
# Resolve a stable JWT signing key (persists under data_dir if no env var)
|
|
74
|
+
from ..core.auth import init_secret_key
|
|
75
|
+
init_secret_key(data_dir)
|
|
76
|
+
|
|
53
77
|
# Store singletons on app state (injected via Depends)
|
|
54
78
|
app.state.session_manager = session_manager
|
|
55
79
|
app.state.connection_manager = ConnectionManager()
|
|
56
80
|
user_store = UserStore(db_path)
|
|
57
81
|
app.state.user_store = user_store
|
|
58
|
-
app.state.housekeeping = HousekeepingService(
|
|
82
|
+
app.state.housekeeping = HousekeepingService(
|
|
83
|
+
msg_db_path, log_dir, user_store,
|
|
84
|
+
message_writer=session_manager.message_writer,
|
|
85
|
+
)
|
|
59
86
|
app.state.template_store = TemplateStore(data_dir)
|
|
60
87
|
app.state.scenario_store = ScenarioStore(data_dir)
|
|
61
88
|
app.state.custom_tag_store = CustomTagStore(data_dir)
|
|
62
89
|
app.state.spec_overlay_store = SpecOverlayStore(data_dir)
|
|
90
|
+
perf_writer = PerfWriter(os.path.join(data_dir, "perf.db"))
|
|
91
|
+
app.state.perf_writer = perf_writer
|
|
92
|
+
app.state.perf_registry = RunRegistry(perf_writer)
|
|
93
|
+
# Runs left non-terminal in perf.db were interrupted by a prior restart —
|
|
94
|
+
# mark them errored so History doesn't show zombie 'running' rows.
|
|
95
|
+
app.state.perf_registry.reconcile_interrupted()
|
|
96
|
+
app.state.perf_store = PerfStore(data_dir)
|
|
63
97
|
conn_manager: ConnectionManager = app.state.connection_manager
|
|
64
98
|
app.state.scenario_runner = ScenarioRunner(
|
|
65
99
|
session_manager, app.state.template_store, conn_manager.broadcast_scenario_event
|
|
@@ -77,8 +111,15 @@ def create_app(
|
|
|
77
111
|
app.include_router(branding.router, prefix="/api")
|
|
78
112
|
app.include_router(custom_tags.router, prefix="/api/custom-tags")
|
|
79
113
|
app.include_router(spec_overlay.router, prefix="/api/spec-overlay")
|
|
114
|
+
app.include_router(perf.router, prefix="/api/perf")
|
|
80
115
|
app.include_router(ws.router)
|
|
81
116
|
|
|
117
|
+
# Unauthenticated liveness probe (used by the Docker HEALTHCHECK). Registered
|
|
118
|
+
# before the SPA catch-all below so it isn't shadowed by index.html.
|
|
119
|
+
@app.get("/api/health")
|
|
120
|
+
def health():
|
|
121
|
+
return {"status": "ok"}
|
|
122
|
+
|
|
82
123
|
# Serve built React app from fixture/static/
|
|
83
124
|
static_dir = os.path.join(os.path.dirname(__file__), "..", "static")
|
|
84
125
|
static_dir = os.path.abspath(static_dir)
|
|
@@ -71,16 +71,41 @@ class ConnectionManager:
|
|
|
71
71
|
Each connection carries a uid and is_admin flag for broadcast filtering.
|
|
72
72
|
"""
|
|
73
73
|
|
|
74
|
+
# Aggregation: stream per-message frames only up to PER_MSG_RATE_CAP msg/s
|
|
75
|
+
# per session; above that, suppress per-message and emit one periodic
|
|
76
|
+
# `message_agg` summary instead (the UI can't render thousands/sec anyway).
|
|
77
|
+
AGG_INTERVAL_S = 0.5
|
|
78
|
+
PER_MSG_RATE_CAP = 40 # msgs/sec/session below which per-message frames stream
|
|
79
|
+
|
|
74
80
|
def __init__(self):
|
|
75
81
|
self._clients: dict[str, dict[WebSocket, _ConnInfo]] = {}
|
|
82
|
+
# Hold strong references to in-flight broadcast tasks. asyncio only keeps
|
|
83
|
+
# a weak reference to tasks, so a fire-and-forget create_task() can be
|
|
84
|
+
# garbage-collected mid-execution, silently dropping a broadcast.
|
|
85
|
+
self._tasks: set[asyncio.Task] = set()
|
|
86
|
+
# Per-session message counters for the current aggregation window.
|
|
87
|
+
self._counters: dict[str, dict] = {}
|
|
88
|
+
self._per_window_cap = max(1, int(self.PER_MSG_RATE_CAP * self.AGG_INTERVAL_S))
|
|
89
|
+
|
|
90
|
+
def _has_clients(self) -> bool:
|
|
91
|
+
return any(self._clients.values())
|
|
92
|
+
|
|
93
|
+
def _spawn(self, coro) -> None:
|
|
94
|
+
"""Schedule a broadcast coroutine, retaining a reference until it finishes."""
|
|
95
|
+
task = asyncio.create_task(coro)
|
|
96
|
+
self._tasks.add(task)
|
|
97
|
+
task.add_done_callback(self._tasks.discard)
|
|
76
98
|
|
|
77
99
|
# ------------------------------------------------------------------
|
|
78
100
|
# Called from async context (WS handler)
|
|
79
101
|
# ------------------------------------------------------------------
|
|
80
102
|
|
|
81
103
|
async def connect(self, websocket: WebSocket, session_id: str = "*",
|
|
82
|
-
uid: str = "", is_admin: bool = False
|
|
83
|
-
|
|
104
|
+
uid: str = "", is_admin: bool = False,
|
|
105
|
+
subprotocol: str | None = None) -> None:
|
|
106
|
+
# Echo the negotiated subprotocol ("bearer") when the token came in via
|
|
107
|
+
# the Sec-WebSocket-Protocol header, so the handshake completes cleanly.
|
|
108
|
+
await websocket.accept(subprotocol=subprotocol)
|
|
84
109
|
self._clients.setdefault(session_id, {})[websocket] = _ConnInfo(uid, is_admin)
|
|
85
110
|
|
|
86
111
|
def disconnect(self, websocket: WebSocket, session_id: str = "*") -> None:
|
|
@@ -92,13 +117,77 @@ class ConnectionManager:
|
|
|
92
117
|
# ------------------------------------------------------------------
|
|
93
118
|
|
|
94
119
|
def sync_event_handler(self, event: SessionEvent) -> None:
|
|
95
|
-
"""Registered with SessionManager.subscribe(). Called on the event loop.
|
|
96
|
-
|
|
97
|
-
|
|
120
|
+
"""Registered with SessionManager.subscribe(). Called on the event loop.
|
|
121
|
+
|
|
122
|
+
Does nothing when no clients are connected (avoids per-message work at
|
|
123
|
+
high rate with nobody watching). Message events are rate-gated: streamed
|
|
124
|
+
per-message up to the cap, then suppressed in favour of periodic
|
|
125
|
+
`message_agg` summaries from run_aggregator().
|
|
126
|
+
"""
|
|
127
|
+
if not self._has_clients():
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
et = event.event_type
|
|
131
|
+
if et in (EventType.MESSAGE_RECEIVED, EventType.MESSAGE_SENT):
|
|
132
|
+
sid = event.session_id
|
|
133
|
+
c = self._counters.get(sid)
|
|
134
|
+
if c is None:
|
|
135
|
+
c = {"count": 0, "in": 0, "out": 0, "last_seq": 0, "owner": event.owner_uid}
|
|
136
|
+
self._counters[sid] = c
|
|
137
|
+
c["count"] += 1
|
|
138
|
+
c["owner"] = event.owner_uid
|
|
139
|
+
if et == EventType.MESSAGE_RECEIVED:
|
|
140
|
+
c["in"] += 1
|
|
141
|
+
else:
|
|
142
|
+
c["out"] += 1
|
|
143
|
+
seq = event.payload.get("seq_num", 0)
|
|
144
|
+
if seq:
|
|
145
|
+
c["last_seq"] = seq
|
|
146
|
+
# Stream per-message only while under the per-window cap; beyond it
|
|
147
|
+
# the aggregator emits a summary instead (bounded task creation).
|
|
148
|
+
if c["count"] <= self._per_window_cap:
|
|
149
|
+
self._spawn(self._broadcast(sid, _serialize_event(event), event.owner_uid))
|
|
150
|
+
else:
|
|
151
|
+
# status / engine_error — always delivered immediately
|
|
152
|
+
self._spawn(self._broadcast(event.session_id, _serialize_event(event), event.owner_uid))
|
|
153
|
+
|
|
154
|
+
async def run_aggregator(self) -> None:
|
|
155
|
+
"""Periodic task: emit a `message_agg` summary for any session whose
|
|
156
|
+
per-message frames were suppressed this window. Start in the app
|
|
157
|
+
lifespan; cancel on shutdown."""
|
|
158
|
+
try:
|
|
159
|
+
while True:
|
|
160
|
+
await asyncio.sleep(self.AGG_INTERVAL_S)
|
|
161
|
+
await self._emit_aggregates()
|
|
162
|
+
except asyncio.CancelledError:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
async def _emit_aggregates(self) -> None:
|
|
166
|
+
"""Emit one message_agg per session whose per-message frames were
|
|
167
|
+
suppressed (high rate) this window. Resets the window counters."""
|
|
168
|
+
if not self._counters:
|
|
169
|
+
return
|
|
170
|
+
snapshot, self._counters = self._counters, {}
|
|
171
|
+
if not self._has_clients():
|
|
172
|
+
return
|
|
173
|
+
for sid, c in snapshot.items():
|
|
174
|
+
if c["count"] <= self._per_window_cap:
|
|
175
|
+
continue # low rate — per-message frames already covered it
|
|
176
|
+
payload = {
|
|
177
|
+
"type": "message_agg",
|
|
178
|
+
"session_id": sid,
|
|
179
|
+
"rate": round(c["count"] / self.AGG_INTERVAL_S, 1),
|
|
180
|
+
"in": c["in"],
|
|
181
|
+
"out": c["out"],
|
|
182
|
+
"last_seq": c["last_seq"],
|
|
183
|
+
"window_ms": int(self.AGG_INTERVAL_S * 1000),
|
|
184
|
+
"ts": _now_iso(),
|
|
185
|
+
}
|
|
186
|
+
await self._broadcast(sid, payload, c["owner"])
|
|
98
187
|
|
|
99
188
|
def broadcast_scenario_event(self, uid: str, data: dict) -> None:
|
|
100
189
|
"""Schedule delivery to all WS connections for uid."""
|
|
101
|
-
|
|
190
|
+
self._spawn(self._broadcast_to_user(uid, data))
|
|
102
191
|
|
|
103
192
|
# ------------------------------------------------------------------
|
|
104
193
|
# Internal async broadcast
|
|
@@ -81,3 +81,15 @@ def get_custom_tag_store(request: HTTPConnection) -> CustomTagStore:
|
|
|
81
81
|
|
|
82
82
|
def get_spec_overlay_store(request: HTTPConnection) -> SpecOverlayStore:
|
|
83
83
|
return request.app.state.spec_overlay_store
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_perf_registry(request: HTTPConnection):
|
|
87
|
+
return request.app.state.perf_registry
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_perf_writer(request: HTTPConnection):
|
|
91
|
+
return request.app.state.perf_writer
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_perf_store(request: HTTPConnection):
|
|
95
|
+
return request.app.state.perf_store
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Performance-testing REST + WebSocket API (admin-only).
|
|
3
|
+
|
|
4
|
+
Stage 1a: run lifecycle (start/list/get/stop) + live snapshot WS. The WS uses a
|
|
5
|
+
short-TTL single-use ticket (JWT never in the URL); the rest of the contract
|
|
6
|
+
(messages/scenarios/export/configs) lands in later stages.
|
|
7
|
+
"""
|
|
8
|
+
import csv
|
|
9
|
+
import io
|
|
10
|
+
import logging
|
|
11
|
+
import zipfile
|
|
12
|
+
from typing import Annotated, Optional
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
|
15
|
+
from fastapi.responses import Response, StreamingResponse
|
|
16
|
+
|
|
17
|
+
from ...core.models import SessionStatus
|
|
18
|
+
from ...core.perf_models import RunConfig, RunStatus
|
|
19
|
+
from ...core.perf_engine import RunRegistry
|
|
20
|
+
from ...core.perf_payload import PerfConfigError
|
|
21
|
+
from ...core.perf_store import PerfStore
|
|
22
|
+
from ...core.perf_writer import PerfWriter
|
|
23
|
+
from ...core.session_manager import SessionManager
|
|
24
|
+
from ...core.template_store import TemplateStore
|
|
25
|
+
from ...core.user_store import User
|
|
26
|
+
from ..deps import (
|
|
27
|
+
get_session_manager, get_perf_registry, get_perf_writer, get_perf_store,
|
|
28
|
+
get_template_store, require_platform_admin,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
router = APIRouter(tags=["perf"])
|
|
34
|
+
|
|
35
|
+
SM = Annotated[SessionManager, Depends(get_session_manager)]
|
|
36
|
+
REG = Annotated[RunRegistry, Depends(get_perf_registry)]
|
|
37
|
+
PW = Annotated[PerfWriter, Depends(get_perf_writer)]
|
|
38
|
+
PS = Annotated[PerfStore, Depends(get_perf_store)]
|
|
39
|
+
TS = Annotated[TemplateStore, Depends(get_template_store)]
|
|
40
|
+
Admin = Annotated[User, Depends(require_platform_admin)]
|
|
41
|
+
|
|
42
|
+
_TERMINAL = ("completed", "stopped", "error")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _require_started(sm: SessionManager, sid: str, label: str):
|
|
46
|
+
try:
|
|
47
|
+
state = sm.get_state(sid)
|
|
48
|
+
except KeyError:
|
|
49
|
+
raise HTTPException(status_code=409, detail=f"{label} session {sid!r} not found")
|
|
50
|
+
# The run drives load through the session but never starts/connects it — it
|
|
51
|
+
# must already be LOGGED_ON, or every send_message() silently returns False
|
|
52
|
+
# (orders all counted as rejected) and the run looks like it never started.
|
|
53
|
+
if state.status != SessionStatus.LOGGED_ON:
|
|
54
|
+
raise HTTPException(
|
|
55
|
+
status_code=409,
|
|
56
|
+
detail=(f"{label} session {sid!r} is not logged on "
|
|
57
|
+
f"(status {state.status.name}); start it and wait for the "
|
|
58
|
+
f"connection to establish before running a perf test"))
|
|
59
|
+
return state
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@router.post("/runs", status_code=201)
|
|
63
|
+
async def create_run(body: RunConfig, sm: SM, reg: REG, ts: TS, admin: Admin):
|
|
64
|
+
# async so PerfRun.start()'s asyncio.create_task runs on the event loop
|
|
65
|
+
# (a sync def route runs in a threadpool with no running loop).
|
|
66
|
+
need_client = body.mode in ("loopback", "manager", "client")
|
|
67
|
+
need_venue = body.mode in ("loopback", "manager", "venue")
|
|
68
|
+
if need_client:
|
|
69
|
+
_require_started(sm, body.client_session_id, "client")
|
|
70
|
+
if need_venue:
|
|
71
|
+
_require_started(sm, body.venue_session_id, "venue")
|
|
72
|
+
|
|
73
|
+
# CompIDs from the driving (client, else venue) session — for template tokens.
|
|
74
|
+
drive_sid = body.client_session_id if need_client else body.venue_session_id
|
|
75
|
+
dcfg = sm.get_state(drive_sid).config
|
|
76
|
+
try:
|
|
77
|
+
run = reg.create(body, sm, template_store=ts, owner_uid=admin.uid,
|
|
78
|
+
sender_comp_id=dcfg.sender_comp_id, target_comp_id=dcfg.target_comp_id)
|
|
79
|
+
except PerfConfigError as e:
|
|
80
|
+
raise HTTPException(status_code=422, detail=str(e))
|
|
81
|
+
|
|
82
|
+
# Soft perf warnings — don't block, but flag footguns.
|
|
83
|
+
checked = []
|
|
84
|
+
if need_client:
|
|
85
|
+
checked.append((body.client_session_id, "client"))
|
|
86
|
+
if need_venue:
|
|
87
|
+
checked.append((body.venue_session_id, "venue"))
|
|
88
|
+
for sid, label in checked:
|
|
89
|
+
cfg = sm.get_state(sid).config
|
|
90
|
+
if cfg.durable_seqnums:
|
|
91
|
+
run.warnings.append(f"{label} session has durable_seqnums=True — fsync per message caps throughput")
|
|
92
|
+
if cfg.log_messages:
|
|
93
|
+
run.warnings.append(f"{label} session has log_messages=True — persistence churn adds loop latency")
|
|
94
|
+
return {"run_id": run.run_id}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@router.get("/runs", response_model=list[RunStatus])
|
|
98
|
+
def list_runs(reg: REG, _admin: Admin):
|
|
99
|
+
# Live runs + historical runs from perf.db (survives a backend restart).
|
|
100
|
+
return reg.list_status()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@router.get("/runs/{run_id}", response_model=RunStatus)
|
|
104
|
+
def get_run(run_id: str, reg: REG, _admin: Admin):
|
|
105
|
+
status = reg.get_status(run_id)
|
|
106
|
+
if status is None:
|
|
107
|
+
raise HTTPException(status_code=404, detail="Run not found")
|
|
108
|
+
return status
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@router.delete("/runs/{run_id}", response_model=RunStatus)
|
|
112
|
+
async def stop_run(run_id: str, reg: REG, _admin: Admin):
|
|
113
|
+
run = await reg.stop(run_id)
|
|
114
|
+
if run is not None:
|
|
115
|
+
return run.status_response()
|
|
116
|
+
# Not live — a historical/terminal run can't be stopped, but report its status.
|
|
117
|
+
status = reg.get_status(run_id)
|
|
118
|
+
if status is None:
|
|
119
|
+
raise HTTPException(status_code=404, detail="Run not found")
|
|
120
|
+
return status
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@router.get("/runs/{run_id}/messages")
|
|
124
|
+
def run_messages(run_id: str, reg: REG, pw: PW, _admin: Admin,
|
|
125
|
+
limit: int = Query(200, ge=1, le=5000), offset: int = Query(0, ge=0)):
|
|
126
|
+
if not reg.exists(run_id):
|
|
127
|
+
raise HTTPException(status_code=404, detail="Run not found")
|
|
128
|
+
return pw.read_messages(run_id, limit, offset)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@router.get("/runs/{run_id}/scenarios")
|
|
132
|
+
def run_scenarios(run_id: str, reg: REG, pw: PW, _admin: Admin,
|
|
133
|
+
limit: int = Query(200, ge=1, le=5000), offset: int = Query(0, ge=0)):
|
|
134
|
+
if not reg.exists(run_id):
|
|
135
|
+
raise HTTPException(status_code=404, detail="Run not found")
|
|
136
|
+
return pw.read_scenarios(run_id, limit, offset)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _csv_stream(pw: PerfWriter, table: str, cols: tuple, run_id: str):
|
|
140
|
+
buf = io.StringIO()
|
|
141
|
+
w = csv.writer(buf)
|
|
142
|
+
w.writerow(cols)
|
|
143
|
+
yield buf.getvalue()
|
|
144
|
+
buf.seek(0); buf.truncate(0)
|
|
145
|
+
for row in pw.iter_rows(table, cols, run_id):
|
|
146
|
+
w.writerow(row)
|
|
147
|
+
yield buf.getvalue()
|
|
148
|
+
buf.seek(0); buf.truncate(0)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _csv_text(pw: PerfWriter, table: str, cols: tuple, run_id: str) -> str:
|
|
152
|
+
out = io.StringIO()
|
|
153
|
+
w = csv.writer(out)
|
|
154
|
+
w.writerow(cols)
|
|
155
|
+
for row in pw.iter_rows(table, cols, run_id):
|
|
156
|
+
w.writerow(row)
|
|
157
|
+
return out.getvalue()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@router.get("/runs/{run_id}/export")
|
|
161
|
+
def run_export(run_id: str, reg: REG, pw: PW, _admin: Admin,
|
|
162
|
+
kind: str = Query("messages")):
|
|
163
|
+
if not reg.exists(run_id):
|
|
164
|
+
raise HTTPException(status_code=404, detail="Run not found")
|
|
165
|
+
if kind == "messages":
|
|
166
|
+
return StreamingResponse(
|
|
167
|
+
_csv_stream(pw, "perf_messages", pw.message_columns(), run_id),
|
|
168
|
+
media_type="text/csv",
|
|
169
|
+
headers={"Content-Disposition": f'attachment; filename="{run_id}_messages.csv"'})
|
|
170
|
+
if kind == "scenarios":
|
|
171
|
+
return StreamingResponse(
|
|
172
|
+
_csv_stream(pw, "perf_scenarios", pw.scenario_columns(), run_id),
|
|
173
|
+
media_type="text/csv",
|
|
174
|
+
headers={"Content-Disposition": f'attachment; filename="{run_id}_scenarios.csv"'})
|
|
175
|
+
if kind == "both":
|
|
176
|
+
buf = io.BytesIO()
|
|
177
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
|
178
|
+
z.writestr(f"{run_id}_messages.csv",
|
|
179
|
+
_csv_text(pw, "perf_messages", pw.message_columns(), run_id))
|
|
180
|
+
z.writestr(f"{run_id}_scenarios.csv",
|
|
181
|
+
_csv_text(pw, "perf_scenarios", pw.scenario_columns(), run_id))
|
|
182
|
+
return Response(buf.getvalue(), media_type="application/zip",
|
|
183
|
+
headers={"Content-Disposition": f'attachment; filename="{run_id}.zip"'})
|
|
184
|
+
raise HTTPException(status_code=422, detail="kind must be messages|scenarios|both")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# --- saved configs --------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
@router.get("/configs")
|
|
190
|
+
def list_configs(store: PS, _admin: Admin):
|
|
191
|
+
return store.list()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@router.post("/configs", status_code=201)
|
|
195
|
+
def save_config(body: RunConfig, store: PS, _admin: Admin):
|
|
196
|
+
return {"config_id": store.save(body.model_dump())}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@router.get("/configs/{config_id}", response_model=RunConfig)
|
|
200
|
+
def get_config(config_id: str, store: PS, _admin: Admin):
|
|
201
|
+
cfg = store.get(config_id)
|
|
202
|
+
if cfg is None:
|
|
203
|
+
raise HTTPException(status_code=404, detail="Config not found")
|
|
204
|
+
return cfg
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@router.delete("/configs/{config_id}", status_code=204)
|
|
208
|
+
def delete_config(config_id: str, store: PS, _admin: Admin):
|
|
209
|
+
if not store.delete(config_id):
|
|
210
|
+
raise HTTPException(status_code=404, detail="Config not found")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@router.post("/runs/{run_id}/ticket", status_code=201)
|
|
214
|
+
def ws_ticket(run_id: str, reg: REG, _admin: Admin):
|
|
215
|
+
if reg.get(run_id) is None:
|
|
216
|
+
raise HTTPException(status_code=404, detail="Run not found")
|
|
217
|
+
ticket, ttl = reg.issue_ticket(run_id)
|
|
218
|
+
return {"ticket": ticket, "expires_in": ttl}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@router.websocket("/runs/{run_id}/live")
|
|
222
|
+
async def live(websocket: WebSocket, run_id: str, ticket: Optional[str] = Query(default=None)):
|
|
223
|
+
reg: RunRegistry = websocket.app.state.perf_registry
|
|
224
|
+
if not ticket or reg.redeem_ticket(ticket) != run_id:
|
|
225
|
+
await websocket.close(code=4003, reason="Invalid or expired ticket")
|
|
226
|
+
return
|
|
227
|
+
run = reg.get(run_id)
|
|
228
|
+
if run is None:
|
|
229
|
+
await websocket.close(code=4004, reason="Run not found")
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
await websocket.accept()
|
|
233
|
+
q = run.add_watcher()
|
|
234
|
+
try:
|
|
235
|
+
while True:
|
|
236
|
+
snap = await q.get()
|
|
237
|
+
await websocket.send_json(snap)
|
|
238
|
+
if snap.get("status") in _TERMINAL:
|
|
239
|
+
break
|
|
240
|
+
except WebSocketDisconnect:
|
|
241
|
+
pass
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
finally:
|
|
245
|
+
run.remove_watcher(q)
|
|
@@ -41,6 +41,9 @@ def _session_response(sm: SessionManager, session_id: str, us: Optional[UserStor
|
|
|
41
41
|
end_time=cfg.end_time,
|
|
42
42
|
reset_on_logon=cfg.reset_on_logon,
|
|
43
43
|
reset_on_logout=cfg.reset_on_logout,
|
|
44
|
+
durable_seqnums=cfg.durable_seqnums,
|
|
45
|
+
log_messages=cfg.log_messages,
|
|
46
|
+
validate_inbound=cfg.validate_inbound,
|
|
44
47
|
session_role=cfg.session_role.value,
|
|
45
48
|
auto_ack=cfg.auto_ack,
|
|
46
49
|
auto_ack_delay_ms=cfg.auto_ack_delay_ms,
|
|
@@ -102,6 +105,9 @@ async def create_session(body: SessionConfigRequest, sm: SM, user: CurrentUser):
|
|
|
102
105
|
end_time=body.end_time,
|
|
103
106
|
reset_on_logon=body.reset_on_logon,
|
|
104
107
|
reset_on_logout=body.reset_on_logout,
|
|
108
|
+
durable_seqnums=body.durable_seqnums,
|
|
109
|
+
log_messages=body.log_messages,
|
|
110
|
+
validate_inbound=body.validate_inbound,
|
|
105
111
|
session_role=role,
|
|
106
112
|
auto_ack=body.auto_ack,
|
|
107
113
|
auto_ack_delay_ms=body.auto_ack_delay_ms,
|
|
@@ -152,6 +158,9 @@ def import_sessions(body: list[SessionConfigRequest], sm: SM, user: CurrentUser)
|
|
|
152
158
|
end_time=item.end_time,
|
|
153
159
|
reset_on_logon=item.reset_on_logon,
|
|
154
160
|
reset_on_logout=item.reset_on_logout,
|
|
161
|
+
durable_seqnums=item.durable_seqnums,
|
|
162
|
+
log_messages=item.log_messages,
|
|
163
|
+
validate_inbound=item.validate_inbound,
|
|
155
164
|
session_role=role,
|
|
156
165
|
auto_ack=item.auto_ack,
|
|
157
166
|
auto_ack_delay_ms=item.auto_ack_delay_ms,
|
|
@@ -218,31 +227,16 @@ async def stop_session(
|
|
|
218
227
|
@router.post("/{session_id}/send")
|
|
219
228
|
async def send_message(session_id: str, body: SendMessageRequest, sm: SM, user: CurrentUser):
|
|
220
229
|
_check_ownership(sm, session_id, user)
|
|
221
|
-
from ...core.
|
|
222
|
-
from fixcore.message.message import Message
|
|
230
|
+
from ...core.fix_builder import build_message_from_raw
|
|
223
231
|
from datetime import datetime, timezone
|
|
224
232
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
raise HTTPException(status_code=422, detail="Missing MsgType (tag 35)")
|
|
233
|
-
|
|
234
|
-
if not body.no_auto_ts and 60 not in fields:
|
|
235
|
-
fields[60] = datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S")
|
|
236
|
-
|
|
237
|
-
SKIP = {8, 9, 10, 34, 35, 49, 52, 56}
|
|
238
|
-
msg = Message()
|
|
239
|
-
msg.header.set(35, msg_type)
|
|
240
|
-
for tag, value in fields.items():
|
|
241
|
-
if tag not in SKIP:
|
|
242
|
-
try:
|
|
243
|
-
msg.set_field(int(tag), str(value))
|
|
244
|
-
except Exception:
|
|
245
|
-
pass
|
|
233
|
+
# Build group-aware using the session's DataDictionary so repeating groups
|
|
234
|
+
# (e.g. NoAllocs rows) survive instead of collapsing to their last instance.
|
|
235
|
+
ts = None if body.no_auto_ts else datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S")
|
|
236
|
+
try:
|
|
237
|
+
msg = build_message_from_raw(body.raw, sm.get_data_dictionary(session_id), transact_time=ts)
|
|
238
|
+
except ValueError as e:
|
|
239
|
+
raise HTTPException(status_code=422, detail=str(e))
|
|
246
240
|
|
|
247
241
|
ok = await sm.send_message(session_id, msg)
|
|
248
242
|
if not ok:
|
|
@@ -40,19 +40,35 @@ async def _send_snapshot(websocket: WebSocket, sm: SessionManager, uid: str, is_
|
|
|
40
40
|
})
|
|
41
41
|
|
|
42
42
|
|
|
43
|
-
def
|
|
44
|
-
"""
|
|
43
|
+
def _token_from_ws(websocket: WebSocket, query_token: Optional[str]) -> tuple[Optional[str], Optional[str]]:
|
|
44
|
+
"""Resolve the JWT, preferring the Sec-WebSocket-Protocol header so the
|
|
45
|
+
token never appears in the URL (and thus not in access/proxy logs or
|
|
46
|
+
browser history). The browser sends ["bearer", "<jwt>"]; we echo "bearer"
|
|
47
|
+
as the negotiated subprotocol. Falls back to the legacy query param.
|
|
48
|
+
|
|
49
|
+
Returns (token, subprotocol_to_echo).
|
|
50
|
+
"""
|
|
51
|
+
raw = websocket.headers.get("sec-websocket-protocol", "")
|
|
52
|
+
parts = [p.strip() for p in raw.split(",") if p.strip()]
|
|
53
|
+
if len(parts) >= 2 and parts[0] == "bearer":
|
|
54
|
+
return parts[1], "bearer"
|
|
55
|
+
return query_token, None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _authenticate_ws(websocket: WebSocket, token: Optional[str]) -> tuple[Optional[str], bool, Optional[str]]:
|
|
59
|
+
"""Validate the JWT. Returns (uid, is_admin, subprotocol) or (None, False, None)."""
|
|
60
|
+
token, subprotocol = _token_from_ws(websocket, token)
|
|
45
61
|
if not token:
|
|
46
|
-
return None, False
|
|
62
|
+
return None, False, None
|
|
47
63
|
try:
|
|
48
64
|
payload = decode_token(token)
|
|
49
65
|
uid = payload.get("sub", "")
|
|
50
66
|
role = payload.get("role", "")
|
|
51
67
|
if not uid:
|
|
52
|
-
return None, False
|
|
53
|
-
return uid, role == "platform_admin"
|
|
68
|
+
return None, False, None
|
|
69
|
+
return uid, role == "platform_admin", subprotocol
|
|
54
70
|
except JWTError:
|
|
55
|
-
return None, False
|
|
71
|
+
return None, False, None
|
|
56
72
|
|
|
57
73
|
|
|
58
74
|
@router.websocket("/ws")
|
|
@@ -63,7 +79,7 @@ async def ws_all_sessions(
|
|
|
63
79
|
token: Optional[str] = Query(default=None),
|
|
64
80
|
):
|
|
65
81
|
"""Subscribe to events from all sessions."""
|
|
66
|
-
uid, is_admin = _authenticate_ws(websocket, token)
|
|
82
|
+
uid, is_admin, subprotocol = _authenticate_ws(websocket, token)
|
|
67
83
|
if not uid:
|
|
68
84
|
logger.warning("WS /ws: auth failed (no token or invalid JWT)")
|
|
69
85
|
await websocket.close(code=4003, reason="Unauthorized")
|
|
@@ -78,7 +94,8 @@ async def ws_all_sessions(
|
|
|
78
94
|
return
|
|
79
95
|
|
|
80
96
|
logger.info("WS /ws: client connected uid=%s is_admin=%s", uid, is_admin)
|
|
81
|
-
await conn_mgr.connect(websocket, session_id="*", uid=uid, is_admin=is_admin
|
|
97
|
+
await conn_mgr.connect(websocket, session_id="*", uid=uid, is_admin=is_admin,
|
|
98
|
+
subprotocol=subprotocol)
|
|
82
99
|
try:
|
|
83
100
|
await _send_snapshot(websocket, sm, uid, is_admin)
|
|
84
101
|
while True:
|
|
@@ -103,7 +120,7 @@ async def ws_single_session(
|
|
|
103
120
|
token: Optional[str] = Query(default=None),
|
|
104
121
|
):
|
|
105
122
|
"""Subscribe to events from a single session."""
|
|
106
|
-
uid, is_admin = _authenticate_ws(websocket, token)
|
|
123
|
+
uid, is_admin, subprotocol = _authenticate_ws(websocket, token)
|
|
107
124
|
if not uid:
|
|
108
125
|
logger.warning("WS /ws/%s: auth failed (no token or invalid JWT)", session_id)
|
|
109
126
|
await websocket.close(code=4003, reason="Unauthorized")
|
|
@@ -125,7 +142,8 @@ async def ws_single_session(
|
|
|
125
142
|
return
|
|
126
143
|
|
|
127
144
|
logger.info("WS /ws/%s: client connected uid=%s is_admin=%s", session_id, uid, is_admin)
|
|
128
|
-
await conn_mgr.connect(websocket, session_id=session_id, uid=uid, is_admin=is_admin
|
|
145
|
+
await conn_mgr.connect(websocket, session_id=session_id, uid=uid, is_admin=is_admin,
|
|
146
|
+
subprotocol=subprotocol)
|
|
129
147
|
try:
|
|
130
148
|
await _send_snapshot(websocket, sm, uid, is_admin, session_id=session_id)
|
|
131
149
|
while True:
|