fix(network): bound link transitions and restore each medium's DNS

Two defects found by an adversarial review of the merge.

A link transition no longer stops a slot whose connect attempt is in flight.
Stopping one means esp_mqtt_client_stop(), which waits on the SDK's API mutex
and its task's stopped event with no bound; the client task notices only when
it returns from whatever transport call it is in. During teardown the bridge's
StopUnproven timeout contains that, but during a transition nothing does, so a
routine WiFi flap could freeze the sole MQTT worker for far longer than the 2 s
this path advertises, with the bridge's own stop handshake queued behind it.
The attempt is left to resolve instead: unlike a reconfigure, a transition
changes neither endpoint nor credentials, so an attempt that completes is
credited to the broker it actually reached and drops with the old route.

Failing back to Ethernet left the node using the WiFi network's DNS server.
lwIP keeps one global server list and IDF 4.4 has no per-interface retention,
so the medium that leased last owns DNS for every socket. Each link now
remembers the resolver its own DHCP lease installed and puts it back when it is
selected again; a medium that never held a lease leaves the current resolver
alone. Where the two networks are on different subnets this was a silent
outage: the link read as connected while every broker and NTP hostname failed
to resolve, until Ethernet's own DHCP renewal happened to fix it.
This commit is contained in:
agessaman committed 2026-09-19 15:31:51 -07:00
1 parent 7f28231b25
commit ee41da85ec
4 files changed
+102 -31

No files matched your search

@@ -76,26 +76,31 @@ TEST(MqttClientState, StopIsAcknowledgedOnlyWhenEveryClientIsProvenStopped) {
}
// A link transition closes each slot's transport because the route under it
// changed. The two predicates above are what decide how, and getting the
// Starting case wrong is not cosmetic: softDisconnect() returns immediately on
// a client that is not yet connected, so that attempt would run to completion
// against the OLD route and deliver a CONNECTED event the new attempt cannot be
// told apart from.
TEST(MqttClientState, LinkTransitionStopsOnlyClientsWithAnAttemptInFlight) {
// changed. These two predicates are what decide which clients it may touch.
//
// The distinction from a reconfigure matters: a reconfigure must cancel an
// in-flight attempt, because that attempt would report the OLD endpoint under
// the new configuration. A transition changes no configuration, so the attempt
// is left to resolve — cancelling it would mean an unbounded
// esp_mqtt_client_stop() on the sole MQTT worker during a routine link flap.
TEST(MqttClientState, LinkTransitionOnlyDisconnectsClientsWithNoAttemptInFlight) {
for (MqttClientState s : kAllStates) {
const bool touched = mqttClientStateIsLive(s);
const bool needs_stop = touched && mqttClientStateHasAttemptInFlight(s);
const bool live = mqttClientStateIsLive(s);
const bool disconnected_here = live && !mqttClientStateHasAttemptInFlight(s);
// Quarantined is never touched: its SDK task was not joined, so it stays
// out of service for the rest of the boot.
if (s == MqttClientState::Quarantined) EXPECT_FALSE(touched) << mqttClientStateName(s);
if (s == MqttClientState::Quarantined) EXPECT_FALSE(live) << mqttClientStateName(s);
// Nothing that is already proven stopped is worth a disconnect.
if (mqttClientStateIsProvenStopped(s)) EXPECT_FALSE(touched) << mqttClientStateName(s);
if (mqttClientStateIsProvenStopped(s)) EXPECT_FALSE(live) << mqttClientStateName(s);
EXPECT_EQ(s == MqttClientState::Starting, needs_stop) << mqttClientStateName(s);
// The cheap bounded path, which keeps the esp-mqtt task alive.
EXPECT_EQ(s == MqttClientState::Connected || s == MqttClientState::Disconnected,
touched && !needs_stop) << mqttClientStateName(s);
disconnected_here) << mqttClientStateName(s);
// Starting is live, and still must not be disconnected here.
if (s == MqttClientState::Starting) {
EXPECT_TRUE(live);
EXPECT_FALSE(disconnected_here);
}
}
}