From 7c21fe4a9323db77d3495279a68cc41f27f1ea67 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 18 Jun 2026 22:21:03 +0000 Subject: [PATCH] fix(sessions): invalidate session-list cache on CLI-visibility toggle + de-flake node test timeouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROOT FIX (product bug): POST /api/settings never invalidated the session-list cache, so toggling show_cli_sessions/show_cron_sessions could serve a stale sidebar for up to the cache TTL when the settings-file mtime stamp collided with a cached entry. The handler now clears the cache directly on those toggles. This is also the root cause of the recurring test_gateway_sync CI flake (session absent from /api/sessions right after the visibility toggle). DEFENSE-IN-DEPTH: cleanup_test_sessions fixture now resets show_cli_sessions to default BEFORE each test too, not only in teardown. FLAKE FIX (test infra): bump 31 node-subprocess correctness-harness timeouts from 10s -> 30s across 23 test files. Node spawn + V8 warmup under parallel CI shard load can exceed 10s, causing TimeoutExpired flakes (e.g. test_1325 on #4445). Only real node subprocess.run() sites changed — mock signatures, HTTP urlopen, and deliberate timeout-behavior tests (test_api_timeout, timeout=0/1/2) untouched. --- CHANGELOG.md | 6 ++++++ api/routes.py | 18 ++++++++++++++++++ tests/conftest.py | 12 ++++++++++++ tests/test_1325_user_fenced_code.py | 2 +- tests/test_1707_workspace_filename_click.py | 2 +- tests/test_chat_start_provider_fallback.py | 4 ++-- tests/test_issue1154_fenced_code_leak.py | 2 +- tests/test_issue1188_fuzzy_match.py | 2 +- tests/test_issue1446_glued_heading_lift.py | 2 +- ...ssue1618_yaml_json_diff_newline_preserve.py | 2 +- ...test_issue1771_session_model_switch_sync.py | 2 +- tests/test_issue2881_workspace_chat_links.py | 2 +- ...st_issue3360_multi_slash_model_collision.py | 4 ++-- tests/test_issue3368_model_prefix_shadowing.py | 6 +++--- tests/test_issue3429_uri_scheme_model_ids.py | 4 ++-- tests/test_issue3460_cron_session_unread.py | 2 +- tests/test_issue3691_model_picker_show_all.py | 8 ++++---- tests/test_issue3959_model_suffix_dedup.py | 2 +- ...est_issue500_message_list_virtualization.py | 2 +- ...est_issue500_session_list_virtualization.py | 2 +- tests/test_model_default_boot_precedence.py | 2 +- tests/test_reasoning_chip_js_behaviour.py | 2 +- tests/test_renderer_js_behaviour.py | 2 +- tests/test_session_copy_link_action.py | 2 +- tests/test_session_lineage_collapse.py | 2 +- tests/test_tool_card_preview_summary.py | 2 +- 26 files changed, 67 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8feaf0a8..b8d339c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.502] — 2026-06-18 — Release RL (CLI-session visibility toggle invalidates the sidebar cache) + +### Fixed + +- **Toggling "show CLI/messaging sessions" now takes effect immediately instead of after a brief delay.** `POST /api/settings` did not invalidate the session-list cache, which is keyed partly on the visibility setting and otherwise only revalidated by the settings-file mtime stamp. When a visibility toggle wrote the settings file within the same mtime granularity as a cached entry (and resolved to the default-valued cache key), the sidebar could keep serving the stale session set for up to the cache TTL (~2.5s). The settings handler now clears the session-list cache directly whenever `show_cli_sessions` / `show_cron_sessions` change. This was also the root cause of an intermittent `test_gateway_sync` CI flake (a freshly-inserted CLI/gateway session occasionally absent from `/api/sessions` right after the visibility toggle). + ## [v0.51.501] — 2026-06-18 — Release RK (Ctrl/Cmd+, opens Settings) ### Added diff --git a/api/routes.py b/api/routes.py index 2001bb26..6181a447 100644 --- a/api/routes.py +++ b/api/routes.py @@ -9941,6 +9941,24 @@ def handle_post(handler, parsed) -> bool: saved = save_settings(body) saved.pop("password_hash", None) # never expose hash to client + # Settings that change which sessions appear in the sidebar must + # invalidate the session-list cache directly. Relying on the cache's + # settings-file mtime stamp is fragile: a toggle that writes the + # settings file within the same mtime granularity as a cached entry (and + # produces the default-valued key, e.g. show_cli_sessions back to its + # True default) can leave a stale row set served for up to the cache TTL. + # This is the root cause of the intermittent gateway_sync test flake + # (a freshly-inserted CLI/gateway session occasionally absent from + # /api/sessions right after the visibility toggle). Invalidate explicitly. + if any( + k in body + for k in ("show_cli_sessions", "show_cron_sessions") + ): + try: + _clear_session_list_cache() + except Exception: + pass + auth_enabled_after = is_auth_enabled() auth_just_enabled = bool( requested_password and auth_enabled_after and not auth_enabled_before diff --git a/tests/conftest.py b/tests/conftest.py index 95f8b497..62205b22 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1073,6 +1073,18 @@ def cleanup_test_sessions(): was left in an unexpected state by a prior test in the same shard. """ created: list[str] = [] + # Defense-in-depth: reset the CLI-session visibility setting to its default + # BEFORE the test runs too, not only in teardown. Teardown-only reset relies + # on every sibling test being wrapped by this fixture AND on its teardown + # actually completing; a pre-test reset guarantees each test starts from a + # known visibility state regardless of what a prior test left behind, so a + # test that reads /api/sessions without first setting the toggle can't + # inherit a leaked value. Pairs with the POST /api/settings cache + # invalidation that fixes the gateway_sync row-absence flake at the source. + try: + _post(TEST_BASE, "/api/settings", {"show_cli_sessions": False}) + except Exception: + pass yield created for sid in created: diff --git a/tests/test_1325_user_fenced_code.py b/tests/test_1325_user_fenced_code.py index 7b4ec77a..be918a4b 100644 --- a/tests/test_1325_user_fenced_code.py +++ b/tests/test_1325_user_fenced_code.py @@ -46,7 +46,7 @@ def _run_user_render(text_input): try: result = subprocess.run( ['node', tf.name, json.dumps(text_input)], - capture_output=True, text=True, timeout=10 + capture_output=True, text=True, timeout=30 ) if result.returncode != 0: raise RuntimeError(f"node error: {result.stderr}") diff --git a/tests/test_1707_workspace_filename_click.py b/tests/test_1707_workspace_filename_click.py index 4fe47b84..b0011a9f 100644 --- a/tests/test_1707_workspace_filename_click.py +++ b/tests/test_1707_workspace_filename_click.py @@ -278,7 +278,7 @@ setTimeout_(() => { ) r = subprocess.run( [NODE, "-e", js], - capture_output=True, text=True, timeout=10, + capture_output=True, text=True, timeout=30, ) if r.returncode != 0: raise RuntimeError(f"node failed: {r.stderr}") diff --git a/tests/test_chat_start_provider_fallback.py b/tests/test_chat_start_provider_fallback.py index 018a1c53..d3c6b202 100644 --- a/tests/test_chat_start_provider_fallback.py +++ b/tests/test_chat_start_provider_fallback.py @@ -126,7 +126,7 @@ def _run_helper(driver_path, payload): [NODE, driver_path, str(UI_JS_PATH), json.dumps(payload)], capture_output=True, text=True, - timeout=10, + timeout=30, ) if result.returncode != 0: raise RuntimeError(f"node driver failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}") @@ -139,7 +139,7 @@ def _run_model_state_helper(driver_path, payload): [NODE, driver_path, str(UI_JS_PATH), json.dumps(payload)], capture_output=True, text=True, - timeout=10, + timeout=30, ) if result.returncode != 0: raise RuntimeError(f"node driver failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}") diff --git a/tests/test_issue1154_fenced_code_leak.py b/tests/test_issue1154_fenced_code_leak.py index 30df0e92..5c24d68a 100644 --- a/tests/test_issue1154_fenced_code_leak.py +++ b/tests/test_issue1154_fenced_code_leak.py @@ -64,7 +64,7 @@ def _render(driver_path, markdown: str) -> str: import subprocess result = subprocess.run( [NODE, driver_path, str(UI_JS_PATH)], - input=markdown, capture_output=True, text=True, timeout=10, + input=markdown, capture_output=True, text=True, timeout=30, ) if result.returncode != 0: raise RuntimeError(f"node driver failed: {result.stderr}") diff --git a/tests/test_issue1188_fuzzy_match.py b/tests/test_issue1188_fuzzy_match.py index 1b5697e8..9c795888 100644 --- a/tests/test_issue1188_fuzzy_match.py +++ b/tests/test_issue1188_fuzzy_match.py @@ -77,7 +77,7 @@ def _find(driver_path, model_id: str, options: list[str], preferred: str | None result = subprocess.run( [NODE, driver_path, str(UI_JS_PATH), json.dumps({"modelId": model_id, "options": options, "preferredProvider": preferred})], - capture_output=True, text=True, timeout=10, + capture_output=True, text=True, timeout=30, ) if result.returncode != 0: raise RuntimeError(f"node driver failed: {result.stderr}") diff --git a/tests/test_issue1446_glued_heading_lift.py b/tests/test_issue1446_glued_heading_lift.py index a9ef33f6..fcd2f924 100644 --- a/tests/test_issue1446_glued_heading_lift.py +++ b/tests/test_issue1446_glued_heading_lift.py @@ -233,7 +233,7 @@ def _render(driver_path: str, markdown: str) -> str: input=markdown, capture_output=True, text=True, - timeout=10, + timeout=30, ) if result.returncode != 0: raise RuntimeError(f"node driver failed: {result.stderr}") diff --git a/tests/test_issue1618_yaml_json_diff_newline_preserve.py b/tests/test_issue1618_yaml_json_diff_newline_preserve.py index 61f983ef..76bc8d44 100644 --- a/tests/test_issue1618_yaml_json_diff_newline_preserve.py +++ b/tests/test_issue1618_yaml_json_diff_newline_preserve.py @@ -169,7 +169,7 @@ def _render(driver_path, markdown: str) -> str: input=markdown, capture_output=True, text=True, - timeout=10, + timeout=30, ) if result.returncode != 0: raise RuntimeError(f"node driver failed: {result.stderr}") diff --git a/tests/test_issue1771_session_model_switch_sync.py b/tests/test_issue1771_session_model_switch_sync.py index e74ac84a..e424851c 100644 --- a/tests/test_issue1771_session_model_switch_sync.py +++ b/tests/test_issue1771_session_model_switch_sync.py @@ -164,7 +164,7 @@ def _run_sync(driver_path, *, session_model, initial_value="@expensive:gpt-5.5", [NODE, driver_path, str(UI_JS_PATH), json.dumps(payload)], capture_output=True, text=True, - timeout=10, + timeout=30, ) if result.returncode != 0: raise RuntimeError(f"node driver failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}") diff --git a/tests/test_issue2881_workspace_chat_links.py b/tests/test_issue2881_workspace_chat_links.py index c8f5aa89..6de2b20f 100644 --- a/tests/test_issue2881_workspace_chat_links.py +++ b/tests/test_issue2881_workspace_chat_links.py @@ -63,7 +63,7 @@ def _render(driver_path: str, markdown: str) -> str: input=markdown, capture_output=True, text=True, - timeout=10, + timeout=30, ) if result.returncode != 0: raise RuntimeError(result.stderr) diff --git a/tests/test_issue3360_multi_slash_model_collision.py b/tests/test_issue3360_multi_slash_model_collision.py index ec995e45..c2e7808c 100644 --- a/tests/test_issue3360_multi_slash_model_collision.py +++ b/tests/test_issue3360_multi_slash_model_collision.py @@ -114,7 +114,7 @@ def _find(driver_path, model_id, options, preferred=None): result = subprocess.run( [NODE, driver_path, str(UI_JS_PATH), json.dumps({"modelId": model_id, "options": options, "preferredProvider": preferred})], - capture_output=True, text=True, timeout=10, + capture_output=True, text=True, timeout=30, ) if result.returncode != 0: raise RuntimeError(f"node driver failed: {result.stderr}") @@ -124,7 +124,7 @@ def _find(driver_path, model_id, options, preferred=None): def _norm_keys(driver_path, ids): result = subprocess.run( [NODE, driver_path, str(UI_JS_PATH), json.dumps(ids)], - capture_output=True, text=True, timeout=10, + capture_output=True, text=True, timeout=30, ) if result.returncode != 0: raise RuntimeError(f"node driver failed: {result.stderr}") diff --git a/tests/test_issue3368_model_prefix_shadowing.py b/tests/test_issue3368_model_prefix_shadowing.py index cffc798c..00287690 100644 --- a/tests/test_issue3368_model_prefix_shadowing.py +++ b/tests/test_issue3368_model_prefix_shadowing.py @@ -164,7 +164,7 @@ def _resolve(driver, query, groups, sel_options): r = subprocess.run( [NODE, driver, str(COMMANDS_JS_PATH), json.dumps({"query": query, "groups": groups, "selOptions": sel_options})], - capture_output=True, text=True, timeout=10, + capture_output=True, text=True, timeout=30, ) if r.returncode != 0: raise RuntimeError(f"node driver failed: {r.stderr}") @@ -175,7 +175,7 @@ def _find(driver, model_id, options, preferred=None): r = subprocess.run( [NODE, driver, str(UI_JS_PATH), json.dumps({"modelId": model_id, "options": options, "preferredProvider": preferred})], - capture_output=True, text=True, timeout=10, + capture_output=True, text=True, timeout=30, ) if r.returncode != 0: raise RuntimeError(f"node driver failed: {r.stderr}") @@ -185,7 +185,7 @@ def _find(driver, model_id, options, preferred=None): def _best(driver, query, options): r = subprocess.run( [NODE, driver, str(COMMANDS_JS_PATH), json.dumps({"query": query, "options": options})], - capture_output=True, text=True, timeout=10, + capture_output=True, text=True, timeout=30, ) if r.returncode != 0: raise RuntimeError(f"node driver failed: {r.stderr}") diff --git a/tests/test_issue3429_uri_scheme_model_ids.py b/tests/test_issue3429_uri_scheme_model_ids.py index 22e6f750..85a1ebdd 100644 --- a/tests/test_issue3429_uri_scheme_model_ids.py +++ b/tests/test_issue3429_uri_scheme_model_ids.py @@ -92,7 +92,7 @@ def norm_driver(tmp_path_factory): def _labels(driver_path, ids): result = subprocess.run( [NODE, driver_path, str(UI_JS_PATH), json.dumps(ids)], - capture_output=True, text=True, timeout=10, + capture_output=True, text=True, timeout=30, ) if result.returncode != 0: raise RuntimeError(f"node driver failed: {result.stderr}") @@ -102,7 +102,7 @@ def _labels(driver_path, ids): def _norm_keys(driver_path, ids): result = subprocess.run( [NODE, driver_path, str(UI_JS_PATH), json.dumps(ids)], - capture_output=True, text=True, timeout=10, + capture_output=True, text=True, timeout=30, ) if result.returncode != 0: raise RuntimeError(f"node driver failed: {result.stderr}") diff --git a/tests/test_issue3460_cron_session_unread.py b/tests/test_issue3460_cron_session_unread.py index f3cbbf3a..47bd99fa 100644 --- a/tests/test_issue3460_cron_session_unread.py +++ b/tests/test_issue3460_cron_session_unread.py @@ -51,7 +51,7 @@ def _run_node(script: str) -> dict: check=True, capture_output=True, text=True, - timeout=10, + timeout=30, ) return json.loads(result.stdout) diff --git a/tests/test_issue3691_model_picker_show_all.py b/tests/test_issue3691_model_picker_show_all.py index 54e9c43c..7512282a 100644 --- a/tests/test_issue3691_model_picker_show_all.py +++ b/tests/test_issue3691_model_picker_show_all.py @@ -1394,7 +1394,7 @@ def _run_dropdown_driver(driver_path: str, payload: dict | None = None) -> dict: [NODE, driver_path, str(REPO / "static" / "ui.js"), json.dumps(payload)], capture_output=True, text=True, - timeout=10, + timeout=30, ) if result.returncode != 0: raise RuntimeError(f"node driver failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}") @@ -1562,7 +1562,7 @@ def test_runtime_inplace_expand_then_search_clear_preserves_expanded_group(_driv [NODE, _driver_paths["inplace"], str(REPO / "static" / "ui.js"), json.dumps(payload)], capture_output=True, text=True, - timeout=10, + timeout=30, ) if result.returncode != 0: raise RuntimeError(f"node inplace driver failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}") @@ -1621,7 +1621,7 @@ def test_runtime_inplace_endpoint_error_group_renders_open(_driver_paths): [NODE, _driver_paths["endpoint_error"], str(REPO / "static" / "ui.js"), json.dumps(payload)], capture_output=True, text=True, - timeout=10, + timeout=30, ) if result.returncode != 0: raise RuntimeError(f"node endpoint_error driver failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}") @@ -1663,7 +1663,7 @@ def test_runtime_inplace_expand_with_preexisting_options_reveals_them(_driver_pa [NODE, _driver_paths["preexisting"], str(REPO / "static" / "ui.js"), json.dumps(payload)], capture_output=True, text=True, - timeout=10, + timeout=30, ) if result.returncode != 0: raise RuntimeError(f"node preexisting driver failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}") diff --git a/tests/test_issue3959_model_suffix_dedup.py b/tests/test_issue3959_model_suffix_dedup.py index 04c85ba6..0091a979 100644 --- a/tests/test_issue3959_model_suffix_dedup.py +++ b/tests/test_issue3959_model_suffix_dedup.py @@ -138,7 +138,7 @@ def test_backend_frontend_parity_complex_aggregator_proxy(): tmp = f.name try: result = subprocess.run( - ["node", tmp], capture_output=True, text=True, timeout=10 + ["node", tmp], capture_output=True, text=True, timeout=30 ) assert result.returncode == 0, f"JS execution failed: {result.stderr}" js_pairs = json.loads(result.stdout.strip()) diff --git a/tests/test_issue500_message_list_virtualization.py b/tests/test_issue500_message_list_virtualization.py index e3c1d217..7b062d16 100644 --- a/tests/test_issue500_message_list_virtualization.py +++ b/tests/test_issue500_message_list_virtualization.py @@ -26,7 +26,7 @@ def _run_node(source: str) -> str: cwd=str(REPO_ROOT), capture_output=True, text=True, - timeout=10, + timeout=30, ) finally: script_path.unlink(missing_ok=True) diff --git a/tests/test_issue500_session_list_virtualization.py b/tests/test_issue500_session_list_virtualization.py index 6d663e04..be05b592 100644 --- a/tests/test_issue500_session_list_virtualization.py +++ b/tests/test_issue500_session_list_virtualization.py @@ -26,7 +26,7 @@ def _run_node(source: str) -> str: cwd=str(REPO_ROOT), capture_output=True, text=True, - timeout=10, + timeout=30, ) finally: script_path.unlink(missing_ok=True) diff --git a/tests/test_model_default_boot_precedence.py b/tests/test_model_default_boot_precedence.py index beec74e8..a94eed91 100644 --- a/tests/test_model_default_boot_precedence.py +++ b/tests/test_model_default_boot_precedence.py @@ -307,7 +307,7 @@ def _run_populate_driver( [NODE, driver_path, str(REPO / "static" / "ui.js"), json.dumps(payload)], capture_output=True, text=True, - timeout=10, + timeout=30, ) if result.returncode != 0: raise RuntimeError(f"node driver failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}") diff --git a/tests/test_reasoning_chip_js_behaviour.py b/tests/test_reasoning_chip_js_behaviour.py index aeb56881..f7fb3b26 100644 --- a/tests/test_reasoning_chip_js_behaviour.py +++ b/tests/test_reasoning_chip_js_behaviour.py @@ -108,7 +108,7 @@ def _apply(driver_path, value): import json as _json result = subprocess.run( [NODE, driver_path, str(UI_JS_PATH), _json.dumps(value)], - capture_output=True, text=True, timeout=10, + capture_output=True, text=True, timeout=30, ) if result.returncode != 0: raise RuntimeError(f"node driver failed: {result.stderr}") diff --git a/tests/test_renderer_js_behaviour.py b/tests/test_renderer_js_behaviour.py index eacdddd1..2a84905a 100644 --- a/tests/test_renderer_js_behaviour.py +++ b/tests/test_renderer_js_behaviour.py @@ -80,7 +80,7 @@ def _render(driver_path, markdown: str) -> str: input=markdown, capture_output=True, text=True, - timeout=10, + timeout=30, ) if result.returncode != 0: raise RuntimeError(f"node driver failed: {result.stderr}") diff --git a/tests/test_session_copy_link_action.py b/tests/test_session_copy_link_action.py index b06f248c..5d12d2da 100644 --- a/tests/test_session_copy_link_action.py +++ b/tests/test_session_copy_link_action.py @@ -53,7 +53,7 @@ def _run_session_search_helper(cases): [NODE, "-e", driver, json.dumps(cases)], capture_output=True, text=True, - timeout=10, + timeout=30, ) assert result.returncode == 0, result.stderr return json.loads(result.stdout) diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index 986c4268..f1a7712d 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -24,7 +24,7 @@ def _run_node(source: str) -> str: capture_output=True, encoding="utf-8", text=True, - timeout=10, + timeout=30, ) if result.returncode != 0: raise RuntimeError(result.stderr) diff --git a/tests/test_tool_card_preview_summary.py b/tests/test_tool_card_preview_summary.py index b50b7664..ca9010b8 100644 --- a/tests/test_tool_card_preview_summary.py +++ b/tests/test_tool_card_preview_summary.py @@ -62,7 +62,7 @@ def _preview(driver_path: str, tc: dict, display_snippet: str = "") -> str: input=json.dumps({"tc": tc, "displaySnippet": display_snippet}), capture_output=True, text=True, - timeout=10, + timeout=30, ) if result.returncode != 0: raise RuntimeError(result.stderr)