fix: move blocking subprocess calls off the async event loop

Wrap long-running synchronous calls in run_in_executor to prevent
the web UI from freezing during:

- wifi_scan (~20s scan)
- hotspot_enable / hotspot_disable (~60s each)
- bluetooth_scan (~12s scan)
- start_jack (~15s JACK restart) in set_audio_profile and save_settings

Each blocking call now runs in a thread executor, keeping the FastAPI
event loop responsive for other requests.

Added: functools.partial import, asyncio.get_event_loop() in two handlers.
This commit is contained in:
2026-06-17 21:19:14 -04:00
parent 4a6e7900ce
commit 3c8d5e4c1c
+20 -9
View File
@@ -21,6 +21,7 @@ import datetime
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from functools import partial
from typing import Any, Optional from typing import Any, Optional
import uvicorn import uvicorn
@@ -1210,7 +1211,9 @@ class WebServer:
async def wifi_scan(): async def wifi_scan():
"""Scan for available WiFi networks.""" """Scan for available WiFi networks."""
from ..system.network import wifi_scan from ..system.network import wifi_scan
return {"networks": wifi_scan()} loop = asyncio.get_event_loop()
networks = await loop.run_in_executor(None, wifi_scan)
return {"networks": networks}
@app.get("/api/wifi/saved") @app.get("/api/wifi/saved")
async def wifi_saved(): async def wifi_saved():
@@ -1258,16 +1261,21 @@ class WebServer:
Body: {ssid: str (optional), password: str (optional)} Body: {ssid: str (optional), password: str (optional)}
""" """
from ..system.network import hotspot_enable from ..system.network import hotspot_enable
return hotspot_enable( loop = asyncio.get_event_loop()
ssid=data.get("ssid", "Pi-Pedal"), result = await loop.run_in_executor(
password=data.get("password", "pedal1234"), None, partial(hotspot_enable,
ssid=data.get("ssid", "Pi-Pedal"),
password=data.get("password", "pedal1234"),
),
) )
return result
@app.post("/api/wifi/hotspot/disable") @app.post("/api/wifi/hotspot/disable")
async def wifi_hotspot_disable(): async def wifi_hotspot_disable():
"""Disable WiFi hotspot.""" """Disable WiFi hotspot."""
from ..system.network import hotspot_disable from ..system.network import hotspot_disable
return hotspot_disable() loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, hotspot_disable)
# ── Bluetooth endpoints ────────────────────────────────── # ── Bluetooth endpoints ──────────────────────────────────
@@ -1299,7 +1307,8 @@ class WebServer:
async def bluetooth_scan(): async def bluetooth_scan():
"""Scan for discoverable Bluetooth devices.""" """Scan for discoverable Bluetooth devices."""
from ..system.bluetooth import bt_scan from ..system.bluetooth import bt_scan
return bt_scan(timeout=12) loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, partial(bt_scan, timeout=12))
@app.get("/api/bluetooth/paired") @app.get("/api/bluetooth/paired")
async def bluetooth_paired(): async def bluetooth_paired():
@@ -1426,6 +1435,7 @@ class WebServer:
Changes are saved to ~/.pedal/config.yaml so they survive reboot. Changes are saved to ~/.pedal/config.yaml so they survive reboot.
The switch restarts JACK (brief audio dropout ~1s). The switch restarts JACK (brief audio dropout ~1s).
""" """
loop = asyncio.get_event_loop()
audio_sys = self.deps.audio_system audio_sys = self.deps.audio_system
if not audio_sys: if not audio_sys:
raise HTTPException(status_code=503, detail="Audio system not available") raise HTTPException(status_code=503, detail="Audio system not available")
@@ -1519,13 +1529,13 @@ class WebServer:
# exist until jack_client.start() recreates the JACK client below # exist until jack_client.start() recreates the JACK client below
saved_auto_connect = audio_sys.config.auto_connect saved_auto_connect = audio_sys.config.auto_connect
audio_sys.config.auto_connect = False audio_sys.config.auto_connect = False
ok = audio_sys.start_jack(timeout=15) ok = await loop.run_in_executor(None, partial(audio_sys.start_jack, timeout=15))
audio_sys.config.auto_connect = saved_auto_connect audio_sys.config.auto_connect = saved_auto_connect
if not ok: if not ok:
# Rollback on failure # Rollback on failure
audio_sys.config.profile = old_key audio_sys.config.profile = old_key
logger.warning("JACK restart failed — attempting rollback to %s", old_key) logger.warning("JACK restart failed — attempting rollback to %s", old_key)
audio_sys.start_jack(timeout=15) ok = await loop.run_in_executor(None, partial(audio_sys.start_jack, timeout=15))
if jack_client: if jack_client:
try: try:
jack_client.start() jack_client.start()
@@ -1616,6 +1626,7 @@ class WebServer:
Special handling: Special handling:
``brightness`` — UI sends 110, stored as 0.11.0 float. ``brightness`` — UI sends 110, stored as 0.11.0 float.
""" """
loop = asyncio.get_event_loop()
if self.deps.config is None: if self.deps.config is None:
raise HTTPException(status_code=503, detail="Config not available") raise HTTPException(status_code=503, detail="Config not available")
@@ -1670,7 +1681,7 @@ class WebServer:
audio_sys.stop_jack() audio_sys.stop_jack()
if jack_client: if jack_client:
jack_client.stop() jack_client.stop()
ok = audio_sys.start_jack(timeout=10) ok = await loop.run_in_executor(None, partial(audio_sys.start_jack, timeout=10))
if not ok: if not ok:
logger.warning("JACK restart failed after device change — rolling back") logger.warning("JACK restart failed after device change — rolling back")
# Restore old devices is complex, just log the failure # Restore old devices is complex, just log the failure