From 442b0321179701b36735633dda252075170008ab Mon Sep 17 00:00:00 2001 From: Jens Ahrensfeld Date: Mon, 22 Jun 2026 21:28:30 +0200 Subject: [PATCH] Cap the forecast sent over the wire to avoid exceeding WebSocket max_size A fine enough dt over a multi-hour brew can produce a single Forecast message several MB in size - large enough to exceed the websockets library's default 1 MiB max_size and get the connection closed outright (code 1009) right after Load. Confirmed by reproducing with dt=0.1: the 276KB message at dt=1 was safe, but the equivalent ~2.7MB at dt=0.1 reliably disconnected the client. SudTask._send_forecast() now thins T/Theta to at most MAX_FORECAST_POINTS (1000) via simple decimation before sending - self.forecast_t/ forecast_theta themselves stay at full simulated resolution, since _continue_forecast_after_confirm()'s bisect-based truncation needs exact t-value matches, and SudLogTask's logs/forecast_*.json wants full fidelity. Only the copy actually sent to clients is thinned, which the GUI's few-hundred-pixel-wide plot can't show the difference from anyway. Verified against a live server+client over the real WebSocket connection with the dt=0.1 config that originally triggered the disconnect: 4/4 clean runs, forecast arrives capped under 1000 points, and the rendered plot is visually identical to the un-downsampled version. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01DkkuG48uHFCGKe6dPSERFk --- tasks/sud.py | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/tasks/sud.py b/tasks/sud.py index 3cb3502..e89cd82 100644 --- a/tasks/sud.py +++ b/tasks/sud.py @@ -7,6 +7,34 @@ from components import APid, AStirrer from components.plant import APlant from components.sud import Sud, SudState +# Upper bound on how many (t, theta) points the forecast is thinned to +# before going out over the wire (see _send_forecast()) - a fine enough +# dt over a multi-hour brew can otherwise produce a single JSON message +# of several MB, large enough to exceed the websockets library's default +# 1 MiB max_size and get the connection closed outright (code 1009). The +# GUI's forecast plot is a few hundred pixels wide, so this many points +# is already far more resolution than it can show; self.forecast_t/ +# forecast_theta themselves stay at full simulated resolution - this +# only thins the copy actually sent to clients. +MAX_FORECAST_POINTS = 1000 + + +def _downsample(t, theta, max_points=MAX_FORECAST_POINTS): + """Returns (t, theta) thinned to at most max_points entries by simple + decimation, always keeping the first and last point - losing a few + intermediate samples doesn't matter for a plot this size, but losing + the endpoints would visibly truncate the curve or its final value.""" + n = len(t) + if n <= max_points: + return t, theta + step = -(-n // max_points) # ceil(n / max_points) + t_ds = t[::step] + theta_ds = theta[::step] + if t_ds[-1] != t[-1]: + t_ds = t_ds + [t[-1]] + theta_ds = theta_ds + [theta[-1]] + return t_ds, theta_ds + class SudTask(ATask): def __init__(self, sud: Sud, tc: APid, stirrer: AStirrer, pot: APlant, dt, interval, msg_handler: MsgIo, @@ -166,9 +194,10 @@ class SudTask(ATask): await self._send_forecast() async def _send_forecast(self): + t, theta = _downsample(self.forecast_t, self.forecast_theta) await self.send({'Forecast': { - 'T': self.forecast_t, - 'Theta': self.forecast_theta, + 'T': t, + 'Theta': theta, 'Finished': self.forecast_finished, }})