[PATCH] AP MLD: Keep the new driver STA when a non-MLD STA changes links

Goran Sustek gsustek at gmail.com
Mon Oct 26 13:48:50 PDT 2026


A non-MLD STA that moves from one affiliated link of an AP MLD to
another gets a separate sta_info entry on each link, but the driver
keeps a single station entry per address on the AP MLD netdev. When the
STA gets authorized on the new link, hostapd_prune_associations()
disassociates the entry on the old link. Two seconds later, the pending
disassociation callback removes the driver station by address, which
removes the station of the new association. When the old entry is
finally freed after AP_MAX_INACTIVITY_AFTER_DISASSOC, ap_free_sta()
also clears the authorized flag of that driver station through
hostapd_set_sta_flags().

The guard added in __ap_free_sta() for non-AP MLDs requires an MLD
station entry, so it does not cover this case. Move it into
ap_sta_replaced_on_partner_link(), extend it to non-MLD STAs, and use
it also in hostapd_set_sta_flags() and hostapd_set_authorized(). A
non-MLD STA has its entry only on the link it associated to, so an
associated entry on another link replaces it. To keep the check
one-sided while both entries are still marked associated before the
prune, only an entry that is no longer associated and that did not add
the driver station for an ongoing authentication can be replaced.

This was seen with a Wi-Fi 6E phone (non-MLD) on a 5 GHz + 6 GHz
ath12k AP MLD. The phone moved between the links every 5-6 seconds, and
each move was followed 2 seconds later by
MLME-DISASSOCIATE.indication(reason 1) and the loss of the new
association, which caused the next move.

Signed-off-by: Goran Sustek <gsustek at gmail.com>
---
This applies on top of "AP: Avoid deleting replacement MLD station
during stale cleanup" from Nicholas Sielicki (patchwork 2327436), which
is not applied yet. Nicholas, your patch covers the non-AP MLD case. The
same delayed DEL_STATION hits a non-MLD STA that moves between links,
and the authorized flag gets cleared 30 seconds later. I kept your loop
and moved it into a helper so that the flag updates can use it too. I
am happy to have this folded into a v2 of your patch instead.

Tested with mac80211_hwsim on main (937ce1394) + 2327436 + this patch:
a non-MLD STA (disable_eht=1) on a two-link AP MLD (channels 1 and 6),
SAE with PMF, broadcast frames from the STA every 0.5 seconds, and the
AP's driver STA sampled every 0.5 seconds.
- One roam, 45 seconds: without this patch, with or without 2327436,
  the driver STA is gone 2 seconds after the roam. With it, the driver
  STA stays present and authorized, and its rx packet count keeps
  increasing.
- 8 roams 3 seconds apart, then 35 seconds: without this patch, the
  driver STA disappears about 2 seconds after every roam. With it, the
  driver STA stays present and authorized throughout.
- A client disconnect still removes the driver STA.
- A non-AP MLD on both links connects, stays 40 seconds, and
  disconnects normally, without hitting the new check.

To make the roam reach the other link in hwsim, I needed a local test
change: frames from an associated non-MLD STA are reported with the
link of its driver station even when they arrive on the other link's
channel, so hostapd dropped the Authentication frame to the other link
("Ignore MLME frame event for foreign address"). For the test only, I
selected the link by A1 in that case. I have not checked whether this
is a mac80211 or a mac80211_hwsim issue. On ath12k, the Authentication
frame does reach the other link, which is how the phone got into the
loop.

The one-sided check (an associated or added_unassoc entry is never
treated as replaced) and the new guards in the flag functions also
apply to the non-AP MLD case from 2327436. I could not reproduce that
scenario here (a non-AP MLD moving between the AP MLD and a standalone
BSS), so that path is covered only by review.

Not done yet: the full hwsim test suite and a test of this patch on the
ath12k AP.

 src/ap/ap_drv_ops.c | 12 +++++++
 src/ap/sta_info.c   | 77 ++++++++++++++++++++++++++++++---------------
 src/ap/sta_info.h   |  2 ++
 3 files changed, 66 insertions(+), 25 deletions(-)

diff --git a/src/ap/ap_drv_ops.c b/src/ap/ap_drv_ops.c
index dd2f5c542..c184cb6e3 100644
--- a/src/ap/ap_drv_ops.c
+++ b/src/ap/ap_drv_ops.c
@@ -323,6 +323,12 @@ int hostapd_set_authorized(struct hostapd_data *hapd,
 		return 0;
 	}
 
+#ifdef CONFIG_IEEE80211BE
+	/* The driver STA belongs to a newer association on another link */
+	if (ap_sta_replaced_on_partner_link(hapd, sta))
+		return 0;
+#endif /* CONFIG_IEEE80211BE */
+
 	if (authorized) {
 		return hostapd_sta_set_flags(hapd, sta->addr,
 					     hostapd_sta_flags_to_drv(
@@ -358,6 +364,12 @@ int hostapd_set_sta_flags(struct hostapd_data *hapd, struct sta_info *sta)
 		return 0;
 	}
 
+#ifdef CONFIG_IEEE80211BE
+	/* The driver STA belongs to a newer association on another link */
+	if (ap_sta_replaced_on_partner_link(hapd, sta))
+		return 0;
+#endif /* CONFIG_IEEE80211BE */
+
 	flags_or = total_flags & set_flags;
 	flags_and = total_flags | ~set_flags;
 	return hostapd_sta_set_flags(hapd, sta->addr, total_flags,
diff --git a/src/ap/sta_info.c b/src/ap/sta_info.c
index be2cb1cee..0df68a706 100644
--- a/src/ap/sta_info.c
+++ b/src/ap/sta_info.c
@@ -207,38 +207,65 @@ void ap_free_sta_pasn(struct hostapd_data *hapd, struct sta_info *sta)
 #endif /* CONFIG_PASN */
 
 
-static int __ap_free_sta(struct hostapd_data *hapd, struct sta_info *sta)
-{
 #ifdef CONFIG_IEEE80211BE
+/**
+ * ap_sta_replaced_on_partner_link - Check for a newer association on a link
+ * @hapd: BSS of the station entry
+ * @sta: Old station entry
+ * Returns: true if another link of the AP MLD has an associated entry that
+ * replaces @sta
+ *
+ * A new association on another link can use the same address while the old
+ * station's disassociation callback or inactivity timer is still pending: the
+ * MLD address of a non-AP MLD, or the address of a non-MLD STA that moved from
+ * one affiliated link to another. Both associations use the same MLD netdev,
+ * so a driver operation by address for the old entry would apply to the new
+ * one. A non-MLD STA has its entry only on the link it associated to, so any
+ * associated entry on another link is a replacement. Only an entry that is no
+ * longer associated can have been replaced; this keeps the check one-sided
+ * while both entries are still marked associated before the old one is
+ * pruned. An entry that added the driver STA for an ongoing authentication
+ * (added_unassoc) owns that driver STA.
+ */
+bool ap_sta_replaced_on_partner_link(struct hostapd_data *hapd,
+				     struct sta_info *sta)
+{
 	struct hostapd_data *lhapd;
 
-	/*
-	 * A new association on another link can use the same MLD address while
-	 * the old station's disassociation callback is still pending. Removing
-	 * the old station by address would also remove the new one from the
-	 * driver, since both associations use the same MLD netdev.
-	 */
-	if (hapd->mld && ap_sta_is_mld(hapd, sta)) {
-		for_each_mld_link(lhapd, hapd) {
-			struct sta_info *lsta;
+	if (!hapd->mld || (sta->flags & WLAN_STA_ASSOC) || sta->added_unassoc)
+		return false;
 
-			if (lhapd == hapd)
-				continue;
-			lsta = ap_get_sta(lhapd, sta->addr);
-			if (lsta &&
-			    lsta->mld_assoc_link_id != sta->mld_assoc_link_id &&
-			    lsta->mld_assoc_link_id == lhapd->mld_link_id &&
-			    (lsta->flags & WLAN_STA_ASSOC)) {
-				wpa_printf(MSG_DEBUG,
-					   "%s: Keep replacement MLD STA " MACSTR
-					   " on link %u",
-					   __func__, MAC2STR(sta->addr),
-					   lhapd->mld_link_id);
-				return 0;
-			}
+	for_each_mld_link(lhapd, hapd) {
+		struct sta_info *lsta;
+
+		if (lhapd == hapd)
+			continue;
+		lsta = ap_get_sta(lhapd, sta->addr);
+		if (lsta &&
+		    (!ap_sta_is_mld(hapd, sta) ||
+		     (lsta->mld_assoc_link_id != sta->mld_assoc_link_id &&
+		      lsta->mld_assoc_link_id == lhapd->mld_link_id)) &&
+		    (lsta->flags & WLAN_STA_ASSOC)) {
+			wpa_printf(MSG_DEBUG,
+				   "%s: Keep replacement STA " MACSTR
+				   " on link %u",
+				   hapd->conf->iface, MAC2STR(sta->addr),
+				   lhapd->mld_link_id);
+			return true;
 		}
 	}
 
+	return false;
+}
+#endif /* CONFIG_IEEE80211BE */
+
+
+static int __ap_free_sta(struct hostapd_data *hapd, struct sta_info *sta)
+{
+#ifdef CONFIG_IEEE80211BE
+	if (ap_sta_replaced_on_partner_link(hapd, sta))
+		return 0;
+
 	if (hostapd_sta_is_link_sta(hapd, sta)) {
 		return hostapd_drv_link_sta_remove(hapd, sta->addr);
 	}
diff --git a/src/ap/sta_info.h b/src/ap/sta_info.h
index c05c8a77e..fd81740a1 100644
--- a/src/ap/sta_info.h
+++ b/src/ap/sta_info.h
@@ -462,6 +462,8 @@ void ap_sta_delayed_1x_auth_fail_disconnect(struct hostapd_data *hapd,
 int ap_sta_pending_delayed_1x_auth_fail_disconnect(struct hostapd_data *hapd,
 						   struct sta_info *sta);
 int ap_sta_re_add(struct hostapd_data *hapd, struct sta_info *sta);
+bool ap_sta_replaced_on_partner_link(struct hostapd_data *hapd,
+				     struct sta_info *sta);
 
 void ap_free_sta_pasn(struct hostapd_data *hapd, struct sta_info *sta);
 
-- 
2.56.0




More information about the Hostap mailing list