From 79c59eeeedec52e769cd0f3fe36834761a7e81a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AE=D1=80=D0=B8=D0=B9=20=D0=A7=D0=B5=D1=80=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE?= Date: Mon, 24 Aug 2026 15:59:05 +0300 Subject: [PATCH] =?UTF-8?q?=D1=88=D0=BE=D1=80=D1=82=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 3 +- hockey_data/agent_bridge.py | 160 +++++++++++------- ...est_build100_shortcut_delivery_key_edge.py | 85 ++++------ tests/test_build57_quick_panel_parameters.py | 3 +- ui_builder/static/app.js | 17 +- 5 files changed, 153 insertions(+), 115 deletions(-) diff --git a/app.py b/app.py index ff3bef0..274273e 100644 --- a/app.py +++ b/app.py @@ -29,7 +29,8 @@ from ui_builder import install_ui_builder from khl_site.khl_data_center import APP as khl_site_app BASE_DIR = Path(__file__).resolve().parent -BUILD_VERSION = "2026.08.24.4" +BUILD_VERSION = "2026.08.24.5" +# compatibility: BUILD_VERSION = "2026.08.24.4" # compatibility: BUILD_VERSION = "2026.08.24.2" # compatibility: BUILD_VERSION = "2026.08.24.1" # compatibility: BUILD_VERSION = "2026.08.21.1" diff --git a/hockey_data/agent_bridge.py b/hockey_data/agent_bridge.py index f99b8b0..aee99b4 100644 --- a/hockey_data/agent_bridge.py +++ b/hockey_data/agent_bridge.py @@ -249,6 +249,10 @@ class VmixAgentHub: # BUILD97: serialize Mapping applications per Agent while still letting # runtime/timer commands use the normal vMix FIFO between Mapping chunks. self._mapping_apply_locks: dict[str, asyncio.Lock] = {} + # BUILD102: interactive operator shortcuts use their own tiny send lock. + # They must never sit behind Mapping's ACK wait, otherwise a visible F-key + # or bottom-dock button can feel dead even though vMix itself is reachable. + self._vmix_shortcut_send_locks: dict[str, asyncio.Lock] = {} self.mapping_data = MappingDataService(database, settings=settings) self._auto_refresh_task: asyncio.Task[None] | None = None self._auto_refresh_next: dict[tuple[str, str], float] = {} @@ -688,6 +692,13 @@ class VmixAgentHub: self._mapping_apply_locks[device_id] = lock return lock + def _device_vmix_shortcut_send_lock(self, device_id: str) -> asyncio.Lock: + lock = self._vmix_shortcut_send_locks.get(device_id) + if lock is None: + lock = asyncio.Lock() + self._vmix_shortcut_send_locks[device_id] = lock + return lock + @staticmethod def _mapping_batch_chunks(entries: list[dict[str, Any]]) -> list[list[tuple[int, dict[str, Any]]]]: """Group Mapping commands by vMix Input, then cap packet count and bytes. @@ -1505,72 +1516,46 @@ class VmixAgentHub: prepared_commands.append(command) results: list[dict[str, Any]] = [] - # BUILD100: operator Shortcut/Quick-panel traffic is intentionally delivered - # as ordered single vmix.command frames with an ACK for every command. Mapping - # keeps its chunked batch transport and generic non-shortcut runtime sync may - # still use Agent 1.4 vmix.batch. Interactive title/timer actions are small, and - # losing the rest of a shortcut after one bad command is much worse than a few - # extra websocket frames. + # BUILD102: operator Shortcut/Quick-panel traffic is fire-and-forget after + # the WebSocket frame has been handed to the connected Agent. We deliberately + # do NOT wait for command.ack here. Agent ACK can arrive later and is ignored + # for this transport. Mapping and generic runtime sync keep their existing ACK + # semantics so diagnostics are still useful where exact field delivery matters. interactive_shortcut = bool(str(sequence_id or "").strip() or str(button_id or "").strip()) use_batch = (not interactive_shortcut) and self._agent_supports_batch(agent_version) and len(prepared_commands) > 1 - transport = "batch" if use_batch else ("shortcut-sequential" if interactive_shortcut else "legacy") + transport = "shortcut-no-ack" if interactive_shortcut else ("batch" if use_batch else "legacy") - async with self._device_vmix_send_lock(target_device_id): - if use_batch: - ack = await self._send_vmix_batch_unlocked( - target_device_id, - assignment_id=assignment_id, - match_id=match_id, - commands=prepared_commands, - timeout=max(4.0, min(8.0, timeout + 2.0)), - ) - ack_results = ack.get("results") if isinstance(ack.get("results"), list) else [] + if interactive_shortcut: + # Preserve order between operator shortcuts, but do not wait behind Mapping's + # command ACK lock. A shortcut returns as soon as its frames are on the Agent socket. + async with self._device_vmix_shortcut_send_lock(target_device_id): for index, command in enumerate(prepared_commands): function = str(command.get("Function") or "") - item_ack = ack_results[index] if index < len(ack_results) and isinstance(ack_results[index], dict) else {} - ok = bool(item_ack.get("ok")) if item_ack else bool(ack.get("ok")) + request_id = secrets.token_urlsafe(12) + delivered = await self.send( + target_device_id, + { + "type": "vmix.command", + "protocol": AGENT_PROTOCOL_VERSION, + "request_id": request_id, + "device_id": target_device_id, + "assignment_id": assignment_id, + "match_id": match_id, + "command": command, + }, + ) item = { "index": index, "function": function, - "ok": ok, - "reason": str(item_ack.get("reason") or item_ack.get("error") or ("" if ok else ack.get("reason") or ack.get("error") or "vmix_batch_error")), + "ok": bool(delivered), + "reason": "" if delivered else "Agent сейчас offline", + "delivery": "agent-websocket", + "ack_waited": False, } results.append(item) - if ok: - self._track_runtime_overlay_command( - target_device_id, command, sequence_id=sequence_id, sequence_name=sequence_name, button_id=button_id - ) - else: - for index, command in enumerate(prepared_commands): - function = str(command.get("Function") or "") - try: - ack = await self._send_vmix_command_unlocked( - target_device_id, - assignment_id=assignment_id, - match_id=match_id, - command=command, - timeout=timeout, - ) - item = { - "index": index, - "function": function, - "ok": bool(ack.get("ok")), - "reason": str(ack.get("reason") or ack.get("error") or ""), - } - except Exception as error: - detail = getattr(error, "detail", None) - if isinstance(detail, dict): - reason = str(detail.get("message") or detail) - else: - reason = str(detail or error or "vmix_command_error") - item = { - "index": index, - "function": function, - "ok": False, - "reason": reason[:500], - } - results.append(item) - if item["ok"]: + if delivered: + # With no ACK wait the local ON AIR state is intentionally optimistic: + # success means delivery to the live Agent WebSocket, not vMix confirmation. self._track_runtime_overlay_command( target_device_id, command, @@ -1578,10 +1563,66 @@ class VmixAgentHub: sequence_name=sequence_name, button_id=button_id, ) - # For ordinary legacy non-shortcut runtime calls preserve the old - # fail-fast behavior. Shortcut delivery continues deliberately. - if not item["ok"] and not interactive_shortcut: + else: break + else: + async with self._device_vmix_send_lock(target_device_id): + if use_batch: + ack = await self._send_vmix_batch_unlocked( + target_device_id, + assignment_id=assignment_id, + match_id=match_id, + commands=prepared_commands, + timeout=max(4.0, min(8.0, timeout + 2.0)), + ) + ack_results = ack.get("results") if isinstance(ack.get("results"), list) else [] + for index, command in enumerate(prepared_commands): + function = str(command.get("Function") or "") + item_ack = ack_results[index] if index < len(ack_results) and isinstance(ack_results[index], dict) else {} + ok = bool(item_ack.get("ok")) if item_ack else bool(ack.get("ok")) + item = { + "index": index, + "function": function, + "ok": ok, + "reason": str(item_ack.get("reason") or item_ack.get("error") or ("" if ok else ack.get("reason") or ack.get("error") or "vmix_batch_error")), + } + results.append(item) + if ok: + self._track_runtime_overlay_command(target_device_id, command) + else: + for index, command in enumerate(prepared_commands): + function = str(command.get("Function") or "") + try: + ack = await self._send_vmix_command_unlocked( + target_device_id, + assignment_id=assignment_id, + match_id=match_id, + command=command, + timeout=timeout, + ) + item = { + "index": index, + "function": function, + "ok": bool(ack.get("ok")), + "reason": str(ack.get("reason") or ack.get("error") or ""), + } + except Exception as error: + detail = getattr(error, "detail", None) + if isinstance(detail, dict): + reason = str(detail.get("message") or detail) + else: + reason = str(detail or error or "vmix_command_error") + item = { + "index": index, + "function": function, + "ok": False, + "reason": reason[:500], + } + results.append(item) + if item["ok"]: + self._track_runtime_overlay_command(target_device_id, command) + if not item["ok"]: + break failed = [item for item in results if not item["ok"]] return { @@ -1596,6 +1637,7 @@ class VmixAgentHub: "failed": len(failed), "results": results, "transport": transport, + "confirmation": "not_waited" if interactive_shortcut else "ack", "overlay_state": self._runtime_overlay_payload(target_device_id), } diff --git a/tests/test_build100_shortcut_delivery_key_edge.py b/tests/test_build100_shortcut_delivery_key_edge.py index e9297e7..e101142 100644 --- a/tests/test_build100_shortcut_delivery_key_edge.py +++ b/tests/test_build100_shortcut_delivery_key_edge.py @@ -37,8 +37,8 @@ def test_build100_frontend_shortcut_edges_and_pending_press_are_guarded() -> Non assert 'BUILD_VERSION = "2026.08.24.4"' in APP -def test_build100_interactive_shortcut_is_single_command_acked_and_continues_after_failure(tmp_path: Path) -> None: - database = LocalTestDatabase(tmp_path / "build100-shortcut.sqlite3") +def test_build102_interactive_shortcut_is_dispatched_without_ack_or_mapping_lock(tmp_path: Path) -> None: + database = LocalTestDatabase(tmp_path / "build102-shortcut.sqlite3") database.create_all() hub = VmixAgentHub(database) # type: ignore[arg-type] ws = FakeWebSocket() @@ -50,8 +50,8 @@ def test_build100_interactive_shortcut_is_single_command_acked_and_continues_aft { "device_id": "GFX-BUILD100", "device_secret": "z" * 40, - "device_name": "Build100 GFX", - "hostname": "BUILD100-PC", + "device_name": "Build102 GFX", + "hostname": "BUILD102-PC", "agent_version": "1.4.0", "vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"}, }, @@ -64,55 +64,44 @@ def test_build100_interactive_shortcut_is_single_command_acked_and_continues_aft ) assert assigned is not None - task = asyncio.create_task( - hub.run_vmix_sequence_for_user( - user, - [ - {"Function": "OverlayInput1In", "Input": "SCORE"}, - {"Function": "OverlayInput2In", "Input": "BROKEN"}, - {"Function": "OverlayInput3In", "Input": "LINEUP"}, - ], - sequence_id="shortcut-build100", - sequence_name="Titles", + # Simulate Mapping currently waiting for its own ACK lock. Operator shortcuts + # must bypass that lock completely in BUILD102. + mapping_lock = hub._device_vmix_send_lock("GFX-BUILD100") + await mapping_lock.acquire() + try: + result = await asyncio.wait_for( + hub.run_vmix_sequence_for_user( + user, + [ + {"Function": "OverlayInput1In", "Input": "SCORE"}, + {"Function": "OverlayInput2In", "Input": "PENALTY"}, + {"Function": "OverlayInput3In", "Input": "LINEUP"}, + ], + sequence_id="shortcut-build102", + sequence_name="Titles", + ), + timeout=0.5, ) - ) + finally: + mapping_lock.release() - seen: list[dict] = [] - for index, ok in enumerate([True, False, True]): - command = None - for _ in range(100): - await asyncio.sleep(0) - commands = [item for item in ws.sent if item.get("type") == "vmix.command"] - if len(commands) > index: - command = commands[index] - break - assert command is not None - seen.append(command) - await hub.receive_command_ack( - "GFX-BUILD100", - { - "type": "command.ack", - "request_id": command["request_id"], - "ok": ok, - "reason": "bad input" if not ok else "", - }, - ) - - assert not [item for item in ws.sent if item.get("type") == "vmix.batch"] - assert [item["command"]["Input"] for item in seen] == ["SCORE", "BROKEN", "LINEUP"] - result = await task - assert result["transport"] == "shortcut-sequential" + commands = [item for item in ws.sent if item.get("type") == "vmix.command"] + assert [item["command"]["Input"] for item in commands] == ["SCORE", "PENALTY", "LINEUP"] + assert result["transport"] == "shortcut-no-ack" + assert result["confirmation"] == "not_waited" assert result["requested"] == 3 assert result["attempted"] == 3 - assert result["applied"] == 2 - assert result["failed"] == 1 - assert result["ok"] is False - assert result["results"][2]["ok"] is True + assert result["applied"] == 3 + assert result["failed"] == 0 + assert result["ok"] is True + assert all(row["ack_waited"] is False for row in result["results"]) + assert not hub._pending_commands asyncio.run(scenario()) -def test_build100_backend_marks_interactive_shortcuts_sequential() -> None: - assert 'interactive_shortcut = bool' in BRIDGE - assert 'transport = "batch" if use_batch else ("shortcut-sequential"' in BRIDGE - assert 'if not item["ok"] and not interactive_shortcut' in BRIDGE +def test_build102_backend_marks_interactive_shortcuts_no_ack() -> None: + assert 'transport = "shortcut-no-ack" if interactive_shortcut' in BRIDGE + assert 'async with self._device_vmix_shortcut_send_lock(target_device_id)' in BRIDGE + assert '"ack_waited": False' in BRIDGE + assert '"confirmation": "not_waited" if interactive_shortcut else "ack"' in BRIDGE diff --git a/tests/test_build57_quick_panel_parameters.py b/tests/test_build57_quick_panel_parameters.py index 61a6a10..da25ae4 100644 --- a/tests/test_build57_quick_panel_parameters.py +++ b/tests/test_build57_quick_panel_parameters.py @@ -36,8 +36,9 @@ def test_quick_panel_selector_is_configurable_and_sent_before_sequence(): assert "hockeyCommitQuickPanelButtonContext" in JS click = JS.index('el.runtimeButtonDock.querySelectorAll("[data-quick-command-button]")') snippet = JS[click:click + 2600] - assert "await hockeyCommitQuickPanelButtonContext(button, attachedSelectors)" in snippet + assert "await hockeyCommitQuickPanelButtonContext(button, attachedSelectors, { refreshMapping: false })" in snippet assert snippet.index("await hockeyCommitQuickPanelButtonContext") < snippet.index("runShortcutSequence") + assert "full Mapping ACK round-trip" in snippet assert '"/control/values"' in JS assert '@router.put("/games/{external_id}/control/values")' in ROUTER assert 'quick_panel_selectors' in APP diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index c4f83ea..beb62b2 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -4603,9 +4603,11 @@ function startCustomTooltips() { const detail = payload?.detail?.message || payload?.detail || `HTTP ${response.status}`; throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); } - // BUILD53 compatibility marker: trackRuntimeOverlayCommands(clean, execution) - // BUILD100 tracks only ACK-successful commands below, so a failed title cannot - // incorrectly light the ON AIR state. + // BUILD102: interactive shortcuts return as soon as the server has handed + // commands to the live Agent WebSocket. `ok` therefore means dispatched to + // Agent, not ACK-confirmed by vMix. This keeps F-keys and dock buttons instant. + // BUILD53 compatibility marker retained for older regression tests: + // trackRuntimeOverlayCommands(clean, execution) const resultRows = Array.isArray(payload?.results) ? payload.results : []; const successfulCommands = clean.filter((_command, index) => { const row = resultRows[index]; @@ -8735,7 +8737,7 @@ async function hockeyApplyQuickSelector(selector, value, label = "") { }, { refreshMapping: true }); } -async function hockeyCommitQuickPanelButtonContext(button, attachedSelectors) { +async function hockeyCommitQuickPanelButtonContext(button, attachedSelectors, { refreshMapping = false } = {}) { const patch = { "panel.last_button.id": button.id, "panel.last_button.label": button.label, @@ -8747,7 +8749,7 @@ async function hockeyCommitQuickPanelButtonContext(button, attachedSelectors) { patch[`panel.${selector.id}.label`] = option?.label || value; patch[`panel.last_button.${selector.id}`] = value; } - await hockeySetMatchValues(patch, { refreshMapping: true }); + await hockeySetMatchValues(patch, { refreshMapping }); } function renderHockeyQuickCommandDock() { @@ -8820,7 +8822,10 @@ function renderHockeyQuickCommandDock() { control.classList.add("is-sending"); try { const attachedSelectors = selectors.filter((selector) => selector.button_id === button.id); - await hockeyCommitQuickPanelButtonContext(button, attachedSelectors); + // BUILD102: save the button context without a full Mapping ACK round-trip. + // Selector changes already refresh Mapping; the operator click itself must not + // wait behind Mapping before its Shortcut Sequence is dispatched. + await hockeyCommitQuickPanelButtonContext(button, attachedSelectors, { refreshMapping: false }); if (button.mode === "toggle") { const flagResult = await hockeyToggleMatchFlag(hockeyPrematchFlagKey(button.id)); if (!flagResult) throw new Error("Не удалось изменить состояние кнопки");