diff --git a/buckaroo/server/websocket_handler.py b/buckaroo/server/websocket_handler.py index da5917932..0a00202eb 100644 --- a/buckaroo/server/websocket_handler.py +++ b/buckaroo/server/websocket_handler.py @@ -51,7 +51,21 @@ def on_message(self, message): def _handle_buckaroo_state_change(self, new_state): sessions = self.application.settings["sessions"] session = sessions.get(self.session_id) - if not session or session.mode != "buckaroo": + if not session: + return + if session.mode != "buckaroo": + # Lazy mode (and any future read-only mode) doesn't currently + # plumb state_change → filter into its dataflow. Return a + # structured error rather than the pre-#793 silent drop so the + # client can render "filtering not supported in this mode" + # instead of hanging on the WS read. + self.write_message(json.dumps({ + "type": "error", + "error_code": "state_change_unsupported_mode", + "message": ( + f"buckaroo_state_change is not supported in " + f"mode={session.mode!r}; this reader is read-only " + "(no filter / cleaning / post-processing).")})) return dataflow = session.xorq_dataflow if session.backend == "xorq" else session.dataflow if dataflow is None: diff --git a/tests/unit/server/test_load_expr.py b/tests/unit/server/test_load_expr.py index e4d6cc9db..a53ede645 100644 --- a/tests/unit/server/test_load_expr.py +++ b/tests/unit/server/test_load_expr.py @@ -141,6 +141,48 @@ async def test_ws_search_pushdown(self): finally: shutil.rmtree(builds_root, ignore_errors=True) + @tornado.testing.gen_test + async def test_lazy_state_change_returns_explicit_error(self): + """Regression for #793: ``_handle_buckaroo_state_change`` + returned silently for ``mode != "buckaroo"`` — lazy-polars + sessions had their state_change messages dropped on the floor + with no response, hanging the client until its read timed out. + + Must return a structured error instead so callers can render + a sensible "not supported" message. + """ + import asyncio + csv_fd, csv_path = tempfile.mkstemp(suffix=".csv") + os.close(csv_fd) + try: + pd.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]}).to_csv(csv_path, index=False) + await _post(self.get_http_port(), "/load", + {"session": "lazy-sc", "path": csv_path, "mode": "lazy"}) + + ws = await tornado.websocket.websocket_connect( + f"ws://localhost:{self.get_http_port()}/ws/lazy-sc") + await ws.read_message() # discard initial_state + + ws.write_message(json.dumps({ + "type": "buckaroo_state_change", + "new_state": { + "post_processing": "", "cleaning_method": "", + "quick_command_args": {"search": ["x"]}, + "df_display": "main", "show_commands": False, + "sampled": False, "search_string": "x", + }})) + + # Today: nothing comes back; client times out. + # After fix: a structured error frame within a couple seconds. + frame = await asyncio.wait_for(ws.read_message(), timeout=3.0) + self.assertIsNotNone(frame) + d = json.loads(frame) + self.assertEqual(d.get("type"), "error") + self.assertIn("error_code", d) + ws.close() + finally: + os.unlink(csv_path) + @tornado.testing.gen_test async def test_session_reuse_xorq_then_pandas(self): """A client that POSTs /load_expr and then POSTs /load with the