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 dataclasses import dataclass, field
from pathlib import Path
from functools import partial
from typing import Any, Optional
import uvicorn
@@ -1210,7 +1211,9 @@ class WebServer:
async def wifi_scan():
"""Scan for available WiFi networks."""
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")
async def wifi_saved():
@@ -1258,16 +1261,21 @@ class WebServer:
Body: {ssid: str (optional), password: str (optional)}
"""
from ..system.network import hotspot_enable
return hotspot_enable(
ssid=data.get("ssid", "Pi-Pedal"),
password=data.get("password", "pedal1234"),
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None, partial(hotspot_enable,
ssid=data.get("ssid", "Pi-Pedal"),
password=data.get("password", "pedal1234"),
),
)
return result
@app.post("/api/wifi/hotspot/disable")
async def wifi_hotspot_disable():
"""Disable WiFi hotspot."""
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 ──────────────────────────────────
@@ -1299,7 +1307,8 @@ class WebServer:
async def bluetooth_scan():
"""Scan for discoverable Bluetooth devices."""
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")
async def bluetooth_paired():
@@ -1426,6 +1435,7 @@ class WebServer:
Changes are saved to ~/.pedal/config.yaml so they survive reboot.
The switch restarts JACK (brief audio dropout ~1s).
"""
loop = asyncio.get_event_loop()
audio_sys = self.deps.audio_system
if not audio_sys:
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
saved_auto_connect = audio_sys.config.auto_connect
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
if not ok:
# Rollback on failure
audio_sys.config.profile = 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:
try:
jack_client.start()
@@ -1616,6 +1626,7 @@ class WebServer:
Special handling:
``brightness`` — UI sends 110, stored as 0.11.0 float.
"""
loop = asyncio.get_event_loop()
if self.deps.config is None:
raise HTTPException(status_code=503, detail="Config not available")
@@ -1670,7 +1681,7 @@ class WebServer:
audio_sys.stop_jack()
if jack_client:
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:
logger.warning("JACK restart failed after device change — rolling back")
# Restore old devices is complex, just log the failure