Fix race condition in player register flow wrt config
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Mon, 23 Feb 2026 11:42:19 +0000 (12:42 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Mon, 23 Feb 2026 11:42:19 +0000 (12:42 +0100)
music_assistant/controllers/config.py
music_assistant/controllers/players/controller.py
music_assistant/models/player.py
music_assistant/providers/_demo_player_provider/player.py
music_assistant/providers/chromecast/player.py
music_assistant/providers/sendspin/player.py
music_assistant/providers/squeezelite/player.py
music_assistant/providers/sync_group/player.py
music_assistant/providers/universal_group/player.py

index 016eae6c4b4a977347bd79b1e6fdfcd5c12c963c..f5528ffeab7c2b2462e00b173b823593f476dae5 100644 (file)
@@ -1850,11 +1850,6 @@ class ConfigController:
         all_entries: list[ConfigEntry] = []
         output_protocols = player.output_protocols
 
-        if not player.available:
-            # if player is not available, we cannot reliably determine the available protocols
-            # so we return no options to avoid confusion
-            return all_entries
-
         # Build options from available output protocols, sorted by priority
         options: list[ConfigValueOption] = []
         default_value: str | None = None
index f0a5e42e5f01ce9b6aec063c48866ccb476dfc98..3f4f1099ef76429c7ddbdb020889282fe61b480b 100644 (file)
@@ -195,6 +195,7 @@ class PlayerController(ProtocolLinkingMixin, CoreController):
             for player in list(self._players.values())
             if (player.state.available or return_unavailable)
             and (player.state.enabled or return_disabled)
+            and player.initialized.is_set()
             and (provider_filter is None or player.provider.instance_id == provider_filter)
             and (
                 not user_filter
@@ -1240,13 +1241,21 @@ class PlayerController(ProtocolLinkingMixin, CoreController):
                 player.extra_data[ATTR_FAKE_POWER] = cached_value
 
             # finally actually register it
-            self._players[player_id] = player
-            # update state without signaling event first (ensure all attributes are set)
-            player.update_state(signal_event=False)
 
+            # Despite the fact that the player is not fully ready yet
+            # (config not loaded, protocol links not evaluated),
+            # we already add it to the _players dict here because we
+            # want to make sure the player is available in the controller
+            # during the rest of the registration process
+            # (such as when fetching config or evaluating protocol links).
+            # We use the 'initialized' attribute to indicate that the player
+            # is still in the process of being registered so we can filter it out where needed.
+            self._players[player_id] = player
             # ensure we fetch and set the latest/full config for the player
             player_config = await self.mass.config.get_player_config(player_id)
             player.set_config(player_config)
+            # update state without signaling event first (ensures all attributes are set)
+            player.update_state(signal_event=False)
             # call hook after the player is registered and config is set
             await player.on_config_updated()
 
@@ -1255,6 +1264,8 @@ class PlayerController(ProtocolLinkingMixin, CoreController):
             await self._enrich_player_identifiers(player)
             self._evaluate_protocol_links(player)
 
+            # now we're ready to signal the player is added and available
+            player.set_initialized()
             self.logger.info(
                 "Player (type %s) registered: %s/%s",
                 player.state.type.value,
@@ -1262,26 +1273,17 @@ class PlayerController(ProtocolLinkingMixin, CoreController):
                 player.state.name,
             )
             # signal event that a player was added
-
             if player.state.type != PlayerType.PROTOCOL:
                 self.mass.signal_event(
                     EventType.PLAYER_ADDED, object_id=player.player_id, data=player
                 )
-
-            # register playerqueue for this player
-            # Skip if this is a protocol player pending evaluation (queue created when promoted)
-            if (
-                player.state.type != PlayerType.PROTOCOL
-                and player.player_id not in self._pending_protocol_evaluations
-            ):
+            # register playerqueue for this player (if not a protocol player)
+            if player.state.type != PlayerType.PROTOCOL:
                 await self.mass.player_queues.on_player_register(player)
 
-        # always call update to fix special attributes like display name, group volume etc.
-        player.update_state()
-
         # Schedule debounced update of all players since can_group_with values may change
         # when a new player is added (provider IDs expand to include the new player)
-        self._schedule_update_all_players()
+        self._schedule_update_all_players(5)
 
     async def register_or_update(self, player: Player) -> None:
         """Register a new player on the controller or update existing one."""
index 65c2f836d68c565b723e35ca57cdf075576fe5d7..9eb6e9e5f97f63916980b758e0c028941dcb942d 100644 (file)
@@ -12,6 +12,7 @@ The final active source can be retrieved by using the 'state' property.
 
 from __future__ import annotations
 
+import asyncio
 import time
 from abc import ABC
 from collections.abc import Callable
@@ -129,6 +130,7 @@ class Player(ABC):
         self._extra_attributes: dict[str, Any] = {}
         self._on_unload_callbacks: list[Callable[[], None]] = []
         self.__active_mass_source: str | None = None
+        self.__initialized = asyncio.Event()
         # The PlayerState is the (snapshotted) final state of the player
         # after applying any config overrides and other transformations,
         # such as the display name and player controls.
@@ -727,6 +729,16 @@ class Player(ABC):
         """Return if the player is enabled."""
         return self._config.enabled
 
+    @property
+    @final
+    def initialized(self) -> asyncio.Event:
+        """
+        Return if the player is initialized.
+
+        Used by player controller to indicate initial registration completed.
+        """
+        return self.__initialized
+
     @property
     def corrected_elapsed_time(self) -> float | None:
         """Return the corrected/realtime elapsed time."""
@@ -1196,7 +1208,11 @@ class Player(ABC):
         """
         # TODO: validate that caller is the PlayerController ?
         self._config = config
-        self.mass.players.trigger_player_update(self.player_id)
+
+    @final
+    def set_initialized(self) -> None:
+        """Set the player as initialized."""
+        self.__initialized.set()
 
     @final
     def to_dict(self) -> dict[str, Any]:
index cb3dc2da6858acefd80b8391f654df3638199eff..372b6e53c87eb3f55eea9d16838851bc33dd5f3f 100644 (file)
@@ -32,7 +32,7 @@ class DemoPlayer(Player):
         self._set_attributes()
 
     async def on_config_updated(self) -> None:
-        """Handle logic when the player is loaded or updated."""
+        """Handle logic when the PlayerConfig is first loaded or updated."""
         # OPTIONAL
         # This method is optional and should be implemented if you need to handle
         # any initialization logic after the config was initially loaded or updated.
index 1a68f8b77497d371c6d1396097a90b8926aaf51b..d71df7cea5b206b2e03a477666a8cb5047111116 100644 (file)
@@ -326,7 +326,7 @@ class ChromecastPlayer(Player):
         ]
 
     async def on_config_updated(self) -> None:
-        """Handle config updates - resend Sendspin config if needed."""
+        """Handle config load/update - resend Sendspin config if needed."""
         if not self.sendspin_mode_enabled:
             return
 
index 02b2ab16777743229d54ecb7b1b78b6112e20dcf..8b15fedd59368ebd549c6c06e2b6d8a2ab584d85 100644 (file)
@@ -412,7 +412,7 @@ class SendspinPlayer(Player):
         self.update_state()
 
     async def on_config_updated(self) -> None:
-        """Apply preferred format when config changes."""
+        """Handle logic when the PlayerConfig is first loaded or updated."""
         await self._apply_preferred_format()
 
     async def _apply_preferred_format(self) -> None:
index bef9acb76b97f47aaa69d663748546a2553da5b2..f3b53f67dab3f8d66fb7488ce33153316dc52163 100644 (file)
@@ -109,7 +109,7 @@ class SqueezelitePlayer(Player):
         )
 
     async def on_config_updated(self) -> None:
-        """Handle logic when the player is registered or the config was updated."""
+        """Handle logic when the PlayerConfig is first loaded or updated."""
         # set presets and display
         await self._set_preset_items()
         await self._set_display()
index 0392c46ddea064bf11ad44e687d8c64b85e101ee..e14056bcf78583a3f38b5ce41c20c504a95f256b 100644 (file)
@@ -56,7 +56,7 @@ class SyncGroupPlayer(Player):
         return None
 
     async def on_config_updated(self) -> None:
-        """Handle logic when the player is loaded or updated."""
+        """Handle logic when the PlayerConfig is first loaded or updated."""
         # Config is only available after the player was registered
         self._cache.clear()  # clear to prevent loading old is_dynamic
         default_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
index 88b5bafa79cadba0b39a7790eaf4539ef1086f17..5d794a57b18820f9c2f1bd1fa00d1b260b8994ed 100644 (file)
@@ -111,7 +111,7 @@ class UniversalGroupPlayer(Player):
         }
 
     async def on_config_updated(self) -> None:
-        """Handle logic when the player is loaded or updated."""
+        """Handle logic when the PlayerConfig is first loaded or updated."""
         static_members = cast("list[str]", self.config.get_value(CONF_GROUP_MEMBERS, []))
         self._attr_static_group_members = static_members.copy()
         if not self.powered: