diff --git a/tests/hermes_cli/test_profiles_sidebar_cache.py b/tests/hermes_cli/test_profiles_sidebar_cache.py index 5b45f739ce..4abdafdff2 100644 --- a/tests/hermes_cli/test_profiles_sidebar_cache.py +++ b/tests/hermes_cli/test_profiles_sidebar_cache.py @@ -172,20 +172,37 @@ class SidebarCacheTests(unittest.TestCase): self.assertEqual(inspect.signature(wrapped), inspect.signature(scan)) - def test_heavy_sidebar_endpoints_are_all_single_flight_wrapped(self): - # HostHighCPU on a 2-vCore box: /api/profiles/projects/tree was the - # one heavy sidebar endpoint left uncoalesced, so every Desktop poll - # re-ran the full 50-profile fan-out. Any new heavy sidebar endpoint - # must carry the wrapper too — this pins the existing two. - for endpoint in ( - profiles.get_profiles_sessions_sidebar, - profiles.get_profiles_projects_tree, - ): - self.assertTrue( - callable(getattr(endpoint, "cache_clear", None)), - f"{endpoint.__name__} lost its @_sidebar_singleflight_cache " - "wrapper — concurrent Desktop polls will re-scan every profile", - ) + def test_projects_tree_coalesces_concurrent_scans_and_returns_copies(self): + # /api/profiles/projects/tree fans out over every profile's state.db; desktop + # background sync + sidebar refreshes overlap identical requests. One scan must + # serve the whole burst, and no two callers may share the same payload object. + workers = 8 + entered = threading.Event() + release = threading.Event() + scans = 0 + scans_lock = threading.Lock() + + def fake_read(name, home, errors, fn): + nonlocal scans + with scans_lock: + scans += 1 + entered.set() + self.assertTrue(release.wait(timeout=2)) + return None + + with mock.patch.object(profiles, "_profile_targets", return_value=[("default", Path("/nonexistent"))]), \ + mock.patch.object(profiles, "_read_profile_db", side_effect=fake_read), \ + ThreadPoolExecutor(max_workers=workers) as pool: + futures = [pool.submit(profiles.get_profiles_projects_tree) for _ in range(workers)] + self.assertTrue(entered.wait(timeout=1)) + time.sleep(0.05) + release.set() + results = [future.result(timeout=2) for future in futures] + + self.assertEqual(scans, 1) + self.assertEqual(len({id(r) for r in results}), workers) + self.assertEqual(results, [results[0]] * workers) + self.assertEqual(results[0]["projects"], []) if __name__ == "__main__": diff --git a/tests/hermes_cli/test_profiles_sidebar_scope.py b/tests/hermes_cli/test_profiles_sidebar_scope.py index 068bb0e1d1..5b5f4f6aab 100644 --- a/tests/hermes_cli/test_profiles_sidebar_scope.py +++ b/tests/hermes_cli/test_profiles_sidebar_scope.py @@ -15,30 +15,13 @@ import pytest @pytest.fixture(autouse=True) -def _cold_sidebar_singleflight_caches(): - """Force a cold single-flight cache around every test in this file. - - Both sidebar endpoints are wrapped in ``_sidebar_singleflight_cache`` - (5s TTL). Each test builds a fresh tmp profile set, so a warm entry from - a previous test — same default query params, different disk state — would - answer with another test's payload. Clearing before and after keeps every - request cold without disabling the caching the endpoints rely on. - """ +def _uncached_sidebar_endpoints(monkeypatch): + """Both sidebar endpoints sit behind ``_sidebar_singleflight_cache`` (5s TTL). Every + test here builds a fresh tmp profile set under the same default query params, so a warm + entry would answer with another test's payload. TTL 0 makes each request cold.""" from hermes_cli.web_routers import profiles as profiles_routes - endpoints = ( - profiles_routes.get_profiles_sessions_sidebar, - profiles_routes.get_profiles_projects_tree, - ) - for endpoint in endpoints: - clear = getattr(endpoint, "cache_clear", None) - if clear is not None: - clear() - yield - for endpoint in endpoints: - clear = getattr(endpoint, "cache_clear", None) - if clear is not None: - clear() + monkeypatch.setattr(profiles_routes, "_SIDEBAR_CACHE_TTL_SECONDS", 0.0) @pytest.fixture