Complete Phase 12 dual-stack networking
Add IPv6-aware Wi-Fi state, HTTPS/SSH listeners, mDNS service reconciliation, and browser Wi-Fi administration. Include a guarded build-local fix for mDNS 1.12.0 membership handling, focused regression suites, and Phase 12 acceptance documentation.
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
# Phase 12 mDNS owner host regression
|
||||
|
||||
Run `python3 tests/mdns_phase12/run.py` from the repository root. Uses the host C11 compiler and temporary fake SDK headers; compiles the actual production module. No PlatformIO, device operations, or generated firmware assets.
|
||||
|
||||
Covers notifications before initialization and while the project mutex is held; default unavailable SSH; fixed HTTPS443/SSH22 and empty TXT; repeated starts without duplicate records or hostname churn; record add/remove failures and retry; offline withdrawal; offline rename and retry; latest-state coalescing; init/hostname/instance failure latches and cleanup. Family tests cover lost IPv4 with surviving IPv6, IPv6-only/link-local readiness, no valid IPv6, netif down, stop with stale addresses, missed GOT_IP6, explicitly failed and silently dropped action submission, delayed upstream disable, missing STA netif, and no enable churn during ordinary healthy polls. TCP/IP fakes assert address reads occur in TCP/IP context; netif actions assert neither project mutex nor publication critical section is held. Actual multicast, component tasks, DNS conflicts, and sockets are not simulated.
|
||||
|
||||
## Integration contract
|
||||
|
||||
- Listener owners call `void mdns_service_set_https_available(bool)` / `void mdns_service_set_ssh_available(bool)` after successful listener creation and on unavailability. Calls use tiny portMUX critical sections, safe before initialization, with no blocking semaphore, allocation, callbacks, or component calls. Xtensa does not promise lock-free C11 atomic bool, so no atomics are required.
|
||||
- The sole Wi-Fi owner calls `esp_err_t mdns_service_reconcile(void)` periodically, even offline. No new arguments are needed: it looks up the permanent `WIFI_STA_DEF` netif and samples addresses via `esp_netif_tcpip_exec`. No project mutex or publication mux is held across SDK/component calls.
|
||||
- Reconciliation never initializes the responder. Before module init it returns invalid-state; after module init but before responder startup it is a successful no-op. Once initialized it applies listener states and family readiness, retrying failed operations on subsequent passes. Notifications converge on a later pass, not synchronously; an in-flight pass can briefly reflect an older state.
|
||||
- Existing start/reannounce also reconcile. Start remains gated by usable STA in either family. Stop now clears announcement expectation **and requests family disable**, even if netif still holds nonzero addresses; it does not destroy/reinitialize the responder. Errors are retained in the snapshot. Do not call lifecycle operations concurrently or while holding another service lock. The manager owns start/stop, not event callbacks.
|
||||
- Required effective SDK options remain `CONFIG_MDNS_PREDEF_NETIF_STA=y`, AP/ETH predefined interfaces disabled, `CONFIG_LWIP_IPV4=y`, `CONFIG_LWIP_IPV6=y`, `CONFIG_MDNS_MAX_SERVICES >= 2`. No configuration change was made for the repair.
|
||||
|
||||
## Exact dependency contracts and limitations
|
||||
|
||||
Inspected installed Espressif mDNS **1.12.0** and ESP-IDF **5.5.0**:
|
||||
|
||||
- `mdns_netif.c` initializes IPv6 from `esp_netif_get_ip6_linklocal`, independently of IPv4. Its predefined handlers process disconnect and address acquisition, but not STA_LOST_IP. `mdns_networking_lwip.c` tracks family readiness separately from current addresses. `mdns_send.c` checks readiness before emitting A/AAAA, but can emit zero A if readiness remains true after IPv4 loss. Explicit family disable repairs that stale readiness, including when IPv6 survives.
|
||||
- `mdns_send.c` uses `esp_netif_get_all_ip6`, whose IDF implementation includes **valid addresses, including deprecated addresses**, excludes invalid/tentative/zero addresses, and reads lwIP state directly. Our sampling therefore runs in TCP/IP context and intentionally follows valid-address semantics, not preferred-only semantics. No address cache is passed to mDNS; it still reads addresses when serializing records.
|
||||
- `mdns_netif_action` is asynchronous, has no public readiness getter or acknowledgement, and `post_custom_action` returns `ESP_OK` even if action enqueue fails. A cached successful API return is not proof of applied state. Allocation errors retry on the next pass; silent losses require reassertion. Missing-family disables are idempotent and reasserted every poll. Present-family enables restart probes, so they are submitted on family-mask transitions and at a **30-second repair cadence**, not every healthy interval. This also repairs late upstream disconnect actions and failed internal PCB initialization. There is no responder restart or service-record churn.
|
||||
- Public-only control cannot simultaneously guarantee acknowledged readiness and absolutely no periodic enable reprobes. Healthy families re-probe every 30 seconds as the explicit tradeoff. Recovery from silent dropped enables is on a subsequent repair cadence **once the component queue/network resources make progress**, not a hard deadline under sustained failure. Missing-family disable retries each poll. Polling, concurrent network changes, and component queuing leave a transient window in which stale/zero A responses can still escape; this is convergence, not an atomic packet-level filter. No upstream patch is included.
|
||||
- `mdns_responder.c` adds/removes records synchronously under its own mutex. Hostname setting waits for the component worker; instance setting queues work. Work per pass is one bounded address scan, at most one family action, and two record decisions; no additional tasks, queues, dynamic application storage, or retry loops. This is not a hard wall-clock guarantee for upstream blocking calls.
|
||||
|
||||
## Target validation
|
||||
|
||||
In addition to the host suite, the updated `src/mdns_service.c` was compiled to a temporary object using its exact `.pio/build/esp32-s3-devkitc-1-n16r8/compile_commands.json` command and the installed Xtensa compiler / IDF 5.5 headers. Compilation passed. No full PlatformIO build, link, or hardware validation was performed.
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile the production mDNS owner against bounded host fakes; no IDF build."""
|
||||
import pathlib
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
with tempfile.TemporaryDirectory(prefix="mdns-phase12-") as tmp:
|
||||
p = pathlib.Path(tmp)
|
||||
(p / "freertos").mkdir()
|
||||
headers = {
|
||||
"esp_err.h": """#pragma once
|
||||
typedef int esp_err_t;
|
||||
#define ESP_OK 0
|
||||
#define ESP_ERR_INVALID_ARG 1
|
||||
#define ESP_ERR_INVALID_STATE 2
|
||||
#define ESP_ERR_NO_MEM 3
|
||||
#define ESP_ERR_NOT_FOUND 4
|
||||
#define ESP_ERR_TIMEOUT 5
|
||||
""",
|
||||
"sdkconfig.h": """#define CONFIG_MDNS_PREDEF_NETIF_STA 1
|
||||
#define CONFIG_MDNS_PREDEF_NETIF_AP 0
|
||||
#define CONFIG_MDNS_PREDEF_NETIF_ETH 0
|
||||
#define CONFIG_LWIP_IPV6_NUM_ADDRESSES 3
|
||||
""",
|
||||
"freertos/FreeRTOS.h": """#pragma once
|
||||
#define portMAX_DELAY 100
|
||||
#define pdTRUE 1
|
||||
typedef int portMUX_TYPE;
|
||||
#define portMUX_INITIALIZER_UNLOCKED 0
|
||||
void fake_enter(portMUX_TYPE *);
|
||||
void fake_exit(portMUX_TYPE *);
|
||||
#define portENTER_CRITICAL(m) fake_enter(m)
|
||||
#define portEXIT_CRITICAL(m) fake_exit(m)
|
||||
""",
|
||||
"freertos/semphr.h": """#pragma once
|
||||
typedef void *SemaphoreHandle_t;
|
||||
SemaphoreHandle_t xSemaphoreCreateMutex(void);
|
||||
int xSemaphoreTake(SemaphoreHandle_t, int);
|
||||
int xSemaphoreGive(SemaphoreHandle_t);
|
||||
""",
|
||||
"esp_timer.h": """#pragma once
|
||||
#include <stdint.h>
|
||||
int64_t esp_timer_get_time(void);
|
||||
""",
|
||||
"esp_netif.h": """#pragma once
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
typedef struct { int unused; } esp_netif_t;
|
||||
typedef struct { uint32_t addr; } esp_ip4_addr_t;
|
||||
typedef struct { uint32_t addr[4]; } esp_ip6_addr_t;
|
||||
typedef struct { esp_ip4_addr_t ip; } esp_netif_ip_info_t;
|
||||
esp_netif_t *esp_netif_get_handle_from_ifkey(const char *);
|
||||
bool esp_netif_is_netif_up(esp_netif_t *);
|
||||
esp_err_t esp_netif_get_ip_info(esp_netif_t *, esp_netif_ip_info_t *);
|
||||
int esp_netif_get_all_ip6(esp_netif_t *, esp_ip6_addr_t *);
|
||||
esp_err_t esp_netif_tcpip_exec(esp_err_t (*)(void *), void *);
|
||||
""",
|
||||
"mdns.h": """#pragma once
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
#include "esp_netif.h"
|
||||
typedef enum {
|
||||
MDNS_EVENT_ENABLE_IP4 = 1 << 1, MDNS_EVENT_ENABLE_IP6 = 1 << 2,
|
||||
MDNS_EVENT_DISABLE_IP4 = 1 << 5, MDNS_EVENT_DISABLE_IP6 = 1 << 6
|
||||
} mdns_event_actions_t;
|
||||
esp_err_t mdns_netif_action(esp_netif_t *, mdns_event_actions_t);
|
||||
esp_err_t mdns_init(void);
|
||||
void mdns_free(void);
|
||||
esp_err_t mdns_hostname_set(const char *);
|
||||
esp_err_t mdns_instance_name_set(const char *);
|
||||
esp_err_t mdns_service_add(const char *, const char *, const char *, uint16_t, void *, size_t);
|
||||
esp_err_t mdns_service_remove(const char *, const char *);
|
||||
""",
|
||||
}
|
||||
for name, text in headers.items():
|
||||
(p / name).write_text(text)
|
||||
exe = p / "test"
|
||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-I", tmp,
|
||||
"-I", str(ROOT / "src"), str(pathlib.Path(__file__).with_name("test.c")),
|
||||
"-o", str(exe)], check=True)
|
||||
for case in ("normal", "init-failure", "hostname-failure", "instance-failure"):
|
||||
subprocess.run([str(exe), case], check=True)
|
||||
@@ -0,0 +1,171 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "mdns_service.c"
|
||||
|
||||
static int held, inits, frees, names, adds, removes;
|
||||
static int fail_init, fail_name, fail_instance, fail_record;
|
||||
static bool https, ssh;
|
||||
static char hostname[64];
|
||||
static int critical, tcpip, actions, enables, fail_action, drop_action;
|
||||
static bool up = true, valid6 = true, present = true, zero6;
|
||||
static uint32_t ipv4 = 1;
|
||||
static unsigned ready;
|
||||
static int64_t clock_us;
|
||||
static esp_netif_t sta;
|
||||
void fake_enter(portMUX_TYPE *m) { (void)m; assert(!critical); critical = 1; }
|
||||
void fake_exit(portMUX_TYPE *m) { (void)m; assert(critical); critical = 0; }
|
||||
int64_t esp_timer_get_time(void) { assert(!held && !critical); return clock_us; }
|
||||
esp_netif_t *esp_netif_get_handle_from_ifkey(const char *key) {
|
||||
assert(!held && !critical && !strcmp(key, "WIFI_STA_DEF")); return present ? &sta : NULL;
|
||||
}
|
||||
bool esp_netif_is_netif_up(esp_netif_t *n) { assert(tcpip && n == &sta); return up; }
|
||||
esp_err_t esp_netif_get_ip_info(esp_netif_t *n, esp_netif_ip_info_t *ip) {
|
||||
assert(tcpip && n == &sta); ip->ip.addr = ipv4; return ESP_OK;
|
||||
}
|
||||
int esp_netif_get_all_ip6(esp_netif_t *n, esp_ip6_addr_t *ip) {
|
||||
assert(tcpip && n == &sta); memset(ip, 0, sizeof(*ip)); ip->addr[0] = zero6 ? 0 : 0xfe80;
|
||||
return valid6 ? 1 : 0;
|
||||
}
|
||||
esp_err_t esp_netif_tcpip_exec(esp_err_t (*fn)(void *), void *arg) {
|
||||
assert(!held && !critical && !tcpip); tcpip = 1;
|
||||
esp_err_t error = fn(arg); tcpip = 0; return error;
|
||||
}
|
||||
esp_err_t mdns_netif_action(esp_netif_t *n, mdns_event_actions_t action) {
|
||||
assert(!held && !critical && !tcpip && n == &sta); ++actions;
|
||||
if (fail_action) return ESP_ERR_NO_MEM;
|
||||
if (drop_action) return ESP_OK; /* Actual upstream full-queue behavior. */
|
||||
if (action & MDNS_EVENT_ENABLE_IP4) { ready |= 1; ++enables; }
|
||||
if (action & MDNS_EVENT_ENABLE_IP6) { ready |= 2; ++enables; }
|
||||
if (action & MDNS_EVENT_DISABLE_IP4) ready &= ~1U;
|
||||
if (action & MDNS_EVENT_DISABLE_IP6) ready &= ~2U;
|
||||
return ESP_OK;
|
||||
}
|
||||
static void test_families(void) {
|
||||
assert(ready == 3);
|
||||
int before = actions;
|
||||
for (int i = 0; i < 20; ++i) assert(mdns_service_reconcile() == ESP_OK);
|
||||
assert(actions == before); /* Healthy dual-stack poll has no action. */
|
||||
ipv4 = 0; /* Lost IPv4, surviving IPv6: A readiness must be removed. */
|
||||
drop_action = 1;
|
||||
assert(mdns_service_reconcile() == ESP_OK && ready == 3);
|
||||
drop_action = 0;
|
||||
assert(mdns_service_reconcile() == ESP_OK && ready == 2);
|
||||
before = enables;
|
||||
for (int i = 0; i < 20; ++i) assert(mdns_service_reconcile() == ESP_OK);
|
||||
assert(enables == before); /* Disables do not restart the healthy family. */
|
||||
zero6 = true; /* Defensive rejection even if an API supplied a zero entry. */
|
||||
assert(mdns_service_reconcile() == ESP_OK && ready == 0);
|
||||
zero6 = false;
|
||||
valid6 = false;
|
||||
assert(mdns_service_reconcile() == ESP_OK && ready == 0);
|
||||
valid6 = true; /* Missed GOT_IP6 and silently lost repair submission. */
|
||||
drop_action = 1;
|
||||
assert(mdns_service_reconcile() == ESP_OK && ready == 0);
|
||||
drop_action = 0;
|
||||
clock_us += MDNS_FAMILY_REPAIR_US;
|
||||
assert(mdns_service_reconcile() == ESP_OK && ready == 2);
|
||||
ipv4 = 1; fail_action = 1;
|
||||
assert(mdns_service_reconcile() == ESP_ERR_NO_MEM && ready == 2);
|
||||
fail_action = 0;
|
||||
assert(mdns_service_reconcile() == ESP_OK && ready == 3);
|
||||
ready = 0; /* Late upstream disconnect action after the last sample. */
|
||||
clock_us += MDNS_FAMILY_REPAIR_US;
|
||||
assert(mdns_service_reconcile() == ESP_OK && ready == 3);
|
||||
up = false;
|
||||
assert(mdns_service_reconcile() == ESP_OK && ready == 0);
|
||||
up = true;
|
||||
assert(mdns_service_reconcile() == ESP_OK && ready == 3);
|
||||
mdns_service_stop();
|
||||
assert(ready == 0); /* Even if stale nonzero addresses remain in netif. */
|
||||
assert(mdns_service_start() == ESP_OK && ready == 3);
|
||||
present = false;
|
||||
assert(mdns_service_reconcile() == ESP_ERR_INVALID_STATE);
|
||||
present = true;
|
||||
assert(mdns_service_reconcile() == ESP_OK);
|
||||
}
|
||||
SemaphoreHandle_t xSemaphoreCreateMutex(void) { return &held; }
|
||||
int xSemaphoreTake(SemaphoreHandle_t m, int wait) {
|
||||
(void)m;
|
||||
assert(!critical);
|
||||
if (held) { assert(!wait); return 0; }
|
||||
held = 1; return pdTRUE;
|
||||
}
|
||||
int xSemaphoreGive(SemaphoreHandle_t m) { (void)m; assert(held); held = 0; return 1; }
|
||||
esp_err_t mdns_config_validate(const mdns_config_t *c) { return c && c->suffix_len ? ESP_OK : ESP_ERR_INVALID_ARG; }
|
||||
void mdns_config_defaults(mdns_config_t *c) { memset(c, 0, sizeof(*c)); strcpy(c->suffix, "default"); c->suffix_len = 7; }
|
||||
esp_err_t mdns_config_load(mdns_config_t *c, bool *stored) { mdns_config_defaults(c); *stored = false; return ESP_OK; }
|
||||
esp_err_t mdns_config_save(const mdns_config_t *c) { (void)c; return ESP_OK; }
|
||||
esp_err_t mdns_init(void) { assert(!held && !critical); ++inits; return fail_init ? ESP_ERR_NO_MEM : ESP_OK; }
|
||||
void mdns_free(void) { assert(!held && !critical); ++frees; }
|
||||
esp_err_t mdns_hostname_set(const char *n) { assert(!held && !critical); ++names; if (fail_name) return ESP_ERR_NO_MEM; strcpy(hostname, n); return ESP_OK; }
|
||||
esp_err_t mdns_instance_name_set(const char *n) { assert(!held && !critical && n); return fail_instance ? ESP_ERR_NO_MEM : ESP_OK; }
|
||||
esp_err_t mdns_service_add(const char *instance, const char *type, const char *proto, uint16_t port, void *txt, size_t count) {
|
||||
assert(!held && !critical && !instance && !txt && !count && !strcmp(proto, "_tcp"));
|
||||
++adds;
|
||||
if (fail_record) return ESP_ERR_NO_MEM;
|
||||
bool *record = !strcmp(type, "_https") ? &https : &ssh;
|
||||
assert(port == (record == &https ? 443 : 22));
|
||||
assert(!*record); *record = true; return ESP_OK;
|
||||
}
|
||||
esp_err_t mdns_service_remove(const char *type, const char *proto) {
|
||||
assert(!held && !critical && !strcmp(proto, "_tcp")); ++removes;
|
||||
if (fail_record) return ESP_ERR_NO_MEM;
|
||||
bool *record = !strcmp(type, "_https") ? &https : &ssh;
|
||||
assert(*record); *record = false; return ESP_OK;
|
||||
}
|
||||
int main(int argc, char **argv) {
|
||||
assert(argc == 2);
|
||||
fail_init = !strcmp(argv[1], "init-failure");
|
||||
fail_name = !strcmp(argv[1], "hostname-failure");
|
||||
fail_instance = !strcmp(argv[1], "instance-failure");
|
||||
mdns_service_set_https_available(true);
|
||||
mdns_service_set_ssh_available(false);
|
||||
assert(!inits && !adds);
|
||||
assert(mdns_service_reconcile() == ESP_ERR_INVALID_STATE);
|
||||
mdns_config_t c; mdns_config_defaults(&c);
|
||||
assert(mdns_service_init(&c) == ESP_OK);
|
||||
assert(mdns_service_reconcile() == ESP_OK && !inits);
|
||||
if (fail_init || fail_name || fail_instance) {
|
||||
assert(mdns_service_start() == ESP_ERR_NO_MEM);
|
||||
assert(frees == (fail_init ? 0 : 1));
|
||||
fail_init = fail_name = fail_instance = 0;
|
||||
for (int i = 0; i < 10; ++i) {
|
||||
assert(mdns_service_start() == ESP_ERR_NO_MEM);
|
||||
assert(mdns_service_reconcile() == ESP_ERR_NO_MEM);
|
||||
assert(mdns_service_reannounce() == ESP_ERR_NO_MEM);
|
||||
mdns_service_stop();
|
||||
}
|
||||
assert(inits == 1 && !adds);
|
||||
} else {
|
||||
assert(mdns_service_start() == ESP_OK && https && !ssh);
|
||||
for (int i = 0; i < 10; ++i) assert(mdns_service_start() == ESP_OK);
|
||||
assert(inits == 1 && names == 1 && adds == 1);
|
||||
test_families();
|
||||
held = 1; /* Notifications must never acquire the service mutex. */
|
||||
mdns_service_set_https_available(false);
|
||||
mdns_service_set_ssh_available(true);
|
||||
held = 0;
|
||||
fail_record = 1;
|
||||
assert(mdns_service_reconcile() == ESP_ERR_NO_MEM && https && !ssh);
|
||||
fail_record = 0;
|
||||
assert(mdns_service_reconcile() == ESP_OK && !https && ssh);
|
||||
mdns_service_stop();
|
||||
mdns_service_set_ssh_available(false);
|
||||
assert(mdns_service_reconcile() == ESP_OK && !ssh);
|
||||
strcpy(c.suffix, "offline"); c.suffix_len = 7;
|
||||
assert(mdns_service_set_config(&c) == ESP_OK);
|
||||
fail_name = 1;
|
||||
assert(mdns_service_start() == ESP_ERR_NO_MEM);
|
||||
fail_name = 0;
|
||||
assert(mdns_service_start() == ESP_OK && !strcmp(hostname, "sak-offline"));
|
||||
mdns_service_set_https_available(true);
|
||||
mdns_service_set_https_available(false);
|
||||
int before = adds;
|
||||
assert(mdns_service_reconcile() == ESP_OK && adds == before);
|
||||
assert(inits == 1 && !frees);
|
||||
}
|
||||
printf("PASS %s\n", argv[1]);
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user