@@ -1674,18 +1674,25 @@ def test_playback_close_drains_in_flight_snapshot(service) -> None:
16741674 ctrl = service ._playback_ctrl
16751675 rpc = service ._rpc
16761676
1677- # Gate that keeps the first RPC call blocked until we release it.
1677+ # Gate that blocks ALL RPC calls until released. We wait for all 7
1678+ # snapshot reads to start before signalling, then block every call on the
1679+ # gate. This ensures all 7 futures are submitted and in-flight when close()
1680+ # runs, regardless of pool scheduling order.
16781681 gate = _threading .Event ()
1682+ call_count = _threading .Lock ()
16791683 call_started = _threading .Event ()
16801684
16811685 original_get = rpc .playback_get
16821686
16831687 def gated_playback_get (path : str ):
1684- if path == "/is-playing" and not gate .is_set ():
1685- call_started .set ()
1686- gate .wait (timeout = 10 )
1688+ with call_count :
1689+ rpc .playback_get_calls .append (path )
1690+ if len (rpc .playback_get_calls ) >= 7 and not call_started .is_set ():
1691+ call_started .set ()
1692+ gate .wait (timeout = 10 )
16871693 return original_get (path )
16881694
1695+ rpc .playback_get_calls .clear ()
16891696 rpc .playback_get = gated_playback_get
16901697
16911698 # Start a snapshot in a background thread. It will block on the gate.
@@ -1972,6 +1979,7 @@ def test_paused_session_runtime_survives_restart(settings, service, tmp_path) ->
19721979 preference_store = PreferenceStore (database_path ),
19731980 resolver = first ._resolver ,
19741981 )
1982+ restarted .reconcile_session_runtime ()
19751983
19761984 session = restarted ._preferences .get_active_session ()
19771985 assert session is not None
@@ -2021,6 +2029,7 @@ def test_reconcile_preserves_current_playing_queue_item(settings, service, tmp_p
20212029 preference_store = PreferenceStore (database_path ),
20222030 resolver = first ._resolver ,
20232031 )
2032+ restarted .reconcile_session_runtime ()
20242033
20252034 queue = restarted .session_queue (include_history = True )
20262035 assert queue ["items" ][0 ]["state" ] == "playing"
@@ -2071,6 +2080,7 @@ def test_active_stopped_session_remains_eligible_after_restart(settings, service
20712080 preference_store = PreferenceStore (database_path ),
20722081 resolver = first ._resolver ,
20732082 )
2083+ restarted .reconcile_session_runtime ()
20742084
20752085 session = restarted ._preferences .get_active_session ()
20762086 assert session is not None
@@ -2121,6 +2131,7 @@ def test_explicit_play_advances_stopped_active_session_after_restart(settings, s
21212131 preference_store = PreferenceStore (database_path ),
21222132 resolver = first ._resolver ,
21232133 )
2134+ restarted .reconcile_session_runtime ()
21242135
21252136 session = restarted ._preferences .get_active_session ()
21262137 assert session is not None
@@ -2164,10 +2175,68 @@ def test_reconcile_without_active_session_has_no_runtime(settings, service, tmp_
21642175 resolver = service ._resolver .__class__ (),
21652176 )
21662177
2178+ restarted .reconcile_session_runtime ()
21672179 assert restarted ._preferences .get_active_session () is None
21682180 assert restarted ._session_runtime == {}
21692181
21702182
2183+ def test_construction_has_no_storage_or_rpc_side_effects (settings , service , tmp_path ) -> None :
2184+ # CiderAgentService.__init__ must be cheap and deterministic: it should
2185+ # not hit SQLite for active-session lookup or call Cider RPC via
2186+ # playback_snapshot() during construction. Previously __init__ called
2187+ # reconcile_session_runtime() unconditionally (issue #86).
2188+ from vesper .storage import PreferenceStore
2189+
2190+ database_path = tmp_path / "side-effects.db"
2191+ rpc = service ._rpc .__class__ ()
2192+ # Pre-seed an active session so reconcile would have side effects if it
2193+ # were still called in __init__.
2194+ store = PreferenceStore (database_path )
2195+ store .start_session (request_text = "play upbeat music" )
2196+
2197+ rpc .playback_get_calls .clear ()
2198+
2199+ svc = CiderAgentService (
2200+ Settings (
2201+ http_host = settings .http_host ,
2202+ http_port = settings .http_port ,
2203+ public_base_url = settings .public_base_url ,
2204+ cider_base_url = settings .cider_base_url ,
2205+ cider_api_token = settings .cider_api_token ,
2206+ default_search_source = settings .default_search_source ,
2207+ resolver_backend = settings .resolver_backend ,
2208+ resolver_base_url = settings .resolver_base_url ,
2209+ resolver_model = settings .resolver_model ,
2210+ resolver_api_key = settings .resolver_api_key ,
2211+ resolver_include_reasoning = settings .resolver_include_reasoning ,
2212+ resolver_include_raw_output = settings .resolver_include_raw_output ,
2213+ response_detail = settings .response_detail ,
2214+ session_recent_tracks_limit = settings .session_recent_tracks_limit ,
2215+ global_recent_tracks_limit = settings .global_recent_tracks_limit ,
2216+ request_timeout_seconds = settings .request_timeout_seconds ,
2217+ verify_tls = settings .verify_tls ,
2218+ log_level = settings .log_level ,
2219+ database_path = database_path ,
2220+ config_path = settings .config_path ,
2221+ ),
2222+ rpc_client = rpc ,
2223+ preference_store = PreferenceStore (database_path ),
2224+ resolver = service ._resolver .__class__ (),
2225+ )
2226+ try :
2227+ # No playback RPC calls should have been made during construction.
2228+ assert rpc .playback_get_calls == []
2229+ # Session runtime should be empty until reconcile is called explicitly.
2230+ session = svc ._preferences .get_active_session ()
2231+ assert session is not None
2232+ assert svc ._session_runtime == {}
2233+ # Reconcile restores the runtime.
2234+ svc .reconcile_session_runtime ()
2235+ assert session ["id" ] in svc ._session_runtime
2236+ finally :
2237+ svc ._playback_ctrl .close ()
2238+
2239+
21712240def test_session_events_distinguish_rejection_steering_skip_and_auto_advance (service ) -> None :
21722241 service .play_session ("play upbeat music" )
21732242 session = service ._preferences .get_active_session ()
0 commit comments