Fix RuntimeError from dict/set mutation during iteration (#3159)
authorDavid Bishop <teancom@users.noreply.github.com>
Tue, 17 Feb 2026 07:36:57 +0000 (23:36 -0800)
committerGitHub <noreply@github.com>
Tue, 17 Feb 2026 07:36:57 +0000 (08:36 +0100)
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------

Co-authored-by: David Bishop <git@gnuconsulting.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
music_assistant/controllers/music.py
music_assistant/controllers/players/controller.py
music_assistant/helpers/webserver.py
music_assistant/mass.py

index fb1fa54347e63bf9598f2338b8ec5687b9f32721..0727125c412c41b6ce7bc80d70b9cb9b43451fa5 100644 (file)
@@ -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():
index dc121fcecce7dab0ff2b0def463a953a5981de5a..26536a5b6097f8a3ff6ddd08a9a978536d2448b9 100644 (file)
@@ -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,
index 5097d53e03b67a46788490e8debdca369de04320..c4b22beada445197508b8490cf04a96d994e7e14 100644 (file)
@@ -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]
index d309342a8f187b3f07b883e41f7d3d6a1468f8fd..1df492eaa3c174f5ca1ba5e95f93a6c8b4efab60 100644 (file)
@@ -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: