diff --git a/tasks/__init__.py b/tasks/__init__.py index 00988a7..6fa192d 100644 --- a/tasks/__init__.py +++ b/tasks/__init__.py @@ -1,4 +1,4 @@ -from tasks.task import ATask, TaskManager +from tasks.task import ATask, TaskManager, fire_and_forget from tasks.tempsensor import TempSensorTask from tasks.heater import HeaterTask from tasks.stirrer import StirrerTask diff --git a/tasks/heater.py b/tasks/heater.py index a32f642..cd23415 100755 --- a/tasks/heater.py +++ b/tasks/heater.py @@ -1,5 +1,5 @@ import asyncio -from tasks import ATask +from tasks import ATask, fire_and_forget from components import AHeater from ws.message import MsgIo from utils.value import ChangedInteger @@ -34,12 +34,12 @@ class HeaterTask(ATask): self._on_connected_changed = callback def on_connected_changed(self, value): - asyncio.create_task(self.send({'Connected': value})) + fire_and_forget(self.send({'Connected': value})) if self._on_connected_changed: self._on_connected_changed(value) def on_firmware_version_changed(self, value): - asyncio.create_task(self.send({'FirmwareVersion': value})) + fire_and_forget(self.send({'FirmwareVersion': value})) def shutdown(self): """Called when the brew ends (DONE/IDLE). Switches to open-loop at diff --git a/tasks/stirrer.py b/tasks/stirrer.py index 5f35154..f32c3c7 100644 --- a/tasks/stirrer.py +++ b/tasks/stirrer.py @@ -1,5 +1,5 @@ import asyncio -from tasks import ATask +from tasks import ATask, fire_and_forget from ws.message import MsgIo from components import AStirrer from utils.value import ChangedInteger @@ -34,12 +34,12 @@ class StirrerTask(ATask): self._on_connected_changed = callback def on_connected_changed(self, value): - asyncio.create_task(self.send({'Connected': value})) + fire_and_forget(self.send({'Connected': value})) if self._on_connected_changed: self._on_connected_changed(value) def on_firmware_version_changed(self, value): - asyncio.create_task(self.send({'FirmwareVersion': value})) + fire_and_forget(self.send({'FirmwareVersion': value})) async def recv(self, data): for pair in data.items(): diff --git a/tasks/sud.py b/tasks/sud.py index 2347134..f6badfc 100644 --- a/tasks/sud.py +++ b/tasks/sud.py @@ -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)})) diff --git a/tasks/task.py b/tasks/task.py index 60608b0..0341335 100644 --- a/tasks/task.py +++ b/tasks/task.py @@ -3,6 +3,21 @@ import asyncio from utils.value import AttributeChange +def fire_and_forget(coro): + """asyncio.create_task(), tolerant of there being no running event loop. + An actor's observable attribute (e.g. Connectable.connected) can change + as a side effect of the server's own final best-effort hardware-release + cleanup (server/brewpi.py's `finally:` block - heater.close()/ + stirrer.activate(False)), which runs after the loop has already + stopped. There's nobody left to notify at that point anyway, so this + just drops the message instead of crashing the shutdown sequence.""" + try: + return asyncio.create_task(coro) + except RuntimeError: + coro.close() + return None + + class ATask(AttributeChange): def __init__(self, interval): AttributeChange.__init__(self)