supplicant: dedupe MLO per-link BSSIDs in bgscan multi-AP heuristic

nm_supplicant_config_add_bgscan() picks "simple:30:-65:300" for any
profile that has been seen on more than one BSSID, on the
assumption that this is a multi-AP ESS where periodic roam-candidate
scanning is desirable. Each scan blocks the data path on the radio
for the full scan duration (5-7 s on rtw89 USB at MCS 13), causing
audible/visible stalls in real-time UDP applications such as Teams
and Discord every 5 minutes.

For single-AP Wi-Fi 7 MLO, this heuristic misfires: the AP
advertises one BSSID per link, so a 2-link or 3-link MLD looks
like a 2-3 AP ESS to the seen-bssids count. Verified locally on
RTL8922AU + Bazzite Linux 6.19.11 against TP-Link Deco BE63 mesh;
the connection's seen-bssids list contains only the per-link
BSSIDs of the single physical AP it has been associated with
(Link 1 and Link 2 of the MLD), but bgscan flips to the multi-AP
value with the user-visible stall pattern.

Add a conservative heuristic to detect MLO per-link BSSIDs and
exempt them from the multi-AP path. The detection requires ALL of:

  - 2 to 3 seen BSSIDs (802.11be defines tri-link 2.4 + 5 + 6 GHz
    as the maximum)
  - every BSSID has the locally-administered bit set (vendors use
    LAA-flagged virtual MACs for per-link addresses; verified on
    TP-Link Deco BE63: Link 1 f6:75:0c:74:4b:75 and Link 2
    ca:75:0c:74:4b:70 both have bit 1 set)
  - every BSSID shares octets 1-4 with the others (per-link MACs
    derived from a common base, varying only the first and last
    octets)

Real multi-AP ESSes (mesh, enterprise) typically use vendor-assigned
MACs (LAA bit unset) and unrelated address blocks per AP, so the
heuristic should not false-positive on legitimate multi-AP networks.
The connection's seen-bssids list is bounded at 30 entries (LRU) per
NM_SETTINGS_CONNECTION_SEEN_BSSIDS_MAX; the strv passed in from
build_supplicant_config() is the daemon-managed authoritative copy
loaded from /var/lib/NetworkManager/seen-bssids, not the unreliable
NMSettingWireless property.

Extend nm_supplicant_config_add_bgscan() to take the BSSID strv as
a parameter alongside the existing num_seen_bssids count (mirroring
the 2023 fix in commit 07c6f933d1 ('wifi: fix aggressively
roaming (background Wi-Fi scanning) based on seen-bssids') for
num_seen_bssids), so the heuristic can inspect actual addresses
rather than only the count.

Add a regression test test_wifi_bgscan_mlo_dedup() in
test-supplicant-config.c covering four cases: 2-link MLO BSSIDs
(LAA + shared octets 1-4) producing the long-interval bgscan;
tri-link MLO BSSIDs (3-BSSID variant matching 802.11be's tri-link
maximum) also producing the long-interval bgscan; real multi-AP
BSSIDs (UAA, unrelated blocks) producing the short interval; and
all-LAA BSSIDs with unrelated octets 1-4 still producing the short
interval (no false-positive on LAA alone).

Empirical A/B on the local rig (2026-04-29):
  - With this dedup applied: bgscan = "simple:30:-70:86400";
    0 scans observed in 18-min capture; 0 audio/video stalls in
    a 30-min Discord call
  - Without (stock 1.54): bgscan = "simple:30:-65:300";
    6 full-band scans at exactly 305 s cadence; 4 stall bursts
    coinciding with scan windows in 30 min

Heuristic was calibrated against TP-Link Deco BE63; non-Deco MLO
hardware was not available for testing. The conservative
all-of-three-conditions check should reject most non-MLO traffic,
but vendor diversity in MLO per-link MAC derivation has not been
characterised. Testers with non-Deco MLO hardware welcome.

Signed-off-by: Louis Kotze <loukot@gmail.com>
This commit is contained in:
Louis Kotze 2026-05-06 14:07:10 +02:00
parent fbfbd7f8e4
commit f74b5809a1
No known key found for this signature in database
4 changed files with 195 additions and 8 deletions

View file

@ -3100,12 +3100,19 @@ build_supplicant_config(NMDeviceWifi *self,
goto error;
}
if (!nm_supplicant_config_add_bgscan(config,
connection,
nm_settings_connection_get_num_seen_bssids(sett_conn),
error)) {
g_prefix_error(error, "bgscan: ");
goto error;
{
const char *seen_bssids[NM_SETTINGS_CONNECTION_SEEN_BSSIDS_MAX + 1];
guint num_seen_bssids;
num_seen_bssids = nm_settings_connection_get_seen_bssids(sett_conn, seen_bssids);
if (!nm_supplicant_config_add_bgscan(config,
connection,
num_seen_bssids,
seen_bssids,
error)) {
g_prefix_error(error, "bgscan: ");
goto error;
}
}
ap_isolation = nm_setting_wireless_get_ap_isolation(s_wireless);

View file

@ -752,10 +752,66 @@ nm_supplicant_config_add_setting_wireless(NMSupplicantConfig *self,
return TRUE;
}
/* Maximum number of seen BSSIDs we treat as evidence of a single Wi-Fi 7
* MLO AP. 802.11be defines tri-link (2.4 + 5 + 6 GHz) as the maximum;
* beyond 3 the heuristic does not apply and the multi-AP path runs.
*/
#define _BGSCAN_MLO_HEURISTIC_MAX_BSSIDS 3u
/* _seen_bssids_look_like_mlo_per_link:
*
* Heuristic returning TRUE when @seen_bssids appears to be the per-link
* BSSIDs of one Wi-Fi 7 MLO-capable AP rather than several physical APs.
*
* In MLO, one physical AP advertises one BSSID per link (2 for 5+6 GHz,
* 3 for tri-link). Vendors observed in the wild derive the per-link
* BSSIDs as locally-administered virtual MAC addresses sharing octets
* 1-4 with each other. The MLD address itself may or may not be in the
* seen-bssids list.
*
* Real multi-AP ESSes (mesh, enterprise) typically use vendor-assigned
* MACs (LAA bit unset) and unrelated address blocks per AP, so the
* heuristic should not false-positive on them.
*/
static gboolean
_seen_bssids_look_like_mlo_per_link(const char *const *seen_bssids, guint num_seen_bssids)
{
guint8 common_octets_1_to_4[4];
guint i;
if (num_seen_bssids < 2u || num_seen_bssids > _BGSCAN_MLO_HEURISTIC_MAX_BSSIDS)
return FALSE;
if (!seen_bssids)
return FALSE;
for (i = 0; i < num_seen_bssids; i++) {
const char *bssid = seen_bssids[i];
guint8 addr[6];
if (!bssid || !nm_utils_hwaddr_aton(bssid, addr, sizeof(addr)))
return FALSE;
/* Locally-administered bit is bit 1 (value 0x02) of the first octet. */
if (!(addr[0] & 0x02u))
return FALSE;
/* Octets 1-4 must be identical across all seen BSSIDs (only octets
* 0 and 5 vary across per-link MAC addresses for the same MLD).
*/
if (i == 0)
memcpy(common_octets_1_to_4, &addr[1], 4);
else if (memcmp(common_octets_1_to_4, &addr[1], 4) != 0)
return FALSE;
}
return TRUE;
}
gboolean
nm_supplicant_config_add_bgscan(NMSupplicantConfig *self,
NMConnection *connection,
guint num_seen_bssids,
const char *const *seen_bssids,
GError **error)
{
NMSettingWireless *s_wifi;
@ -792,8 +848,12 @@ nm_supplicant_config_add_bgscan(NMSupplicantConfig *self,
* in which we want more reliable roaming between APs. Thus trigger scans
* when the signal is still somewhat OK so we have an up-to-date roam
* candidate list when the signal gets bad.
*
* Exception: when @seen_bssids look like the per-link addresses of a
* single WiFi-7 MLO AP (locally-administered bit set + shared octets
* 1-4), keep the long interval. See _seen_bssids_look_like_mlo_per_link().
*/
if (num_seen_bssids > 1u
if ((num_seen_bssids > 1u && !_seen_bssids_look_like_mlo_per_link(seen_bssids, num_seen_bssids))
|| ((s_wsec = nm_connection_get_setting_wireless_security(connection))
&& NM_IN_STRSET(nm_setting_wireless_security_get_key_mgmt(s_wsec),
"ieee8021x",

View file

@ -47,6 +47,7 @@ gboolean nm_supplicant_config_add_setting_wireless(NMSupplicantConfig *self,
gboolean nm_supplicant_config_add_bgscan(NMSupplicantConfig *self,
NMConnection *connection,
guint num_seen_bssids,
const char *const *seen_bssids,
GError **error);
gboolean nm_supplicant_config_add_setting_wireless_security(NMSupplicantConfig *self,

View file

@ -129,7 +129,7 @@ build_supplicant_config(NMConnection *connection,
g_assert_no_error(error);
g_assert(success);
success = nm_supplicant_config_add_bgscan(config, connection, 0, &error);
success = nm_supplicant_config_add_bgscan(config, connection, 0, NULL, &error);
g_assert_no_error(error);
g_assert(success);
@ -914,6 +914,124 @@ test_suppl_cap_mask(void)
/*****************************************************************************/
static void
test_wifi_bgscan_mlo_dedup(void)
{
/* The bgscan multi-AP heuristic should treat per-link BSSIDs of one
* Wi-Fi 7 MLO AP as a single AP (long-interval bgscan) and treat
* vendor-assigned multi-AP BSSIDs as multi-AP (short-interval bgscan).
*/
gs_unref_object NMConnection *connection = NULL;
const unsigned char ssid_data[] = {'M', 'L', 'O', '-', 't', 'e', 's', 't'};
gs_unref_bytes GBytes *ssid = g_bytes_new(ssid_data, sizeof(ssid_data));
/* Per-link addresses for one MLO AP: LAA bit set on first octet,
* octets 1-4 shared, only octet 0 and 5 vary across links.
* Captured from a TP-Link Deco BE63 mesh.
*/
const char *mlo_seen[] = {
"f6:75:0c:74:4b:75",
"ca:75:0c:74:4b:70",
NULL,
};
/* Real multi-AP setup: vendor-assigned MACs (UAA bit unset) with
* unrelated address blocks per AP.
*/
const char *multi_ap_seen[] = {
"00:11:22:33:44:55",
"aa:bb:cc:dd:ee:ff",
NULL,
};
/* All-LAA but octets 1-4 do not match: must NOT be treated as MLO. */
const char *laa_unrelated_seen[] = {
"f6:75:0c:74:4b:75",
"f2:99:88:77:66:55",
NULL,
};
/* Tri-link MLO: 3 BSSIDs all matching the per-link MAC pattern.
* 802.11be defines tri-link (2.4 + 5 + 6 GHz) as the maximum;
* the heuristic's upper bound is 3.
*/
const char *mlo_seen_3link[] = {
"f6:75:0c:74:4b:75",
"ca:75:0c:74:4b:70",
"e2:75:0c:74:4b:80",
NULL,
};
connection = new_basic_connection("MLO Wi-Fi", ssid, NULL);
g_object_set(nm_connection_get_setting_wireless(connection),
NM_SETTING_WIRELESS_BAND,
"a",
NULL);
{
gs_unref_object NMSupplicantConfig *config = NULL;
GError *error = NULL;
gboolean success;
config =
nm_supplicant_config_new(NM_SUPPL_CAP_MASK_NONE,
nm_utils_get_connection_first_permissions_user(connection));
NMTST_EXPECT_NM_INFO("Config: added 'bgscan' value 'simple:30:-70:86400'*");
success = nm_supplicant_config_add_bgscan(config, connection, 2, mlo_seen, &error);
g_assert_no_error(error);
g_assert(success);
g_test_assert_expected_messages();
}
{
gs_unref_object NMSupplicantConfig *config = NULL;
GError *error = NULL;
gboolean success;
config =
nm_supplicant_config_new(NM_SUPPL_CAP_MASK_NONE,
nm_utils_get_connection_first_permissions_user(connection));
NMTST_EXPECT_NM_INFO("Config: added 'bgscan' value 'simple:30:-65:300'*");
success = nm_supplicant_config_add_bgscan(config, connection, 2, multi_ap_seen, &error);
g_assert_no_error(error);
g_assert(success);
g_test_assert_expected_messages();
}
{
gs_unref_object NMSupplicantConfig *config = NULL;
GError *error = NULL;
gboolean success;
config =
nm_supplicant_config_new(NM_SUPPL_CAP_MASK_NONE,
nm_utils_get_connection_first_permissions_user(connection));
NMTST_EXPECT_NM_INFO("Config: added 'bgscan' value 'simple:30:-65:300'*");
success =
nm_supplicant_config_add_bgscan(config, connection, 2, laa_unrelated_seen, &error);
g_assert_no_error(error);
g_assert(success);
g_test_assert_expected_messages();
}
{
gs_unref_object NMSupplicantConfig *config = NULL;
GError *error = NULL;
gboolean success;
config =
nm_supplicant_config_new(NM_SUPPL_CAP_MASK_NONE,
nm_utils_get_connection_first_permissions_user(connection));
NMTST_EXPECT_NM_INFO("Config: added 'bgscan' value 'simple:30:-70:86400'*");
success = nm_supplicant_config_add_bgscan(config, connection, 3, mlo_seen_3link, &error);
g_assert_no_error(error);
g_assert(success);
g_test_assert_expected_messages();
}
}
/*****************************************************************************/
NMTST_DEFINE();
int
@ -930,6 +1048,7 @@ main(int argc, char **argv)
g_test_add_func("/supplicant-config/wifi-sae", test_wifi_sae);
g_test_add_func("/supplicant-config/test_suppl_cap_mask", test_suppl_cap_mask);
g_test_add_func("/supplicant-config/wifi-eap-suite-b-192", test_wifi_eap_suite_b_generation);
g_test_add_func("/supplicant-config/wifi-bgscan-mlo-dedup", test_wifi_bgscan_mlo_dedup);
return g_test_run();
}