fix: guard connection-state callbacks against no running event loop

The previous hotfix's disconnect()-on-comm-error path can now fire
during server/brewpi.py's own final best-effort hardware-release
cleanup (finally: block's heater.close()/stirrer.activate(False)),
which runs after the event loop has already stopped - previously
nothing about hardware teardown touched an AttributeChange-tracked
field, so this path was dormant. asyncio.create_task() there raises
RuntimeError: no running event loop, crashing the shutdown sequence.

Adds fire_and_forget() (tasks/task.py) - asyncio.create_task() that
silently drops the message instead of raising when there's no loop to
schedule on - and uses it for the Connected/FirmwareVersion broadcasts.
SudTask.check_connections() also now no-ops entirely without a running
loop, since self.sud.stop() cascades into further unguarded
create_task() calls (on_state_changed, heater_task.shutdown(), ...)
that a stopped loop can't help with anyway.

Reproduced and verified fixed directly against HeaterTask/HeaterHendi
with no event loop running (mirrors the exact server/brewpi.py
shutdown-time call path that crashed on the brewpi Pi).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YaPLuRPpyjWcwhMvCvpHCL
This commit is contained in:
2026-07-03 19:47:17 +02:00
co-authored by Claude Sonnet 5
parent 0877e7754b
commit 069fc352bc
5 changed files with 40 additions and 11 deletions
+18 -4
View File
@@ -1,6 +1,6 @@
import asyncio
import bisect
from tasks import ATask
from tasks import ATask, fire_and_forget
from ws.message import MsgIo
from utils.value import ChangedFloat
from components import APid, AStirrer, AHeater
@@ -265,14 +265,28 @@ class SudTask(ATask):
outside RAMPING/HOLDING/WAIT_USER/PAUSED), the same path a manual
Stop press takes, so all the usual shutdown plumbing (heater
shutdown, sud log stop_run - see SudTask.set_on_end()'s wiring in
server/brewpi.py) fires exactly as it would for Stop."""
server/brewpi.py) fires exactly as it would for Stop.
No-op if there's no running event loop - happens when a
Connectable's connected attribute changes as a side effect of the
server's own final best-effort hardware-release cleanup
(server/brewpi.py's `finally:` block), which runs after the loop
has already stopped. self.sud.stop() below cascades into Sud's own
'state' observable (on_state_changed -> heater_task.shutdown()/
sud_log_task.stop_run()), which isn't itself guarded against a
stopped loop - the whole server is already tearing down at that
point regardless, so there's nothing useful to stop/notify."""
try:
asyncio.get_running_loop()
except RuntimeError:
return
if self.sud.state in (SudState.IDLE, SudState.DONE):
return
if self.heater.connected and self.stirrer.connected:
return
self.sud.stop()
asyncio.create_task(self.send({'Error': 'Heater/Stirrer disconnected - brew stopped.'}))
asyncio.create_task(self.send({'Error': None}))
fire_and_forget(self.send({'Error': 'Heater/Stirrer disconnected - brew stopped.'}))
fire_and_forget(self.send({'Error': None}))
def on_state_changed(self, value):
asyncio.create_task(self.send({'State': str(value)}))