diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 2e414138e..b55da7bb6 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -255,6 +255,9 @@ jobs: - name: Verify WiFi OTA startup and WebConfig handoff run: python3 -B test/test_wifi_ota_start.py -v + - name: Verify browser OTA completion and immediate HTTP listener restart + run: python3 -B test/test_async_web_lifecycle.py -v + - name: Verify GPS status distinguishes user settings from receiver sleep run: | python3 -B test/test_gps_status.py diff --git a/arch/esp32/AsyncElegantOTA/src/AsyncElegantOTA.cpp b/arch/esp32/AsyncElegantOTA/src/AsyncElegantOTA.cpp index 2366906fd..65d3542b6 100644 --- a/arch/esp32/AsyncElegantOTA/src/AsyncElegantOTA.cpp +++ b/arch/esp32/AsyncElegantOTA/src/AsyncElegantOTA.cpp @@ -1,4 +1,13 @@ #include +#include +static std::atomic ota_uploads_enabled{false}; +static std::atomic ota_upload_busy{false}; +#if defined(ESP32) +static std::atomic ota_reboot_pending{false}; +#endif +// All upload callbacks run on AsyncTCP's task. Update is a global singleton; +// retain one request as its owner until that request disconnects. +static AsyncWebServerRequest* ota_upload_owner = nullptr; AsyncElegantOtaClass AsyncElegantOTA; @@ -8,6 +17,7 @@ void AsyncElegantOtaClass::setID(const char* id){ void AsyncElegantOtaClass::begin(AsyncWebServer *server, const char* username, const char* password){ _server = server; + setEnabled(true); if(strlen(username) > 0){ _authRequired = true; @@ -49,13 +59,13 @@ void AsyncElegantOtaClass::begin(AsyncWebServer *server, const char* username, c return request->requestAuthentication(); } } - // the request handler is triggered after the upload has finished... - // create the response, add header, and send response - AsyncWebServerResponse *response = request->beginResponse((Update.hasError())?500:200, "text/plain", (Update.hasError())?"FAIL":"OK"); + if (request->isSent()) return; // Preserve an earlier upload error. + const bool complete = request->_tempObject + && *static_cast(request->_tempObject) && !Update.hasError(); + AsyncWebServerResponse *response = request->beginResponse(complete?200:500, "text/plain", complete?"OK":"FAIL"); response->addHeader("Connection", "close"); response->addHeader("Access-Control-Allow-Origin", "*"); request->send(response); - restart(); }, [&](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { //Upload handler chunks in data if(_authRequired){ @@ -65,11 +75,30 @@ void AsyncElegantOtaClass::begin(AsyncWebServer *server, const char* username, c } if (!index) { + if (!ota_uploads_enabled.load()) { + return request->send(503, "text/plain", "OTA stopped"); + } + bool expected = false; + if (request->_tempObject +#if defined(ESP32) + || ota_reboot_pending.load() +#endif + || !ota_upload_busy.compare_exchange_strong(expected, true) + ) { + return request->send(409, "text/plain", "OTA upload already active"); + } + // A stop can race the first check before ownership was reserved. + if (!ota_uploads_enabled.load()) { + ota_upload_busy.store(false); + return request->send(503, "text/plain", "OTA stopped"); + } if(!request->hasParam("MD5", true)) { + ota_upload_busy.store(false); return request->send(400, "text/plain", "MD5 parameter missing"); } if(!Update.setMD5(request->getParam("MD5", true)->value().c_str())) { + ota_upload_busy.store(false); return request->send(400, "text/plain", "MD5 parameter invalid"); } @@ -84,10 +113,34 @@ void AsyncElegantOtaClass::begin(AsyncWebServer *server, const char* username, c if (!Update.begin(UPDATE_SIZE_UNKNOWN, cmd)) { // Start with max available size #endif Update.printError(Serial); + ota_upload_busy.store(false); return request->send(400, "text/plain", "OTA could not begin"); } + // AsyncWebServer frees this per-request allocation on disconnect. + // hasError() alone also reports success for a POST with no upload. + request->_tempObject = calloc(1, sizeof(bool)); + if (!request->_tempObject) { + Update.abort(); + ota_upload_busy.store(false); + return request->send(503, "text/plain", "OTA state allocation failed"); + } + ota_upload_owner = request; + request->onDisconnect([this, request]() { + if (ota_upload_owner != request) return; + ota_upload_owner = nullptr; + if (*static_cast(request->_tempObject)) { + // Let the response drain and the network callback return + // before rebooting from the separate restart task. + restart(); + } else { + Update.abort(); // Release a disconnected, partial upload. + } + ota_upload_busy.store(false); + }); } + if (request->isSent() || ota_upload_owner != request) return; + // Write chunked data to the free sketch space if(len){ if (Update.write(data, len) != len) { @@ -100,21 +153,53 @@ void AsyncElegantOtaClass::begin(AsyncWebServer *server, const char* username, c Update.printError(Serial); return request->send(400, "text/plain", "Could not end OTA"); } + *static_cast(request->_tempObject) = true; }else{ return; } }); } +bool AsyncElegantOtaClass::setEnabled(bool enabled) { + if (enabled) { + ota_uploads_enabled.store(true); + return true; + } + const bool was_enabled = ota_uploads_enabled.exchange(false); + if (ota_upload_busy.load() +#if defined(ESP32) + || ota_reboot_pending.load() +#endif + ) { + ota_uploads_enabled.store(was_enabled); + return false; + } + return true; +} + // deprecated, keeping for backward compatibility void AsyncElegantOtaClass::loop() { } void AsyncElegantOtaClass::restart() { +#if defined(ESP32) + bool expected = false; + if (!ota_reboot_pending.compare_exchange_strong(expected, true)) return; + // Even disconnect handlers run on the shared AsyncTCP task. Return to it + // immediately, then reboot from a separate task after final TCP cleanup. + if (xTaskCreate([](void*) { + vTaskDelay(pdMS_TO_TICKS(1000)); + ESP.restart(); + vTaskDelete(nullptr); + }, "ota-reboot", 3072, nullptr, 1, nullptr) != pdPASS) { + ota_reboot_pending.store(false); + } +#else yield(); delay(1000); yield(); ESP.restart(); +#endif } String AsyncElegantOtaClass::getID(){ diff --git a/arch/esp32/AsyncElegantOTA/src/AsyncElegantOTA.h b/arch/esp32/AsyncElegantOTA/src/AsyncElegantOTA.h index 219d11029..f7603201b 100644 --- a/arch/esp32/AsyncElegantOTA/src/AsyncElegantOTA.h +++ b/arch/esp32/AsyncElegantOTA/src/AsyncElegantOTA.h @@ -33,6 +33,10 @@ class AsyncElegantOtaClass{ loop(), restart(); + // Stop admission atomically with upload ownership. Existing GET + // responses may drain while the containing listener is stopped. + bool setEnabled(bool enabled); + private: AsyncWebServer *_server; diff --git a/platformio.ini b/platformio.ini index f2cc0d8a8..05cb0bdea 100644 --- a/platformio.ini +++ b/platformio.ini @@ -90,6 +90,7 @@ extra_scripts = pre:scripts/esp32_ota_heap_context.py pre:scripts/esp32_usb_session_fix.py pre:scripts/esp32_file_buffer_fix.py + pre:scripts/esp32_async_web_fix.py merge-bin.py post:tools/mota/pio_endf.py post:scripts/check_esp32_dram.py diff --git a/scripts/esp32_async_web_fix.py b/scripts/esp32_async_web_fix.py new file mode 100644 index 000000000..b9a0c4dcb --- /dev/null +++ b/scripts/esp32_async_web_fix.py @@ -0,0 +1,53 @@ +"""Build private AsyncTCP fixes and the current vendored OTA implementation.""" +import hashlib +from pathlib import Path + + +PINNED_ASYNCTCP_SHA256 = "27e3e22a6030cc5c9c9f32f28d0a3a36189b1ea6a5fa270777b2d2790a89c365" +ORIGINAL_BIND = " msg->err = tcp_bind(pcb, msg->bind.addr, msg->bind.port);" +REUSABLE_BIND = """ // A stopped HTTP listener may still have completed connections in + // TIME_WAIT. Permit immediate rebinding during WebConfig/OTA handoffs. +#if SO_REUSE + ip_set_option(pcb, SOF_REUSEADDR); +#endif +""" + ORIGINAL_BIND + + +def patched_async_tcp(source): + source = source.replace("\r\n", "\n") + if (hashlib.sha256(source.encode("ascii")).hexdigest() != PINNED_ASYNCTCP_SHA256 + or source.count(ORIGINAL_BIND) != 1): + raise RuntimeError("AsyncTCP restart fix: changed pinned source; review library update") + return source.replace(ORIGINAL_BIND, REUSABLE_BIND, 1) + + +def replace_async_source(build_env, node): + source = Path(node.srcnode().get_abspath()) + if source.name == "AsyncTCP.cpp": + content = patched_async_tcp(source.read_text(encoding="utf-8")) + include_dir = source.parent + elif source.name == "AsyncElegantOTA.cpp": + # file:// dependencies can retain an earlier copy in .pio/libdeps. + # Compile this checkout's vendored source without editing that cache. + include_dir = Path(build_env.subst("$PROJECT_DIR")) / "arch/esp32/AsyncElegantOTA/src" + content = (include_dir / source.name).read_text(encoding="ascii") + else: + return node + destination = Path(build_env.subst("$BUILD_DIR")) / "patched-esp32-web" / source.name + destination.parent.mkdir(parents=True, exist_ok=True) + if not destination.exists() or destination.read_text(encoding="ascii") != content: + destination.write_text(content, encoding="ascii") + build_env.PrependUnique(CPPPATH=[str(include_dir)]) + return build_env.File(str(destination)) + + +def install(build_env): + build_env.PrependUnique(CPPPATH=[str(Path(build_env.subst("$PROJECT_DIR")) + / "arch/esp32/AsyncElegantOTA/src")]) + build_env.AddBuildMiddleware(replace_async_source, "*AsyncTCP*src*AsyncTCP.cpp") + build_env.AddBuildMiddleware(replace_async_source, "*AsyncElegantOTA*src*AsyncElegantOTA.cpp") + + +if "Import" in globals(): + Import("env") + install(env) diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 99336f60f..604fd3d3a 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -366,9 +366,14 @@ bool ESP32Board::stopOTAUpdate(char reply[]) { #include #include #include +#include #include +// Requests retain raw pointers to the AsyncWebServer and its route handlers. +// Keep this one host alive across stops; end() only closes its listener. +static AsyncWebServer* async_ota_host = nullptr; + bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { if (!mesh::wireless::control().allowWiFiForOTA()) { strcpy(reply, "ERR: wireless change pending; retry start ota"); @@ -413,25 +418,52 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { // Select the requested network even when the HTTP listener already exists. // In particular, `start ota ap` must work after an earlier LAN-mode start. - if (ota_server != nullptr) return true; + if (ota_server != nullptr) { + if (ota_server->state() != LISTEN) ota_server->begin(); + if (ota_server->state() != LISTEN) { + strcpy(reply, "ERR: OTA server failed; retry start ota"); + return false; + } + return true; + } static char id_buf[60]; snprintf(id_buf, sizeof(id_buf), "%s (%s)", id, getManufacturerName()); static char home_buf[90]; snprintf(home_buf, sizeof(home_buf), "

Hi! I am a MeshCore Repeater. ID: %s

", id); - ota_server = new AsyncWebServer(80); - - ota_server->on("/", HTTP_GET, [](AsyncWebServerRequest *request) { - request->send(200, "text/html", home_buf); - }); - ota_server->on("/log", HTTP_GET, [](AsyncWebServerRequest *request) { - request->send(SPIFFS, "/packet_log", "text/plain"); - }); - + if (async_ota_host == nullptr) { + async_ota_host = new (std::nothrow) AsyncWebServer(80); + if (async_ota_host == nullptr) { + if (ota_started_ap) WiFi.softAPdisconnect(true); + ota_started_ap = false; + inhibit_sleep = false; + strcpy(reply, "ERR: OTA server allocation failed"); + return false; + } + async_ota_host->on("/", HTTP_GET, [](AsyncWebServerRequest *request) { + request->send(200, "text/html", home_buf); + }); + async_ota_host->on("/log", HTTP_GET, [](AsyncWebServerRequest *request) { + request->send(SPIFFS, "/packet_log", "text/plain"); + }); + AsyncElegantOTA.begin(async_ota_host); + } + ota_server = async_ota_host; AsyncElegantOTA.setID(id_buf); - AsyncElegantOTA.begin(ota_server); // Start ElegantOTA + AsyncElegantOTA.setEnabled(true); ota_server->begin(); + if (ota_server->state() != LISTEN) { + // begin() returns void even when the port could not be bound. + AsyncElegantOTA.setEnabled(false); + ota_server->end(); + ota_server = nullptr; + if (ota_started_ap) WiFi.softAPdisconnect(true); + ota_started_ap = false; + inhibit_sleep = false; + strcpy(reply, "ERR: OTA server failed; retry start ota"); + return false; + } return true; } @@ -442,8 +474,11 @@ bool ESP32Board::stopOTAUpdate(char reply[]) { return true; } + if (!AsyncElegantOTA.setEnabled(false)) { + strcpy(reply, "ERR: OTA upload active; retry stop ota after it finishes"); + return false; + } ota_server->end(); - delete ota_server; ota_server = nullptr; if (ota_started_ap) WiFi.softAPdisconnect(true); ota_started_ap = false; diff --git a/test/fixtures/async_ota_completion.cpp b/test/fixtures/async_ota_completion.cpp new file mode 100644 index 000000000..9faf39350 --- /dev/null +++ b/test/fixtures/async_ota_completion.cpp @@ -0,0 +1,139 @@ +#include +#include +#include +#include +#include +#include +using String = std::string; +constexpr int HTTP_GET=0, HTTP_POST=1, U_FLASH=0, U_SPIFFS=100; +constexpr unsigned UPDATE_SIZE_UNKNOWN=~0U; +constexpr int pdPASS=1; +#define pdMS_TO_TICKS(value) (value) +const char ELEGANT_HTML[] = "page"; +const unsigned ELEGANT_HTML_SIZE=sizeof(ELEGANT_HTML); +int Serial=0; +bool in_network_callback=false; +struct Esp { + int restarts=0; + void restart() { assert(!in_network_callback); ++restarts; } +} ESP; +void yield() { assert(!in_network_callback); } +void delay(unsigned) { assert(!in_network_callback); } +void vTaskDelay(unsigned) { assert(!in_network_callback); } +void vTaskDelete(void*) {} +using Task = void (*)(void*); +Task pending_task=nullptr; +bool allow_task=true; +int xTaskCreate(Task task, const char*, unsigned, void*, unsigned, void*) { + if (!allow_task) return 0; + assert(!pending_task); pending_task=task; return pdPASS; +} +@REBOOT_STATE@ +struct Parameter { String text="valid"; String value() { return text; } }; +struct Response { + int code; + void addHeader(const char*, const char*) {} +}; +struct Request { + void* _tempObject=nullptr; + bool md5_present=true, authorized=true, sent=false; + int code=0; + std::function disconnected; + Parameter md5; + ~Request() { free(_tempObject); } + bool authenticate(const char*, const char*) { return authorized; } + void requestAuthentication() { code=401; sent=true; } + bool hasParam(const char*, bool) { return md5_present; } + Parameter* getParam(const char*, bool) { return &md5; } + Response* beginResponse(int code, const char*, const char*) { return new Response{code}; } + Response* beginResponse_P(int code, const char*, const char*, unsigned) { return new Response{code}; } + void send(Response* response) { code=response->code; sent=true; delete response; } + void send(int status, const char*, const char*) { code=status; sent=true; } + void send(int status, const char*, const String&) { code=status; sent=true; } + bool isSent() const { return sent; } + void onDisconnect(std::function fn) { disconnected=fn; } +}; +using AsyncWebServerRequest=Request; +static AsyncWebServerRequest* ota_upload_owner=nullptr; +using AsyncWebServerResponse=Response; +struct AsyncWebServer { + std::function completion; + std::function upload; + template void on(const char*, int, F) {} + template void on(const char*, int method, F callback, U chunks) { + assert(method==HTTP_POST); completion=callback; upload=chunks; + } +}; +struct UpdateFake { + bool error=false, fail_end=false; + int aborts=0; + bool hasError() const { return error; } + bool setMD5(const char* md5) { return String(md5)=="valid"; } + bool begin(unsigned, int) { error=false; return true; } + size_t write(uint8_t*, size_t size) { return size; } + bool end(bool) { error=fail_end; return !error; } + void abort() { error=true; ++aborts; } + void printError(int) {} +} Update; +struct AsyncElegantOtaClass { + AsyncWebServer* _server=nullptr; + bool _authRequired=false; + String _username, _password, _id="fixture"; + void begin(AsyncWebServer*, const char*, const char*); + void restart(); + bool setEnabled(bool); +}; +@BEGIN@ +@RESTART@ + +int main() { + AsyncWebServer server; + AsyncElegantOtaClass ota; + ota.begin(&server,"",""); + auto complete=[&](Request& request) { + in_network_callback=true; server.completion(&request); in_network_callback=false; + }; + auto upload=[&](Request& request) { + uint8_t data[8]={}; + in_network_callback=true; + server.upload(&request,"firmware",0,data,sizeof(data),true); + in_network_callback=false; + }; + // A POST without an actual completed upload cannot report OK or reboot. + Request empty; + complete(empty); + assert(empty.code>=400 && !pending_task && ESP.restarts==0); + Request invalid; + invalid.md5_present=false; + upload(invalid); complete(invalid); + assert(invalid.code==400 && !pending_task && ESP.restarts==0); + Request corrupt; + Update.fail_end=true; + upload(corrupt); complete(corrupt); + assert(corrupt.code==400 && !pending_task && ESP.restarts==0); + in_network_callback=true; corrupt.disconnected(); in_network_callback=false; + assert(Update.aborts==1 && ota_upload_owner==nullptr); + Update.fail_end=false; + Request partial; + uint8_t chunk[4]={}; + server.upload(&partial,"firmware",0,chunk,sizeof(chunk),false); + assert(!ota.setEnabled(false)); // An admitted upload keeps its listener alive. + Request concurrent; + upload(concurrent); complete(concurrent); + assert(concurrent.code==409 && ota_upload_owner==&partial); + in_network_callback=true; partial.disconnected(); in_network_callback=false; + assert(Update.aborts==2 && ota_upload_owner==nullptr && ESP.restarts==0); + assert(ota.setEnabled(false)); + Request stopped; + upload(stopped); complete(stopped); + assert(stopped.code==503 && !ota_upload_owner && !pending_task); + assert(ota.setEnabled(true)); + Request valid; + upload(valid); complete(valid); + assert(valid.code==200 && valid.disconnected && !pending_task && ESP.restarts==0); + // The response drains before disconnect; its callback only schedules work. + in_network_callback=true; valid.disconnected(); in_network_callback=false; + assert(pending_task && ESP.restarts==0); + auto task=pending_task; pending_task=nullptr; task(nullptr); + assert(ESP.restarts==1); +} diff --git a/test/fixtures/wifi_ota_start.cpp b/test/fixtures/wifi_ota_start.cpp index 11c06af5e..1f1801def 100644 --- a/test/fixtures/wifi_ota_start.cpp +++ b/test/fixtures/wifi_ota_start.cpp @@ -12,6 +12,8 @@ constexpr int WL_CONNECTED = 3; constexpr int HTTP_GET = 0; constexpr int WIFI_AP = 2; constexpr int ESP_OK = 0, WIFI_IF_AP = 1; +constexpr int LISTEN = 10; +bool async_bind_allowed = true; constexpr uint8_t WIFI_PROTOCOL_11B = 1, WIFI_PROTOCOL_11G = 2; constexpr uint8_t WIFI_PROTOCOL_11N = 4, WIFI_PROTOCOL_LR = 8; using esp_err_t = int; @@ -60,15 +62,20 @@ struct AsyncWebServerRequest { void send(int, const char*, const char*, const char*) {} }; struct AsyncWebServer { + bool listening = false; explicit AsyncWebServer(int) {} template void on(const char*, int, F) {} - void begin() { ++server_starts; } - void end() {} + void begin() { ++server_starts; listening = async_bind_allowed; } + void end() { listening = false; } + int state() const { return listening ? LISTEN : 0; } }; struct Elegant { + bool busy = false; void setID(const char*) {} void begin(AsyncWebServer*) {} + bool setEnabled(bool enabled) { return enabled || !busy; } } AsyncElegantOTA; +static AsyncWebServer* async_ota_host = nullptr; static int SPIFFS; struct ESP32Board { bool inhibit_sleep = false; @@ -141,6 +148,21 @@ int main() { WiFi.ap = false; // An external driver stop invalidates cached AP ownership. start(true, "192.168.4.1", "Join WiFi MeshCore-OTA"); assert(WiFi.starts == ap_starts + 1); +#ifndef LIGHTWEIGHT_WIFI_OTA + auto* existing_server = board.ota_server; + existing_server->listening = false; + async_bind_allowed = false; + assert(!board.startOTAUpdate("test node", reply.text, true)); + assert(strstr(reply.text, "OTA server failed") && board.inhibit_sleep); + async_bind_allowed = true; + start(true, "192.168.4.1", "Join WiFi MeshCore-OTA"); + assert(board.ota_server == existing_server && existing_server->listening); + AsyncElegantOTA.busy = true; + assert(!board.stopOTAUpdate(reply.text)); + assert(strstr(reply.text, "OTA upload active") && board.inhibit_sleep); + assert(board.ota_server == existing_server && existing_server->listening); + AsyncElegantOTA.busy = false; +#endif #ifdef LIGHTWEIGHT_WIFI_OTA lightweight_ota_server.allow_stop = false; assert(!board.stopOTAUpdate(reply.text)); @@ -165,6 +187,14 @@ int main() { assert(!board.startOTAUpdate("test node", reply.text, false)); assert(strcmp(reply.text, "ERR: OTA WiFi failed") == 0); assert(!board.inhibit_sleep && !board.ota_server); +#ifndef LIGHTWEIGHT_WIFI_OTA + WiFi.allow_ap = true; + async_bind_allowed = false; + assert(!board.startOTAUpdate("test node", reply.text, false)); + assert(strstr(reply.text, "OTA server failed")); + assert(!board.inhibit_sleep && !board.ota_server && !WiFi.ap); + async_bind_allowed = true; +#endif // A pending wireless shutdown must not race a newly started uploader. struct Backend : mesh::wireless::Backend { uint8_t active = mesh::wireless::All; diff --git a/test/test_async_web_lifecycle.py b/test/test_async_web_lifecycle.py new file mode 100644 index 000000000..497312c56 --- /dev/null +++ b/test/test_async_web_lifecycle.py @@ -0,0 +1,74 @@ +"""Execute the vendored uploader and pinned TCP listener restart path.""" +import importlib.util +from pathlib import Path +import urllib.request +import unittest + +from test_replay_reset_integration import extract_braced +import test_wifi_ota_start as wifi_start + +ROOT=Path(__file__).resolve().parents[1] +spec=importlib.util.spec_from_file_location("async_web_fix", ROOT/"scripts/esp32_async_web_fix.py") +fix=importlib.util.module_from_spec(spec) +spec.loader.exec_module(fix) + + +class AsyncWebLifecycleTest(unittest.TestCase): + compile_and_run=wifi_start.WiFiOtaStartTest.compile_and_run + + def test_upload_response_drains_before_deferred_reboot(self): + source=(ROOT/"arch/esp32/AsyncElegantOTA/src/AsyncElegantOTA.cpp").read_text() + fixture=(ROOT/"test/fixtures/async_ota_completion.cpp").read_text() + state="""static std::atomic ota_reboot_pending{false}; +static std::atomic ota_uploads_enabled{false}; +static std::atomic ota_upload_busy{false};""" + # The two preprocessor branches for Update.begin each open the same + # logical block, so extract this method by its next declaration. + begin=source[source.index("void AsyncElegantOtaClass::begin("):source.index("// deprecated")] + self.compile_and_run(fixture.replace("@REBOOT_STATE@",state) + .replace("@BEGIN@",begin) + .replace("@RESTART@",extract_braced(source,"void AsyncElegantOtaClass::restart()")),"-DESP32=1") + + def test_tcp_listener_can_rebind_with_completed_connections(self): + cached=ROOT/".pio/libdeps/heltec_v4_repeater_observer_mqtt/AsyncTCP/src/AsyncTCP.cpp" + source=(cached.read_text() if cached.exists() else urllib.request.urlopen( + "https://raw.githubusercontent.com/ESP32Async/AsyncTCP/v3.5.0/src/AsyncTCP.cpp", + timeout=30).read().decode("ascii")) + patched=fix.patched_async_tcp(source) + fixture=r''' +#include +#define SO_REUSE 1 +constexpr int SOF_REUSEADDR=4,ERR_OK=0,ERR_USE=-8; +using err_t=int; +struct tcp_pcb { int flags=0; }; +struct tcpip_api_call_data {}; +struct tcp_api_call_t : tcpip_api_call_data { + tcp_pcb** pcb; + struct { void* addr=nullptr; unsigned short port=80; } bind; + int err=0; +}; +bool active_listener=false,time_wait=true; +void ip_set_option(tcp_pcb* pcb,int flags) { pcb->flags|=flags; } +int tcp_bind(tcp_pcb* pcb,void*,unsigned short) { + return active_listener || (time_wait && !(pcb->flags&SOF_REUSEADDR)) ? ERR_USE : ERR_OK; +} +int tcp_close(tcp_pcb*) { return ERR_OK; } +void tcp_abort(tcp_pcb*) { assert(false); } +@BIND@ +int main() { + tcp_pcb pcb,*pointer=&pcb; + tcp_api_call_t message; message.pcb=&pointer; + assert(_tcp_bind_api(&message)==ERR_OK && pointer==&pcb); + active_listener=true; + assert(_tcp_bind_api(&message)==ERR_USE && pointer==nullptr); +} +''' + self.compile_and_run(fixture.replace("@BIND@",extract_braced(patched,"static err_t _tcp_bind_api("))) + with self.assertRaises(AssertionError): + self.compile_and_run(fixture.replace("@BIND@",extract_braced(source,"static err_t _tcp_bind_api("))) + with self.assertRaisesRegex(RuntimeError,"changed pinned source"): + fix.patched_async_tcp(source+"\n// unreviewed change\n") + + +if __name__=="__main__": + unittest.main() diff --git a/test/test_wifi_ota_lifecycle.py b/test/test_wifi_ota_lifecycle.py index cdb416f8d..eb332a086 100644 --- a/test/test_wifi_ota_lifecycle.py +++ b/test/test_wifi_ota_lifecycle.py @@ -39,6 +39,7 @@ STUBS = r''' constexpr int WL_CONNECTED = 3, WL_DISCONNECTED = 6, HTTP_GET = 0; constexpr int WIFI_AP = 2; constexpr int ESP_OK = 0; +constexpr int LISTEN = 10; struct IPAddress { uint32_t value = 0; @@ -102,14 +103,19 @@ struct AsyncWebServerRequest { template void send(Args...) {} }; struct AsyncWebServer { + bool listening = false; explicit AsyncWebServer(int port) { assert(port == 80); ++server_count; } template void on(const char*, int, Handler) {} - void begin() { ++server_begin_calls; } + void begin() { ++server_begin_calls; listening = true; } + void end() { listening = false; } + int state() const { return listening ? LISTEN : 0; } }; struct MockElegantOTA { void setID(const char*) {} void begin(AsyncWebServer* server) { assert(server != nullptr); } + bool setEnabled(bool) { return true; } } AsyncElegantOTA; +static AsyncWebServer* async_ota_host = nullptr; struct MockSPIFFS {} SPIFFS; class ESP32Board {