From 0c54342eb77c991621b6e14d1c617a3886aa55c4 Mon Sep 17 00:00:00 2001 From: David Bishop Date: Mon, 16 Feb 2026 23:36:57 -0800 Subject: [PATCH] Fix RuntimeError from dict/set mutation during iteration (#3159) * Fix RuntimeError from dict/set mutation during iteration Several collections are iterated while concurrent callbacks can modify them, causing "Set/dictionary changed size during iteration" RuntimeErrors: - mass.py: _subscribers set iterated in signal_event() while subscribe/unsubscribe callbacks modify it - mass.py: _providers dict iterated in get_provider() and _on_mdns_service_state_change() while providers load/unload - player_controller.py: _players dict iterated in get_players(), get_player_by_name(), and update_player_control() while players register/unregister - music.py: in_progress_syncs list iterated while done-callbacks call .remove() on it Snapshot via list() before iterating in all affected locations. Co-Authored-By: Claude Opus 4.6 * Fix additional dict mutation during iteration in mass.py Snapshot via list() in three more locations where self._providers or self._tracked_tasks are iterated while concurrent callbacks can modify them: get_providers(), get_providers_by_domain(), and stop(). Co-Authored-By: Claude Opus 4.6 * Fix dict mutation during iteration in webserver dynamic routes Snapshot self._dynamic_routes.items() via list() in _handle_catch_all() to prevent RuntimeError when routes are registered or unregistered by providers while a request is being matched against prefix routes. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: David Bishop Co-authored-by: Claude Opus 4.6 --- music_assistant/controllers/music.py | 6 +++--- music_assistant/controllers/players/controller.py | 6 +++--- music_assistant/helpers/webserver.py | 2 +- music_assistant/mass.py | 12 ++++++------ 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/music_assistant/controllers/music.py b/music_assistant/controllers/music.py index fb1fa543..0727125c 100644 --- a/music_assistant/controllers/music.py +++ b/music_assistant/controllers/music.py @@ -202,7 +202,7 @@ class MusicController(CoreController): async def on_provider_unload(self, provider: MusicProvider) -> None: """Handle logic when a provider is (about to get) unloaded.""" # make sure to stop any running sync tasks first - for sync_task in self.in_progress_syncs: + for sync_task in list(self.in_progress_syncs): if sync_task.provider_instance == provider.instance_id: if sync_task.task: sync_task.task.cancel() @@ -1611,7 +1611,7 @@ class MusicController(CoreController): key = f"sync_{provider_instance_id}_{media_type.value}" self.mass.cancel_timer(key) # cancel any running sync tasks - for sync_task in self.in_progress_syncs: + for sync_task in list(self.in_progress_syncs): if sync_task.provider_instance == provider_instance_id: sync_task.task.cancel() @@ -1813,7 +1813,7 @@ class MusicController(CoreController): def _start_provider_sync(self, provider: MusicProvider, media_type: MediaType) -> None: """Start sync task on provider and track progress.""" # check if we're not already running a sync task for this provider/mediatype - for sync_task in self.in_progress_syncs: + for sync_task in list(self.in_progress_syncs): if sync_task.provider_instance != provider.instance_id: continue if sync_task.task.done(): diff --git a/music_assistant/controllers/players/controller.py b/music_assistant/controllers/players/controller.py index dc121fce..26536a5b 100644 --- a/music_assistant/controllers/players/controller.py +++ b/music_assistant/controllers/players/controller.py @@ -191,7 +191,7 @@ class PlayerController(ProtocolLinkingMixin, CoreController): current_sendspin_player = get_sendspin_player_id() return [ player - for player in self._players.values() + for player in list(self._players.values()) if (player.state.available or return_unavailable) and (player.state.enabled or return_disabled) and (provider_filter is None or player.provider.instance_id == provider_filter) @@ -303,7 +303,7 @@ class PlayerController(ProtocolLinkingMixin, CoreController): name_normalized = name.strip().lower() matches: list[Player] = [] - for player in self._players.values(): + for player in list(self._players.values()): if player.state.name.strip().lower() == name_normalized: matches.append(player) @@ -1653,7 +1653,7 @@ class PlayerController(ProtocolLinkingMixin, CoreController): if self.mass.closing: return # update all players that are using this control - for player in self._players.values(): + for player in list(self._players.values()): if control_id in ( player.state.power_control, player.state.volume_control, diff --git a/music_assistant/helpers/webserver.py b/music_assistant/helpers/webserver.py index 5097d53e..c4b22bea 100644 --- a/music_assistant/helpers/webserver.py +++ b/music_assistant/helpers/webserver.py @@ -192,7 +192,7 @@ class Webserver: return await handler(request) # Try prefix match (for routes registered with /*) if self._dynamic_routes is not None: - for route_key, handler in self._dynamic_routes.items(): + for route_key, handler in list(self._dynamic_routes.items()): method, path = route_key.split(".", 1) if method in (request.method, "*") and path.endswith("/*"): prefix = path[:-2] diff --git a/music_assistant/mass.py b/music_assistant/mass.py index d309342a..1df492ea 100644 --- a/music_assistant/mass.py +++ b/music_assistant/mass.py @@ -207,7 +207,7 @@ class MusicAssistant: self.signal_event(EventType.SHUTDOWN) self.closing = True # cancel all running tasks - for task in self._tracked_tasks.values(): + for task in list(self._tracked_tasks.values()): task.cancel() # cleanup all providers await asyncio.gather( @@ -306,7 +306,7 @@ class MusicAssistant: ) return [ x - for x in self._providers.values() + for x in list(self._providers.values()) if (provider_type is None or provider_type == x.type) # apply user provider filter and ( @@ -370,7 +370,7 @@ class MusicAssistant: return None provider_instance_or_domain = prov.domain # fallback to match on domain - for prov in self._providers.values(): + for prov in list(self._providers.values()): if prov.domain != provider_instance_or_domain: continue if return_unavailable or prov.available: @@ -390,7 +390,7 @@ class MusicAssistant: """ return [ prov - for prov in self._providers.values() + for prov in list(self._providers.values()) if (provider_type is None or provider_type == prov.type) and prov.domain == domain and (return_unavailable or prov.available) @@ -413,7 +413,7 @@ class MusicAssistant: LOGGER.getChild("event").log(VERBOSE_LOG_LEVEL, "%s %s", event.value, object_id or "") event_obj = MassEvent(event=event, object_id=object_id, data=data) - for cb_func, event_filter, id_filter in self._subscribers: + for cb_func, event_filter, id_filter in list(self._subscribers): if not (event_filter is None or event in event_filter): continue if not (id_filter is None or object_id in id_filter): @@ -1041,7 +1041,7 @@ class MusicAssistant: service_type, state_change, ) - for prov in self._providers.values(): + for prov in list(self._providers.values()): if not prov.manifest.mdns_discovery: continue if not prov.available: -- 2.34.1