diff --git a/backend/src/utilities/bridge_link_state_manager.py b/backend/src/utilities/bridge_link_state_manager.py index 98cc101..d4bca5b 100644 --- a/backend/src/utilities/bridge_link_state_manager.py +++ b/backend/src/utilities/bridge_link_state_manager.py @@ -199,12 +199,17 @@ class BridgeLinkStateWatcher: self._evaluate_bridge_state(reason="recovery_deadline") def _evaluate_bridge_state(self, reason: str, source_ifname: Optional[str] = None) -> None: + with self._lock: + previous_states = dict(self._member_states) + members = [iface for iface in get_bridge_ports_once(self.bridge_name) if check_interface_exists(iface)] - states = {iface: self._read_member_state(iface) for iface in members} + states = { + iface: self._read_member_state(iface, previous_states.get(iface)) + for iface in members + } now = time.time() with self._lock: - previous_states = dict(self._member_states) self._last_event_ts = now self._last_error = None self._member_states = states @@ -389,7 +394,10 @@ class BridgeLinkStateWatcher: if current is None or previous is None: return False - return current.mtu != previous.mtu or current.ethernet_profile != previous.ethernet_profile + return current.mtu != previous.mtu or self._profiles_differ( + current.ethernet_profile, + previous.ethernet_profile, + ) def _state_has_usable_config(self, state: MemberLinkState) -> bool: """Return whether this snapshot contains configuration that can be mirrored.""" @@ -469,26 +477,126 @@ class BridgeLinkStateWatcher: except OSError: return - def _read_member_state(self, ifname: str) -> MemberLinkState: + def _read_member_state( + self, + ifname: str, + previous_state: Optional[MemberLinkState] = None, + ) -> MemberLinkState: + admin_up = read_interface_admin_up(ifname) + carrier_up = read_interface_carrier(ifname) + operstate = read_interface_operstate(ifname) + link_ready = self._link_looks_ready(admin_up, carrier_up, operstate) + return MemberLinkState( ifname=ifname, - admin_up=read_interface_admin_up(ifname), - carrier_up=read_interface_carrier(ifname), - operstate=read_interface_operstate(ifname), + admin_up=admin_up, + carrier_up=carrier_up, + operstate=operstate, mtu=read_interface_mtu(ifname), - ethernet_profile=self._read_ethernet_profile(ifname), + ethernet_profile=self._read_ethernet_profile( + ifname, + previous_state=previous_state, + link_ready=link_ready, + ), ) - def _read_ethernet_profile(self, ifname: str) -> Optional[EthernetProfile]: + def _read_ethernet_profile( + self, + ifname: str, + previous_state: Optional[MemberLinkState] = None, + link_ready: Optional[bool] = None, + ) -> Optional[EthernetProfile]: """Read ethtool speed/duplex/autoneg for one interface if supported.""" profile = read_interface_ethernet_profile(ifname) if profile is None: - return None + current_profile = None + else: + current_profile = EthernetProfile( + speed_mbps=profile.get("speed_mbps"), + duplex=profile.get("duplex"), + autoneg=profile.get("autoneg"), + ) + + if self._should_keep_previous_profile(ifname, current_profile, previous_state, link_ready): + return previous_state.ethernet_profile if previous_state is not None else None + + previous_profile = previous_state.ethernet_profile if previous_state is not None else None + return self._materialize_profile(current_profile, previous_profile) + + def _should_keep_previous_profile( + self, + ifname: str, + current_profile: Optional[EthernetProfile], + previous_state: Optional[MemberLinkState], + link_ready: Optional[bool], + ) -> bool: + """Keep the previous non-null profile during transient renegotiation windows.""" + if previous_state is None or previous_state.ethernet_profile is None: + return False + if current_profile is not None and self._partial_profile_is_usable(current_profile): + return False + if link_ready is True: + return False + + now = time.time() + with self._lock: + settling = self._settle_deadlines.get(ifname, 0.0) > now + managed = self._managed_event_deadlines.get(ifname, 0.0) > now + + return settling or managed or link_ready is False + + def _link_looks_ready( + self, + admin_up: Optional[bool], + carrier_up: Optional[bool], + operstate: Optional[str], + ) -> bool: + """Evaluate link health from sysfs fields before the MemberLinkState is built.""" + if admin_up is not True: + return False + if carrier_up is False: + return False + if operstate in {"down", "lowerlayerdown", "notpresent"}: + return False + return True + + def _profiles_differ( + self, + current_profile: Optional[EthernetProfile], + previous_profile: Optional[EthernetProfile], + ) -> bool: + """Ignore transient non-null to null drops when detecting config changes.""" + if current_profile is None: + return False + if previous_profile is None: + return True + return current_profile != previous_profile + + def _partial_profile_is_usable(self, profile: Optional[EthernetProfile]) -> bool: + """Return whether a profile contains enough data to drive synchronization.""" + if profile is None: + return False + if profile.autoneg is True: + return True + return profile.autoneg is False and profile.speed_mbps is not None and profile.duplex is not None + + def _materialize_profile( + self, + current_profile: Optional[EthernetProfile], + previous_profile: Optional[EthernetProfile], + ) -> Optional[EthernetProfile]: + """Fill transiently missing profile fields from the last stable snapshot.""" + if current_profile is None: + return previous_profile + if previous_profile is None: + return current_profile + if self._partial_profile_is_usable(current_profile): + return current_profile return EthernetProfile( - speed_mbps=profile.get("speed_mbps"), - duplex=profile.get("duplex"), - autoneg=profile.get("autoneg"), + speed_mbps=current_profile.speed_mbps if current_profile.speed_mbps is not None else previous_profile.speed_mbps, + duplex=current_profile.duplex if current_profile.duplex is not None else previous_profile.duplex, + autoneg=current_profile.autoneg if current_profile.autoneg is not None else previous_profile.autoneg, ) def _sync_member_configuration(self, source_ifname: str, states: dict[str, MemberLinkState]) -> None: