Skip to content

Commit 9d6fcec

Browse files
Merge pull request #101 from randileeharper/fix/playback-pool-shutdown
fix(playback): drain in-flight snapshot tasks on close
2 parents 38ea2ea + 3f219a0 commit 9d6fcec

2 files changed

Lines changed: 99 additions & 4 deletions

File tree

tests/test_service.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1664,6 +1664,68 @@ def test_playback_snapshot_reuses_instance_thread_pool(service) -> None:
16641664
assert service._playback_ctrl._executor is executor_before
16651665

16661666

1667+
def test_playback_close_drains_in_flight_snapshot(service) -> None:
1668+
# close() must wait for in-flight playback_snapshot() fan-out tasks to
1669+
# finish before returning, instead of orphaning worker threads with
1670+
# shutdown(wait=False) (issue #89).
1671+
import threading as _threading
1672+
from concurrent.futures import ThreadPoolExecutor
1673+
1674+
ctrl = service._playback_ctrl
1675+
rpc = service._rpc
1676+
1677+
# Gate that keeps the first RPC call blocked until we release it.
1678+
gate = _threading.Event()
1679+
call_started = _threading.Event()
1680+
1681+
original_get = rpc.playback_get
1682+
1683+
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)
1687+
return original_get(path)
1688+
1689+
rpc.playback_get = gated_playback_get
1690+
1691+
# Start a snapshot in a background thread. It will block on the gate.
1692+
snapshot_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="test-snapshot")
1693+
snapshot_future = snapshot_executor.submit(ctrl.playback_snapshot)
1694+
1695+
assert call_started.wait(timeout=5), "gated RPC call should have started"
1696+
1697+
# Now call close() while the snapshot fan-out is in flight. It should
1698+
# not return until the in-flight tasks complete (after we release the
1699+
# gate). We verify ordering: close() blocks, then we release the gate,
1700+
# then close() returns.
1701+
close_done = _threading.Event()
1702+
1703+
def call_close():
1704+
ctrl.close()
1705+
close_done.set()
1706+
1707+
close_thread = _threading.Thread(target=call_close, name="test-close")
1708+
close_thread.start()
1709+
1710+
# close() should be blocked waiting for the in-flight future.
1711+
assert not close_done.wait(timeout=0.3), "close() should not return while tasks are in flight"
1712+
1713+
# Release the gate so the in-flight snapshot can complete.
1714+
gate.set()
1715+
1716+
# close() should now return promptly (within the drain timeout).
1717+
assert close_done.wait(timeout=6), "close() should return after in-flight tasks drain"
1718+
close_thread.join(timeout=5)
1719+
1720+
# The background snapshot should also have completed successfully.
1721+
snapshot_result = snapshot_future.result(timeout=5)
1722+
assert snapshot_result["status"] == "ok"
1723+
snapshot_executor.shutdown(wait=True)
1724+
1725+
# Restore the stub so the fixture teardown doesn't hit the gated version.
1726+
rpc.playback_get = original_get
1727+
1728+
16671729
def test_handle_text_request_includes_raw_output_when_enabled(settings, service, tmp_path) -> None:
16681730
class RawStubResolver:
16691731
def resolve(self, text: str, service) -> ResolvedAction:

vesper/playback_controller.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818

1919
from __future__ import annotations
2020

21-
from concurrent.futures import ThreadPoolExecutor
21+
import threading
22+
from concurrent.futures import Future, ThreadPoolExecutor
2223
from typing import Any, Protocol
2324

2425
from .catalog import (
@@ -69,6 +70,10 @@ class PlaybackController:
6970
# every call to this hot path. See #67.
7071
_POOL_WORKERS = 14
7172

73+
# Per-future timeout when draining in-flight snapshots in close(). Bounded
74+
# so a hung RPC can't block close() indefinitely (issue #89).
75+
_CLOSE_DRAIN_TIMEOUT_SECONDS = 5.0
76+
7277
def __init__(
7378
self,
7479
host: PlaybackHost,
@@ -84,10 +89,30 @@ def __init__(
8489
self._executor = ThreadPoolExecutor(
8590
max_workers=self._POOL_WORKERS, thread_name_prefix="playback-snapshot"
8691
)
92+
# In-flight snapshot futures, guarded by _pending_lock so close() can
93+
# drain a consistent set while playback_snapshot() submits new work.
94+
# See #89.
95+
self._pending_futures: set[Future[Any]] = set()
96+
self._pending_lock = threading.Lock()
8797

8898
def close(self) -> None:
89-
"""Release the shared snapshot thread pool. Idempotent."""
90-
self._executor.shutdown(wait=False)
99+
"""Release the shared snapshot thread pool. Idempotent.
100+
101+
Signals the pool to stop accepting work, then waits a bounded time for
102+
any in-flight playback_snapshot() fan-out to finish so worker threads
103+
are not orphaned and can't raise at interpreter shutdown (issue #89).
104+
"""
105+
# Stop accepting new submissions first. cancel_futures=True (3.9+)
106+
# drops not-yet-started tasks; started ones still run to completion.
107+
self._executor.shutdown(wait=False, cancel_futures=True)
108+
with self._pending_lock:
109+
pending = list(self._pending_futures)
110+
# Drain in-flight tasks with a short bounded timeout per future. If a
111+
# snapshot RPC hangs, we don't block close() indefinitely — the worker
112+
# is a daemon thread and won't block interpreter exit, but we give
113+
# well-behaved tasks a chance to complete.
114+
for future in pending:
115+
future.result(timeout=self._CLOSE_DRAIN_TIMEOUT_SECONDS)
91116

92117
def status(self) -> dict[str, Any]:
93118
playback = self.playback_snapshot()
@@ -152,11 +177,19 @@ def playback_snapshot(self) -> dict[str, Any]:
152177
# Fan out the 7 playback reads on the reused instance pool rather than
153178
# creating a ThreadPoolExecutor per call. Each future's result() is
154179
# awaited below, so all submissions complete before we return. See #67.
180+
# Futures are registered in _pending_futures so close() can drain them
181+
# if it runs while this fan-out is in flight (issue #89).
155182
futures = {
156183
name: self._executor.submit(self._rpc.playback_get, path)
157184
for name, path in snapshot_paths.items()
158185
}
159-
payloads = {name: future.result() for name, future in futures.items()}
186+
with self._pending_lock:
187+
self._pending_futures.update(futures.values())
188+
try:
189+
payloads = {name: future.result() for name, future in futures.items()}
190+
finally:
191+
with self._pending_lock:
192+
self._pending_futures.difference_update(futures.values())
160193
is_playing_payload = payloads["is_playing"]
161194
now_playing_payload = payloads["now_playing"]
162195
volume_payload = payloads["volume"]

0 commit comments

Comments
 (0)