Skip to content

Commit 0d12cd8

Browse files
fix(service): move reconcile_session_runtime out of __init__ (#86)
CiderAgentService.__init__ called self.reconcile_session_runtime(), which immediately hits SQLite for the active session and calls Cider RPC via playback_snapshot(). This meant constructing the service could fail if Cider was unreachable, and it made pure unit tests harder to write — construction had side effects that could wipe runtime state carefully set up by tests. Move the reconcile call to the explicit startup paths: - Application.worker_lifespan: the server/transport startup path, called before the background session worker starts. - service_context: the CLI one-shot path, where session state needs to be restored before the command runs. Construction is now cheap and deterministic. Tests that relied on reconcile running during construction (the restart tests) now call reconcile_session_runtime() explicitly after constructing the service. Add a regression test verifying construction makes no playback RPC calls and leaves session runtime empty until reconcile is called explicitly.
1 parent 629a65e commit 0d12cd8

4 files changed

Lines changed: 94 additions & 13 deletions

File tree

tests/test_service.py

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
21712240
def 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()

vesper/app.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ async def worker_lifespan(self) -> AsyncIterator[None]:
4949
into their lifespans instead of calling the service start/stop methods
5050
directly, so the worker is never started twice or stopped too early.
5151
"""
52+
# Reconcile session runtime from storage before starting the worker.
53+
# This was previously called in CiderAgentService.__init__ (issue #86);
54+
# it is now an explicit startup side effect so construction stays cheap
55+
# and deterministic.
56+
self._service.reconcile_session_runtime()
5257
self._service.start_background_session_worker()
5358
try:
5459
yield
@@ -68,6 +73,11 @@ def service_context() -> Iterator[CiderAgentService]:
6873
a second CLI command in the same process) gets a fresh instance.
6974
"""
7075
service = get_service()
76+
# Reconcile session runtime from storage. This was previously called in
77+
# CiderAgentService.__init__ (issue #86); it is now an explicit startup
78+
# side effect so construction stays cheap and deterministic. CLI one-shot
79+
# commands that touch session state need the runtime restored.
80+
service.reconcile_session_runtime()
7181
try:
7282
yield service
7383
finally:

vesper/playback_controller.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -179,14 +179,17 @@ def playback_snapshot(self) -> dict[str, Any]:
179179
# Fan out the 7 playback reads on the reused instance pool rather than
180180
# creating a ThreadPoolExecutor per call. Each future's result() is
181181
# awaited below, so all submissions complete before we return. See #67.
182-
# Futures are registered in _pending_futures so close() can drain them
183-
# if it runs while this fan-out is in flight (issue #89).
184-
futures = {
185-
name: self._executor.submit(self._rpc.playback_get, path)
186-
for name, path in snapshot_paths.items()
187-
}
188-
with self._pending_lock:
189-
self._pending_futures.update(futures.values())
182+
# Futures are registered in _pending_futures under the lock as they are
183+
# submitted so close() can drain them if it runs while this fan-out is
184+
# in flight (issue #89). Registering atomically with submission closes
185+
# the window where a future has started running but isn't in the
186+
# pending set yet.
187+
futures: dict[str, Future[Any]] = {}
188+
for name, path in snapshot_paths.items():
189+
future = self._executor.submit(self._rpc.playback_get, path)
190+
with self._pending_lock:
191+
self._pending_futures.add(future)
192+
futures[name] = future
190193
try:
191194
payloads = {name: future.result() for name, future in futures.items()}
192195
finally:

vesper/service.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,6 @@ def __init__(
9191
self._playback_ctrl = PlaybackController(self, rpc=self._rpc, preferences=self._preferences, settings=self._settings)
9292
self._text_request_ctrl = TextRequestController(self)
9393
self._genre_cache = self._search_ctrl._genre_cache
94-
self.reconcile_session_runtime()
9594

9695
@property
9796
def _session_runtime(self) -> dict[int, dict[str, Any]]:

0 commit comments

Comments
 (0)