1818
1919from __future__ import annotations
2020
21- from concurrent .futures import ThreadPoolExecutor
21+ import threading
22+ from concurrent .futures import Future , ThreadPoolExecutor
2223from typing import Any , Protocol
2324
2425from .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