From 4785d20b26ed62c7403f6a5b9d7ec726f4fce744 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Wed, 30 Sep 2026 23:23:32 -0400 Subject: [PATCH 01/24] fix: Cast pcb pointers through uintptr_t when logging A direct cast to uint32_t does not compile where pointers are 64 bits wide. Co-Authored-By: Claude Opus 5.5 --- src/AsyncTCP.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AsyncTCP.cpp b/src/AsyncTCP.cpp index 2c9cfd45..576c8f8c 100644 --- a/src/AsyncTCP.cpp +++ b/src/AsyncTCP.cpp @@ -1611,7 +1611,7 @@ int8_t AsyncClientImpl::_poll(tcp_pcb *pcb) { } if (pcb != _pcb) { - async_tcp_log_d("0x%08" PRIx32 " != 0x%08" PRIx32, (uint32_t)pcb, (uint32_t)_pcb); + async_tcp_log_d("0x%08" PRIx32 " != 0x%08" PRIx32, (uint32_t)(uintptr_t)pcb, (uint32_t)(uintptr_t)_pcb); return ERR_OK; } From 85c8c89a48271eaa3746d09241fedff9f36b30d7 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Wed, 30 Sep 2026 23:28:29 -0400 Subject: [PATCH 02/24] test: Add a host-native test harness Builds the library for the host against stand-ins for lwIP, FreeRTOS and the Arduino core, and runs Unity tests against it with `pio test -e native`. The lwIP mock plays the stack: it records every call, accounts for pcbs, pbufs and ports, and invokes the library's callbacks as lwIP would. The FreeRTOS mock runs the library's async task on the test's own thread, so every test is deterministic. Each test runs in a forked child. [env] becomes [target], so that the host environment does not inherit a board and a framework. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 + platformio.ini | 53 +- test/test_native/README.md | 261 ++++++++ test/test_native/asynctcp.cpp | 2 + test/test_native/fixtures.h | 142 ++++ test/test_native/mocks/include/Arduino.h | 48 ++ test/test_native/mocks/include/IPAddress.h | 26 + test/test_native/mocks/include/IPv6Address.h | 28 + .../test_native/mocks/include/esp32-hal-log.h | 5 + test/test_native/mocks/include/esp_err.h | 12 + .../mocks/include/esp_idf_version.h | 8 + test/test_native/mocks/include/esp_task_wdt.h | 20 + .../mocks/include/freertos/FreeRTOS.h | 24 + .../mocks/include/freertos/semphr.h | 25 + .../test_native/mocks/include/freertos/task.h | 32 + test/test_native/mocks/include/lwip/arch.h | 12 + test/test_native/mocks/include/lwip/dns.h | 21 + test/test_native/mocks/include/lwip/err.h | 27 + test/test_native/mocks/include/lwip/inet.h | 11 + .../test_native/mocks/include/lwip/ip4_addr.h | 27 + .../test_native/mocks/include/lwip/ip6_addr.h | 27 + test/test_native/mocks/include/lwip/ip_addr.h | 64 ++ test/test_native/mocks/include/lwip/opt.h | 13 + test/test_native/mocks/include/lwip/pbuf.h | 42 ++ .../mocks/include/lwip/priv/tcpip_priv.h | 27 + test/test_native/mocks/include/lwip/tcp.h | 112 ++++ test/test_native/mocks/include/lwip/tcpip.h | 28 + test/test_native/mocks/include/sdkconfig.h | 8 + test/test_native/mocks/mock_lwip.cpp | 629 ++++++++++++++++++ test/test_native/mocks/mock_lwip.h | 109 +++ test/test_native/mocks/mock_rtos.cpp | 229 +++++++ test/test_native/mocks/mock_rtos.h | 56 ++ test/test_native/runner.h | 18 + test/test_native/test_main.cpp | 300 +++++++++ test/test_native/tests/test_client.cpp | 85 +++ test/test_native/unity_config.h | 25 + 36 files changed, 2561 insertions(+), 5 deletions(-) create mode 100644 test/test_native/README.md create mode 100644 test/test_native/asynctcp.cpp create mode 100644 test/test_native/fixtures.h create mode 100644 test/test_native/mocks/include/Arduino.h create mode 100644 test/test_native/mocks/include/IPAddress.h create mode 100644 test/test_native/mocks/include/IPv6Address.h create mode 100644 test/test_native/mocks/include/esp32-hal-log.h create mode 100644 test/test_native/mocks/include/esp_err.h create mode 100644 test/test_native/mocks/include/esp_idf_version.h create mode 100644 test/test_native/mocks/include/esp_task_wdt.h create mode 100644 test/test_native/mocks/include/freertos/FreeRTOS.h create mode 100644 test/test_native/mocks/include/freertos/semphr.h create mode 100644 test/test_native/mocks/include/freertos/task.h create mode 100644 test/test_native/mocks/include/lwip/arch.h create mode 100644 test/test_native/mocks/include/lwip/dns.h create mode 100644 test/test_native/mocks/include/lwip/err.h create mode 100644 test/test_native/mocks/include/lwip/inet.h create mode 100644 test/test_native/mocks/include/lwip/ip4_addr.h create mode 100644 test/test_native/mocks/include/lwip/ip6_addr.h create mode 100644 test/test_native/mocks/include/lwip/ip_addr.h create mode 100644 test/test_native/mocks/include/lwip/opt.h create mode 100644 test/test_native/mocks/include/lwip/pbuf.h create mode 100644 test/test_native/mocks/include/lwip/priv/tcpip_priv.h create mode 100644 test/test_native/mocks/include/lwip/tcp.h create mode 100644 test/test_native/mocks/include/lwip/tcpip.h create mode 100644 test/test_native/mocks/include/sdkconfig.h create mode 100644 test/test_native/mocks/mock_lwip.cpp create mode 100644 test/test_native/mocks/mock_lwip.h create mode 100644 test/test_native/mocks/mock_rtos.cpp create mode 100644 test/test_native/mocks/mock_rtos.h create mode 100644 test/test_native/runner.h create mode 100644 test/test_native/test_main.cpp create mode 100644 test/test_native/tests/test_client.cpp create mode 100644 test/test_native/unity_config.h diff --git a/README.md b/README.md index edda6266..73c889e0 100644 --- a/README.md +++ b/README.md @@ -53,3 +53,13 @@ I personally use the following configuration in my projects: - ESP32 - Arduino Core 2.x and 3.x + +## Testing + +`test/test_native` runs the library on a desktop, against mock lwIP and FreeRTOS: + +``` +pio test -e native +``` + +See [test/test_native/README.md](test/test_native/README.md) for how to run it and read the results. diff --git a/platformio.ini b/platformio.ini index d3d60174..6d411225 100644 --- a/platformio.ini +++ b/platformio.ini @@ -4,11 +4,15 @@ default_envs = arduino-2, arduino-3, arduino-libretiny ; src_dir = examples/FetchWebsite src_dir = examples/AsyncSend -[env] +; Settings shared by every build that targets real hardware. This cannot be [env], +; because [env] also applies to [env:native], which has no board and no framework. +[target] framework = arduino ; Build the repository as the library it is, so library.json drives the build ; rather than lib_dir treating the repository root as a folder of libraries. lib_deps = symlink://. +; The host suite is for [env:native]; there is nothing to flash to a board. +test_ignore = test_native build_flags = -Wall -Wextra -D CONFIG_ASYNC_TCP_MAX_ACK_TIME=5000 @@ -27,17 +31,20 @@ monitor_filters = esp32_exception_decoder, log2file board = esp32dev [env:arduino-2] +extends = target platform = espressif32@6.12.0 [env:arduino-3] +extends = target platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip [env:arduino-libretiny] +extends = target platform = libretiny board = generic-bk7231n-qfn32-tuya ; board = generic-rtl8710bn-2mb-788k build_flags = - ${env.build_flags} + ${target.build_flags} -Wno-unused-parameter -Wno-unused-variable -Wno-missing-field-initializers @@ -48,34 +55,70 @@ custom_versions.freertos = 9.0.0 ; CI [env:ci-arduino-2] +extends = target platform = espressif32@6.12.0 board = ${sysenv.PIO_BOARD} [env:ci-arduino-3] +extends = target platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip board = ${sysenv.PIO_BOARD} [env:ci-arduino-2-esp-idf_log] +extends = target platform = espressif32@6.12.0 board = ${sysenv.PIO_BOARD} build_flags = - ${env.build_flags} + ${target.build_flags} -D USE_ESP_IDF_LOG=1 -D TAG=\"core\" [env:ci-arduino-3-esp-idf_log] +extends = target platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip board = ${sysenv.PIO_BOARD} build_flags = - ${env.build_flags} + ${target.build_flags} -D USE_ESP_IDF_LOG=1 [env:ci-arduino-libretiny] +extends = target platform = libretiny board = ${sysenv.PIO_BOARD} build_flags = - ${env.build_flags} + ${target.build_flags} -Wno-unused-parameter -Wno-unused-variable -Wno-missing-field-initializers custom_versions.freertos = 9.0.0 + +; Host-native tests -- see test/test_native/README.md +; +; pio test -e native + +[env:native] +platform = native +test_framework = unity +; Exceptions must stay on, for C as well as C++: the mock FreeRTOS unwinds out of the +; library's task loop to end a pump, and a failed assertion unwinds out of Unity to end +; the test (see test/test_native/unity_config.h). +; Locals start out filled with 0xfe, as fresh heap memory does (see test_main.cpp), so a +; value read before it was written is the same on every run. +build_flags = + -std=gnu++17 + -g -O0 -fexceptions + -ftrivial-auto-var-init=pattern + -Wall -Wextra -Wno-unused-parameter -Wno-missing-field-initializers -Wno-format + -I src + -I test/test_native/mocks/include + -D ASYNCTCP_NATIVE_TEST=1 + -D ARDUINO=10812 + -D CONFIG_LWIP_TCPIP_CORE_LOCKING=1 + -D CONFIG_ASYNC_TCP_RUNNING_CORE=-1 + -D CONFIG_ASYNC_TCP_USE_WDT=1 + -D CONFIG_ASYNC_TCP_TASK_STACK_SIZE=8192 + -D CONFIG_ASYNC_TCP_TASK_PRIORITY=3 + -D CONFIG_ASYNC_TCP_MAX_ACK_TIME=5000 + -D CONFIG_ASYNC_TCP_PRIORITY=10 + -D CONFIG_ASYNC_TCP_QUEUE_SIZE=64 + -D CONFIG_ASYNC_TCP_STACK_SIZE=8192 diff --git a/test/test_native/README.md b/test/test_native/README.md new file mode 100644 index 00000000..64877d70 --- /dev/null +++ b/test/test_native/README.md @@ -0,0 +1,261 @@ +# Host-native test harness + +Compiles `src/AsyncTCP.cpp` for the host against mock lwIP, FreeRTOS and Arduino +headers, so the library's logic can be exercised on a desktop with no ESP32 and +no network. + +Nothing under `src/` is modified or needs to be. + +## Running + +``` +pio test -e native # the suite +``` + +It reports each test separately and exits non-zero if any fails. + +To work under a debugger, run the built program directly; +`ASYNCTCP_TEST_NOFORK=1` keeps every test in one process: + +``` +ASYNCTCP_TEST_NOFORK=1 gdb .pio/build/native/program +``` + +`ASYNCTCP_TEST_VERBOSE=1` additionally prints the library's `log_*` output. + +Unity's own options select tests; `-h` lists them. Through `pio test`, pass each as one +`-a`, joined by `=`: + +``` +pio test -e native -a -f=test_dns_ # only tests whose name contains test_dns_ +pio test -e native -a -x=congestion # all but those +.pio/build/native/program -f=dns_ -l # list the matches instead of running them +``` + +## Reading the results + +A failing test is a defect in the library, not in the test, and stays red until the +library is fixed: no test is skipped or expected to fail. So a run need not be green. +What counts is the difference: a change must not make a test fail that passes without it. + +To compare two runs, list the tests that failed in each: + +``` +pio test -e native | awk -F': ' '/\[FAILED\]/ { print $2 }' | sort -u +``` + +## Why each test is forked + +`test_main.cpp` runs every test in a child process. A test that segfaults then +costs one red result instead of the rest of the run -- which matters, because the +bugs this suite is aimed at tend to crash rather than to fail an assertion. + +## How a failed assertion ends a test + +`unity_config.h` makes Unity's `TEST_ABORT()` throw, and `test_main.cpp` catches +that around the test, `setUp()` and `tearDown()`. So a failed assertion ends the +test on the spot, as it would with Unity's default `longjmp`, but its destructors +run and `tearDown()` still does. That holds inside a callback run by a pump too. + +Where nothing can be unwound -- a failed assertion in a callback run from a +destructor, which is `noexcept` -- the throw ends in `std::terminate()`. The +forked child concludes the test as failed there; under `ASYNCTCP_TEST_NOFORK=1` +it ends the run. + +## How the async task is pumped + +`AsyncTCP` does its application-callback work on a FreeRTOS task +(`_async_service_task`), started once via `xTaskCreate`. Running that +as a real thread would make every test a race, so the harness does not. + +Instead: + +* the FreeRTOS mock's `xTaskCreate()` **records** the task function + and its parameter and returns a fake handle, without starting anything; +* `asynctcp_test_pump()` calls that recorded function **on the calling + thread**. The task's `for (;;)` body drains the library's event queue exactly + as it would on target; +* when the queue is empty the task calls `ulTaskNotifyTake(pdTRUE, + portMAX_DELAY)` to sleep. The mock notices it is inside a pump and throws a + private `PumpExit` exception, which `asynctcp_test_pump()` catches. + +So `asynctcp_test_pump()` means "run the async task until its queue is empty, +then return". + +This needs no hook in `src/`. It relies on two properties of the task loop, both +true today: + +* no live objects span the `ulTaskNotifyTake()` call (the queue mutex guard + lives and dies inside `_get_async_event()`), so the unwind is safe. It is a + real C++ unwind, so destructors run — unlike a `longjmp`; +* the loop keeps no state across iterations, so re-entering it from the top on + each pump is equivalent to letting it spin. + +The build therefore **must** keep `-fexceptions`, which failed assertions need +too. + +`_async_service_task_handle` is a file-static inside `src/AsyncTCP.cpp` that the +harness cannot reach, so once the library has started its task it stays started +for the life of the process. That is fine (the task is stateless) and is why +`mockrtos::reset()` deliberately does not forget the recorded task function. +Forking sidesteps it anyway: the parent runs no test bodies, so each child starts +from a process in which the library has not yet started anything. + +## Layout + +``` +test/test_native/ + runner.h RUN_TEST, overridden to fork + unity_config.h makes TEST_ABORT() throw + test_main.cpp the runner: per-test reset, forking, Unity reporting + asynctcp.cpp the library under test, compiled into the test program + fixtures.h what most tests share: the peer and ports, establish(), + listen_pcb(), Recorder, Accepted + tests/ one file per topic; add files here, they are picked up + test_client.cpp end-to-end smoke tests, one per main path + mocks/ + include/ fake system headers -- on the include path FIRST, so + #include "lwip/tcp.h" etc. resolve here + Arduino.h IPAddress.h IPv6Address.h sdkconfig.h esp_*.h + freertos/{FreeRTOS,task,semphr}.h + lwip/{arch,opt,err,pbuf,ip_addr,ip4_addr,ip6_addr,inet,tcp,dns,tcpip}.h + lwip/priv/tcpip_priv.h + mock_lwip.h/.cpp mock lwIP stack + its test-facing control API + mock_rtos.h/.cpp FreeRTOS, mock clock, logging, the pump +``` + +`mock_lwip.h` and `mock_rtos.h` are for tests only; the library never sees them. + +## What the mock lwIP models + +Behavior is matched to real lwIP wherever AsyncTCP's lifetime handling depends +on it: + +* `tcp_abort()` frees the pcb and **then** invokes the error callback with + `ERR_ABRT` (except on `LISTEN` pcbs). +* A fatal error (`fire_error`) frees the pcb **before** the error callback runs, + so the pointer the library sees is already dangling. +* `tcp_listen_with_backlog()` allocates a **new** listen pcb and frees the one + passed in. +* A callback handed a pcb must return `ERR_ABRT` if and only if it aborted that pcb, + as `tcp_abort()` in lwIP's `tcp.c` requires. An accept callback returning any + other error has the new pcb aborted, as `tcp_process()` does. +* Ports are tracked, so a second bind to a live port returns `ERR_USE` and a + leaked listen pcb shows up as a leaked port. + +`tcpip_api_call(fn, msg)` calls `fn(msg)` directly on the calling thread. With +`CONFIG_LWIP_TCPIP_CORE_LOCKING` that is what lwIP does, under the core lock. + +Inside `tcpip_api_call()` and the `fire_*` helpers the mock counts as the lwIP +thread. After every test the runner fails it if the library: + +* called `tcpip_api_call()` from the lwIP thread, which would deadlock; +* left the TCPIP core lock held; +* called any `tcp_*` or `dns_*` function neither on the lwIP thread nor under + the core lock, which would race the lwIP thread. +* returned `ERR_ABRT` from a callback for a pcb it had not aborted, or anything + else for one it had, which would leave lwIP using a freed pcb or dropping a live + one. + +## Adding a test + +Add it to the file for its topic, or drop a new `.cpp` in `tests/`; PlatformIO +compiles everything under this directory. The tests are plain Unity, and each +file lists what it holds in one `run_*_tests()`: + +```cpp +#include "fixtures.h" + +using namespace mocklwip; + +static void test_topic_data_reaches_onData(void) { + AsyncClient c; + Recorder t; + t.attach(c, "CEDR"); // onConnect, onError, onDisconnect, onData + tcp_pcb *pcb = establish(c); // connected to kPeer:kPort, onConnect delivered + + fire_recv(pcb, "hi", 2); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("CR", t.seq.c_str()); + TEST_ASSERT_EQUAL_STRING("hi", t.data.c_str()); + c.close(); +} + +void run_topic_tests(void) { + UnitySetTestFile(__FILE__); + RUN_TEST(test_topic_data_reaches_onData); +} +``` + +Include `fixtures.h`, or at least `runner.h`, rather than ``: `runner.h` +is what redirects `RUN_TEST` into the forking runner. A new file's +`run_*_tests()` has to be declared and called in `test_main.cpp`. + +`fixtures.h` holds what most tests start with: + +* `kPeer`, `kPort`, `kServerPort`: where clients dial and where servers + listen; +* `establish(c)`: connects `c` and delivers the handshake, returning its pcb; +* `listen_pcb(port)`: the listening pcb, on `port` or on any port; +* `Recorder`: records a client's callbacks as a string, one letter each, so + order, count and exactly-once are one assertion; +* `Accepted`: takes delivery of a server's clients and deletes whatever is left + of them at scope exit. + +`test_main.cpp` calls `mocklwip::reset()`, `mockrtos::reset()` and +`mockclock::reset()` before every test, and `mocklwip::reset()` again after, so +each test starts from zero pcbs, zero pbufs, no faults and `millis() == 1000`. A +test about `millis() == 0` sets it with `mockclock::set_millis(0)`. + +**Destroy your `AsyncClient`/`AsyncServer` before the test returns** if you want +leak assertions to mean anything — `reset()` reclaims whatever is left, but a +client that outlives the reset would hold a dangling pcb pointer. + +### Driving a new lwIP callback + +The `mocklwip::fire_*` functions play the part of the lwIP thread: they call +straight into the callback the library registered on the pcb. Nothing reaches +the application until you then call `asynctcp_test_pump()`. That two-step is the +whole point — it is where the queue-handling bugs live. + +```cpp +fire_connected(pcb, ERR_OK); // tcp_connected_fn; moves pcb to ESTABLISHED +fire_recv(pcb, "data", 4); // tcp_recv_fn with a pbuf the mock allocates +fire_fin(pcb); // tcp_recv_fn with a NULL pbuf +fire_sent(pcb, 4); // tcp_sent_fn +fire_error(pcb, ERR_RST); // frees pcb, then tcp_err_fn -- pcb is dead after +fire_accept(listen_pcb); // builds a new ESTABLISHED pcb, offers it +fire_accept(listen_pcb, peer, 41234); // ... from the peer at that ip_addr_t and port +asynctcp_test_pump(); // now the application callbacks run +``` + +To add a callback the mock does not fire yet: store the callback pointer on +`struct tcp_pcb` in `mocks/include/lwip/tcp.h`, set it in the corresponding +`tcp_*` setter in `mock_lwip.cpp`, and add a `fire_*` that checks `is_live(pcb)` +and then calls it with `pcb->callback_arg`. + +### Asserting + +```cpp +live_pcbs() // outstanding pcbs -- the leak check +live_pbufs() +is_live(pcb) +recved(pcb) // total bytes handed to tcp_recved(): the window reopened +written(pcb) // everything handed to tcp_write(), concatenated +``` + +## Caveats + +* The harness is single-threaded by construction, so it cannot find races. It + finds lifetime, ordering and leak bugs. +* `reset()` cannot un-start the library's async task or free its queue mutex; + those are process-lifetime singletons in `src/AsyncTCP.cpp`. Under the default + fork-per-test that costs nothing; under `ASYNCTCP_TEST_NOFORK=1` they persist + from one test to the next. +* The mocks cover the API surface `src/AsyncTCP.cpp` actually uses. A change that + reaches for more of lwIP will need them extended to match. +* Forking is POSIX-only; elsewhere the tests run in one process and a crash ends + the run. +* Memory the library reads before writing holds 0xfe bytes, on the stack and on + the heap, so a failure that depends on it reads the same on every run. The + heap fill needs glibc. diff --git a/test/test_native/asynctcp.cpp b/test/test_native/asynctcp.cpp new file mode 100644 index 00000000..c556e75b --- /dev/null +++ b/test/test_native/asynctcp.cpp @@ -0,0 +1,2 @@ +// The library under test, compiled into the test program. +#include "AsyncTCP.cpp" diff --git a/test/test_native/fixtures.h b/test/test_native/fixtures.h new file mode 100644 index 00000000..1d02c6b7 --- /dev/null +++ b/test/test_native/fixtures.h @@ -0,0 +1,142 @@ +// Shared test fixtures: the addresses the tests dial and listen on, and the steps most +// tests start with. +#ifndef ASYNCTCP_TEST_FIXTURES_H +#define ASYNCTCP_TEST_FIXTURES_H + +#include "runner.h" + +#include +#include +#include +#include + +#include "AsyncTCP.h" +#include "mocks/mock_lwip.h" +#include "mocks/mock_rtos.h" + +extern "C" { +#include "lwip/tcp.h" +} + +const IPAddress kPeer(10, 0, 0, 1); // where clients dial +const uint16_t kPort = 8080; // ... and on what port +const uint16_t kServerPort = 8081; // where servers listen + +// Connects to kPeer and completes the handshake. Returns the client's pcb. +inline tcp_pcb *establish(AsyncClient &c) { + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + tcp_pcb *pcb = mocklwip::dialled_pcb(); + TEST_ASSERT_NOT_NULL(pcb); + TEST_ASSERT_EQUAL_INT((int)ERR_OK, (int)mocklwip::fire_connected(pcb)); + asynctcp_test_pump(); + TEST_ASSERT_TRUE(c.connected()); + return pcb; +} + +// The listening pcb on , or on any port when it is 0. nullptr if there is none. +inline tcp_pcb *listen_pcb(uint16_t port = 0) { + for (tcp_pcb *p : mocklwip::pcbs()) { + if (p->state == LISTEN && (port == 0 || p->local_port == port)) { + return p; + } + } + return nullptr; +} + +// Records a client's callbacks as one letter each, in the order they run, so that order, +// count and exactly-once are one assertion: "ED" is onError then onDisconnect, "" is +// silence. +// +// C onConnect E onError D onDisconnect R onData +// A onAck +// +// attach() registers only the callbacks it is given -- by default the three that open and +// end a connection -- because registering a handler can change what the library does. +class Recorder { +public: + std::string seq; + int8_t err = 0; // what the last onError was given + std::string data; // everything onData was given, concatenated + size_t acked = 0; // what the last onAck was given + + Recorder() = default; + Recorder(const Recorder &) = delete; + Recorder &operator=(const Recorder &) = delete; + + void attach(AsyncClient &c, const char *events = "CED") { + if (strchr(events, 'C')) { + c.onConnect([this](void *, AsyncClient *cl) { + note('C', cl); + }); + } + if (strchr(events, 'E')) { + c.onError([this](void *, AsyncClient *cl, int8_t e) { + note('E', cl, e); + err = e; + }); + } + if (strchr(events, 'D')) { + c.onDisconnect([this](void *, AsyncClient *cl) { + note('D', cl); + }); + } + if (strchr(events, 'R')) { + c.onData([this](void *, AsyncClient *cl, void *d, size_t n) { + note('R', cl); + data.append((const char *)d, n); + }); + } + if (strchr(events, 'A')) { + c.onAck([this](void *, AsyncClient *cl, size_t n, uint32_t t) { + note('A', cl); + acked = n; + }); + } + } + + // Records for client , as the handlers attach() registers do. + void note(char event, const AsyncClient *c, int8_t e = 0) { + seq += event; + } +}; + +// Takes delivery of a server's clients, in order, and deletes whatever is left of them when +// it goes out of scope. runs on each client as it is delivered. +class Accepted { +public: + explicit Accepted(AsyncServer &s, std::function setup = nullptr) { + s.onClient( + [this, setup](void *, AsyncClient *c) { + _clients.push_back(c); + if (setup) { + setup(c); + } + }, + nullptr + ); + } + ~Accepted() { + clear(); + } + Accepted(const Accepted &) = delete; + Accepted &operator=(const Accepted &) = delete; + + size_t size() const { + return _clients.size(); + } + AsyncClient *operator[](size_t i) const { + return _clients.at(i); + } + void clear() { + std::vector doomed; + doomed.swap(_clients); + for (AsyncClient *c : doomed) { + delete c; + } + } + +private: + std::vector _clients; +}; + +#endif diff --git a/test/test_native/mocks/include/Arduino.h b/test/test_native/mocks/include/Arduino.h new file mode 100644 index 00000000..cbd07d2c --- /dev/null +++ b/test/test_native/mocks/include/Arduino.h @@ -0,0 +1,48 @@ +// Host-native mock of Arduino.h. +// +// Provides just enough of the Arduino/ESP32 core for AsyncTCP: the mock clock +// (millis/micros), the arduino-esp32 log_* macros, and the usual pull-ins. +#ifndef MOCK_ARDUINO_H +#define MOCK_ARDUINO_H + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "IPAddress.h" +#include "esp_err.h" +#include "esp_idf_version.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "sdkconfig.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Mock clock. Advance it from tests via mockclock::advance(). +unsigned long millis(void); +unsigned long micros(void); + +// arduino-esp32 logging. Output is off by default; enable with +// ASYNCTCP_TEST_VERBOSE=1 in the environment. +void asynctcp_mock_log(char level, const char *file, int line, const char *fmt, ...); + +#ifdef __cplusplus +} +#endif + +#define log_e(fmt, ...) asynctcp_mock_log('E', __FILE__, __LINE__, fmt, ##__VA_ARGS__) +#define log_w(fmt, ...) asynctcp_mock_log('W', __FILE__, __LINE__, fmt, ##__VA_ARGS__) +#define log_d(fmt, ...) asynctcp_mock_log('D', __FILE__, __LINE__, fmt, ##__VA_ARGS__) +#define log_v(fmt, ...) asynctcp_mock_log('V', __FILE__, __LINE__, fmt, ##__VA_ARGS__) + +#endif diff --git a/test/test_native/mocks/include/IPAddress.h b/test/test_native/mocks/include/IPAddress.h new file mode 100644 index 00000000..42c208c0 --- /dev/null +++ b/test/test_native/mocks/include/IPAddress.h @@ -0,0 +1,26 @@ +// Host-native mock of the Arduino/ESP32 IPAddress class. +// Header-only; only the surface AsyncTCP uses is provided. +#ifndef MOCK_IPADDRESS_H +#define MOCK_IPADDRESS_H + +#include + +class IPAddress { +public: + IPAddress() : _addr(0) {} + IPAddress(uint32_t address) : _addr(address) {} + IPAddress(uint8_t a, uint8_t b, uint8_t c, uint8_t d) : _addr(((uint32_t)a << 24) | ((uint32_t)b << 16) | ((uint32_t)c << 8) | (uint32_t)d) {} + + operator uint32_t() const { + return _addr; + } + + bool operator==(const IPAddress &other) const { + return _addr == other._addr; + } + +private: + uint32_t _addr; +}; + +#endif diff --git a/test/test_native/mocks/include/IPv6Address.h b/test/test_native/mocks/include/IPv6Address.h new file mode 100644 index 00000000..3ffd8cbd --- /dev/null +++ b/test/test_native/mocks/include/IPv6Address.h @@ -0,0 +1,28 @@ +// Host-native mock of the arduino-esp32 IPv6Address class (pre-IDF5 API). +#ifndef MOCK_IPV6ADDRESS_H +#define MOCK_IPV6ADDRESS_H + +#include +#include + +class IPv6Address { +public: + IPv6Address() { + memset(_addr, 0, sizeof(_addr)); + } + IPv6Address(const uint32_t *address) { + memcpy(_addr, address, sizeof(_addr)); + } + + operator const uint32_t *() const { + return (const uint32_t *)_addr; + } + bool operator==(const IPv6Address &other) const { + return memcmp(_addr, other._addr, sizeof(_addr)) == 0; + } + +private: + uint8_t _addr[16]; +}; + +#endif diff --git a/test/test_native/mocks/include/esp32-hal-log.h b/test/test_native/mocks/include/esp32-hal-log.h new file mode 100644 index 00000000..5c3c942a --- /dev/null +++ b/test/test_native/mocks/include/esp32-hal-log.h @@ -0,0 +1,5 @@ +// Host-native mock of esp32-hal-log.h. The log_* macros live in Arduino.h. +#ifndef MOCK_ESP32_HAL_LOG_H +#define MOCK_ESP32_HAL_LOG_H +#include "Arduino.h" +#endif diff --git a/test/test_native/mocks/include/esp_err.h b/test/test_native/mocks/include/esp_err.h new file mode 100644 index 00000000..5bd97063 --- /dev/null +++ b/test/test_native/mocks/include/esp_err.h @@ -0,0 +1,12 @@ +// Host-native mock of esp_err.h +#ifndef MOCK_ESP_ERR_H +#define MOCK_ESP_ERR_H + +#include + +typedef int esp_err_t; + +#define ESP_OK 0 +#define ESP_FAIL -1 + +#endif diff --git a/test/test_native/mocks/include/esp_idf_version.h b/test/test_native/mocks/include/esp_idf_version.h new file mode 100644 index 00000000..a0bd604a --- /dev/null +++ b/test/test_native/mocks/include/esp_idf_version.h @@ -0,0 +1,8 @@ +// Host-native mock of esp_idf_version.h. +// Reported as "not IDF 5" so the library takes its pre-5 / portable branches. +#ifndef MOCK_ESP_IDF_VERSION_H +#define MOCK_ESP_IDF_VERSION_H + +#define ESP_IDF_VERSION_MAJOR 4 + +#endif diff --git a/test/test_native/mocks/include/esp_task_wdt.h b/test/test_native/mocks/include/esp_task_wdt.h new file mode 100644 index 00000000..96298080 --- /dev/null +++ b/test/test_native/mocks/include/esp_task_wdt.h @@ -0,0 +1,20 @@ +// Host-native mock of esp_task_wdt.h. Always succeeds. +#ifndef MOCK_ESP_TASK_WDT_H +#define MOCK_ESP_TASK_WDT_H + +#include "esp_err.h" +#include "freertos/task.h" + +#ifdef __cplusplus +extern "C" { +#endif + +esp_err_t esp_task_wdt_add(TaskHandle_t handle); +esp_err_t esp_task_wdt_delete(TaskHandle_t handle); +esp_err_t esp_task_wdt_reset(void); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/test/test_native/mocks/include/freertos/FreeRTOS.h b/test/test_native/mocks/include/freertos/FreeRTOS.h new file mode 100644 index 00000000..d7a7dd23 --- /dev/null +++ b/test/test_native/mocks/include/freertos/FreeRTOS.h @@ -0,0 +1,24 @@ +// Host-native mock of freertos/FreeRTOS.h +// +// Must stay C-compatible: AsyncTCP.h pulls freertos/semphr.h in from inside an +// extern "C" block. +#ifndef MOCK_FREERTOS_H +#define MOCK_FREERTOS_H + +#include +#include + +typedef long BaseType_t; +typedef unsigned long UBaseType_t; +typedef uint32_t TickType_t; +typedef size_t configSTACK_DEPTH_TYPE; + +#define pdFALSE ((BaseType_t)0) +#define pdTRUE ((BaseType_t)1) +#define pdPASS pdTRUE + +#define portMAX_DELAY ((TickType_t)0xffffffffUL) + +#define pdMS_TO_TICKS(xTimeInMs) ((TickType_t)(xTimeInMs)) + +#endif diff --git a/test/test_native/mocks/include/freertos/semphr.h b/test/test_native/mocks/include/freertos/semphr.h new file mode 100644 index 00000000..3991b82e --- /dev/null +++ b/test/test_native/mocks/include/freertos/semphr.h @@ -0,0 +1,25 @@ +// Host-native mock of freertos/semphr.h. Stays C-compatible. +#ifndef MOCK_FREERTOS_SEMPHR_H +#define MOCK_FREERTOS_SEMPHR_H + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef void *SemaphoreHandle_t; + +// Single-threaded, so these never block. Taking a non-recursive mutex that is +// already held is recorded as a would-be deadlock: see mockrtos::deadlocks(). +SemaphoreHandle_t xSemaphoreCreateMutex(void); + +BaseType_t xSemaphoreTake(SemaphoreHandle_t xSemaphore, TickType_t xTicksToWait); +BaseType_t xSemaphoreGive(SemaphoreHandle_t xSemaphore); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/test/test_native/mocks/include/freertos/task.h b/test/test_native/mocks/include/freertos/task.h new file mode 100644 index 00000000..066ada23 --- /dev/null +++ b/test/test_native/mocks/include/freertos/task.h @@ -0,0 +1,32 @@ +// Host-native mock of freertos/task.h. Stays C-compatible. +#ifndef MOCK_FREERTOS_TASK_H +#define MOCK_FREERTOS_TASK_H + +#include "freertos/FreeRTOS.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef void *TaskHandle_t; +typedef void (*TaskFunction_t)(void *); + +// The mock records the task function instead of running it; see +// mock_rtos.h / asynctcp_test_pump(). +BaseType_t xTaskCreate( + TaskFunction_t pxTaskCode, const char *pcName, const configSTACK_DEPTH_TYPE usStackDepth, void *pvParameters, UBaseType_t uxPriority, + TaskHandle_t *pxCreatedTask +); + +void vTaskDelete(TaskHandle_t xTaskToDelete); + +BaseType_t xTaskNotifyGive(TaskHandle_t xTaskToNotify); +uint32_t ulTaskNotifyTake(BaseType_t xClearCountOnExit, TickType_t xTicksToWait); + +BaseType_t xPortGetCoreID(void); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/test/test_native/mocks/include/lwip/arch.h b/test/test_native/mocks/include/lwip/arch.h new file mode 100644 index 00000000..8fee9b90 --- /dev/null +++ b/test/test_native/mocks/include/lwip/arch.h @@ -0,0 +1,12 @@ +// Host-native mock of lwip/arch.h -- see test/test_native/README.md +#ifndef MOCK_LWIP_ARCH_H +#define MOCK_LWIP_ARCH_H + +#include + +typedef uint8_t u8_t; +typedef int8_t s8_t; +typedef uint16_t u16_t; +typedef uint32_t u32_t; + +#endif diff --git a/test/test_native/mocks/include/lwip/dns.h b/test/test_native/mocks/include/lwip/dns.h new file mode 100644 index 00000000..5942e473 --- /dev/null +++ b/test/test_native/mocks/include/lwip/dns.h @@ -0,0 +1,21 @@ +// Host-native mock of lwip/dns.h +#ifndef MOCK_LWIP_DNS_H +#define MOCK_LWIP_DNS_H + +#include "lwip/arch.h" +#include "lwip/err.h" +#include "lwip/ip_addr.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef void (*dns_found_callback)(const char *name, const ip_addr_t *ipaddr, void *callback_arg); + +err_t dns_gethostbyname(const char *hostname, ip_addr_t *addr, dns_found_callback found, void *callback_arg); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/test/test_native/mocks/include/lwip/err.h b/test/test_native/mocks/include/lwip/err.h new file mode 100644 index 00000000..08860972 --- /dev/null +++ b/test/test_native/mocks/include/lwip/err.h @@ -0,0 +1,27 @@ +// Host-native mock of lwip/err.h +#ifndef MOCK_LWIP_ERR_H +#define MOCK_LWIP_ERR_H + +#include "lwip/arch.h" + +typedef s8_t err_t; + +#define ERR_OK 0 +#define ERR_MEM -1 +#define ERR_BUF -2 +#define ERR_TIMEOUT -3 +#define ERR_RTE -4 +#define ERR_INPROGRESS -5 +#define ERR_VAL -6 +#define ERR_WOULDBLOCK -7 +#define ERR_USE -8 +#define ERR_ALREADY -9 +#define ERR_ISCONN -10 +#define ERR_CONN -11 +#define ERR_IF -12 +#define ERR_ABRT -13 +#define ERR_RST -14 +#define ERR_CLSD -15 +#define ERR_ARG -16 + +#endif diff --git a/test/test_native/mocks/include/lwip/inet.h b/test/test_native/mocks/include/lwip/inet.h new file mode 100644 index 00000000..6974d760 --- /dev/null +++ b/test/test_native/mocks/include/lwip/inet.h @@ -0,0 +1,11 @@ +// Host-native mock of lwip/inet.h +#ifndef MOCK_LWIP_INET_H +#define MOCK_LWIP_INET_H + +#include "lwip/ip_addr.h" + +#ifndef INADDR_ANY +#define INADDR_ANY IPADDR_ANY +#endif + +#endif diff --git a/test/test_native/mocks/include/lwip/ip4_addr.h b/test/test_native/mocks/include/lwip/ip4_addr.h new file mode 100644 index 00000000..58f63561 --- /dev/null +++ b/test/test_native/mocks/include/lwip/ip4_addr.h @@ -0,0 +1,27 @@ +// Host-native mock of lwip/ip4_addr.h +#ifndef MOCK_LWIP_IP4_ADDR_H +#define MOCK_LWIP_IP4_ADDR_H + +#include "lwip/arch.h" + +#ifdef __cplusplus +extern "C" { +#endif + +struct ip4_addr { + u32_t addr; // network byte order on target; host order here (see README) +}; +typedef struct ip4_addr ip4_addr_t; + +#define IPADDR_ANY ((u32_t)0x00000000UL) + +#define ip4_addr_set_zero(ipaddr) ((ipaddr)->addr = 0) +#define ip4_addr_get_u32(src_ipaddr) ((src_ipaddr)->addr) +#define ip4_addr_set_u32(dest_ipaddr, src_u32) ((dest_ipaddr)->addr = (src_u32)) +#define ip4_addr_isany_val(addr1) ((addr1).addr == IPADDR_ANY) + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/test/test_native/mocks/include/lwip/ip6_addr.h b/test/test_native/mocks/include/lwip/ip6_addr.h new file mode 100644 index 00000000..d040e5fb --- /dev/null +++ b/test/test_native/mocks/include/lwip/ip6_addr.h @@ -0,0 +1,27 @@ +// Host-native mock of lwip/ip6_addr.h +#ifndef MOCK_LWIP_IP6_ADDR_H +#define MOCK_LWIP_IP6_ADDR_H + +#include "lwip/arch.h" +#include "lwip/opt.h" + +#ifdef __cplusplus +extern "C" { +#endif + +struct ip6_addr { + u32_t addr[4]; +}; +typedef struct ip6_addr ip6_addr_t; + +#define ip6_addr_set_zero(ip6addr) \ + do { \ + (ip6addr)->addr[0] = (ip6addr)->addr[1] = (ip6addr)->addr[2] = (ip6addr)->addr[3] = 0; \ + } while (0) +#define ip6_addr_isany_val(ip6addr) (((ip6addr).addr[0] == 0) && ((ip6addr).addr[1] == 0) && ((ip6addr).addr[2] == 0) && ((ip6addr).addr[3] == 0)) + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/test/test_native/mocks/include/lwip/ip_addr.h b/test/test_native/mocks/include/lwip/ip_addr.h new file mode 100644 index 00000000..f589b53a --- /dev/null +++ b/test/test_native/mocks/include/lwip/ip_addr.h @@ -0,0 +1,64 @@ +// Host-native mock of lwip/ip_addr.h. +// As in lwIP built with both IPv4 and IPv6, ip_addr_t is a tagged union of the +// two families. +#ifndef MOCK_LWIP_IP_ADDR_H +#define MOCK_LWIP_IP_ADDR_H + +#include "lwip/arch.h" +#include "lwip/ip4_addr.h" +#include "lwip/ip6_addr.h" +#include "lwip/opt.h" + +#ifdef __cplusplus +extern "C" { +#endif + +enum lwip_ip_addr_type { + IPADDR_TYPE_V4 = 0U, + IPADDR_TYPE_V6 = 6U, + IPADDR_TYPE_ANY = 46U +}; + +typedef struct ip_addr { + union { + ip6_addr_t ip6; + ip4_addr_t ip4; + } u_addr; + u8_t type; +} ip_addr_t; + +#define IP_SET_TYPE_VAL(ipaddr, iptype) ((ipaddr).type = (iptype)) +#define IP_SET_TYPE(ipaddr, iptype) \ + do { \ + if ((ipaddr) != NULL) { \ + IP_SET_TYPE_VAL(*(ipaddr), iptype); \ + } \ + } while (0) + +#define ip_2_ip4(ipaddr) (&((ipaddr)->u_addr.ip4)) +#define ip_addr_get_ip4_u32(ipaddr) ((ipaddr)->u_addr.ip4.addr) +#define ip_addr_set_ip4_u32(ipaddr, val) \ + do { \ + if (ipaddr) { \ + ip4_addr_set_u32(ip_2_ip4(ipaddr), val); \ + IP_SET_TYPE(ipaddr, IPADDR_TYPE_V4); \ + } \ + } while (0) +#define ip_addr_set_ip4_u32_val(ipaddr, val) \ + do { \ + ip4_addr_set_u32(ip_2_ip4(&(ipaddr)), val); \ + IP_SET_TYPE_VAL(ipaddr, IPADDR_TYPE_V4); \ + } while (0) + +#define ip_addr_isany_val(ipaddr) (((ipaddr).type == IPADDR_TYPE_V6) ? ip6_addr_isany_val((ipaddr).u_addr.ip6) : ip4_addr_isany_val((ipaddr).u_addr.ip4)) + +#define IPADDR4_INIT(u32val) \ + { {{{u32val, 0, 0, 0}}}, IPADDR_TYPE_V4 } +#define IPADDR6_INIT(a, b, c, d) \ + { {{{a, b, c, d}}}, IPADDR_TYPE_V6 } + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/test/test_native/mocks/include/lwip/opt.h b/test/test_native/mocks/include/lwip/opt.h new file mode 100644 index 00000000..61c38904 --- /dev/null +++ b/test/test_native/mocks/include/lwip/opt.h @@ -0,0 +1,13 @@ +// Host-native mock of lwip/opt.h +#ifndef MOCK_LWIP_OPT_H +#define MOCK_LWIP_OPT_H + +#include "lwip/arch.h" + +#define LWIP_IPV4 1 +#define LWIP_IPV6 1 +#define TCP_MSS 1436 +#define TCP_SND_BUF (4 * TCP_MSS) +#define LWIP_TCPIP_CORE_LOCKING 1 + +#endif diff --git a/test/test_native/mocks/include/lwip/pbuf.h b/test/test_native/mocks/include/lwip/pbuf.h new file mode 100644 index 00000000..16afeab9 --- /dev/null +++ b/test/test_native/mocks/include/lwip/pbuf.h @@ -0,0 +1,42 @@ +// Host-native mock of lwip/pbuf.h +#ifndef MOCK_LWIP_PBUF_H +#define MOCK_LWIP_PBUF_H + +#include "lwip/arch.h" +#include "lwip/err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum { + PBUF_TRANSPORT, + PBUF_IP, + PBUF_LINK, + PBUF_RAW_TX, + PBUF_RAW +} pbuf_layer; + +typedef enum { + PBUF_RAM = 0x220, + PBUF_ROM = 0x001, + PBUF_REF = 0x041, + PBUF_POOL = 0x120 +} pbuf_type; + +struct pbuf { + struct pbuf *next; + void *payload; + u16_t tot_len; + u16_t len; + u8_t ref; +}; + +struct pbuf *pbuf_alloc(pbuf_layer l, u16_t length, pbuf_type type); +u8_t pbuf_free(struct pbuf *p); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/test/test_native/mocks/include/lwip/priv/tcpip_priv.h b/test/test_native/mocks/include/lwip/priv/tcpip_priv.h new file mode 100644 index 00000000..de8360a5 --- /dev/null +++ b/test/test_native/mocks/include/lwip/priv/tcpip_priv.h @@ -0,0 +1,27 @@ +// Host-native mock of lwip/priv/tcpip_priv.h. +// +// tcpip_api_call() invokes the callback on the calling thread, which keeps the +// library deterministic and single-threaded under test. +#ifndef MOCK_LWIP_TCPIP_PRIV_H +#define MOCK_LWIP_TCPIP_PRIV_H + +#include "lwip/err.h" +#include "lwip/tcpip.h" + +#ifdef __cplusplus +extern "C" { +#endif + +struct tcpip_api_call_data { + u8_t dummy; +}; + +typedef err_t (*tcpip_api_call_fn)(struct tcpip_api_call_data *call); + +err_t tcpip_api_call(tcpip_api_call_fn fn, struct tcpip_api_call_data *call); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/test/test_native/mocks/include/lwip/tcp.h b/test/test_native/mocks/include/lwip/tcp.h new file mode 100644 index 00000000..a3894468 --- /dev/null +++ b/test/test_native/mocks/include/lwip/tcp.h @@ -0,0 +1,112 @@ +// Host-native mock of lwip/tcp.h. +// +// Only the parts AsyncTCP touches are modeled. The behavioral contract of the +// mocked functions is documented in test/test_native/mocks/mock_lwip.h. +#ifndef MOCK_LWIP_TCP_H +#define MOCK_LWIP_TCP_H + +#include "lwip/arch.h" +#include "lwip/err.h" +#include "lwip/ip_addr.h" +#include "lwip/opt.h" +#include "lwip/pbuf.h" + +#ifdef __cplusplus +extern "C" { +#endif + +struct tcp_pcb; + +typedef err_t (*tcp_accept_fn)(void *arg, struct tcp_pcb *newpcb, err_t err); +typedef err_t (*tcp_recv_fn)(void *arg, struct tcp_pcb *tpcb, struct pbuf *p, err_t err); +typedef err_t (*tcp_sent_fn)(void *arg, struct tcp_pcb *tpcb, u16_t len); +typedef err_t (*tcp_poll_fn)(void *arg, struct tcp_pcb *tpcb); +typedef void (*tcp_err_fn)(void *arg, err_t err); +typedef err_t (*tcp_connected_fn)(void *arg, struct tcp_pcb *tpcb, err_t err); + +enum tcp_state { + CLOSED = 0, + LISTEN = 1, + SYN_SENT = 2, + SYN_RCVD = 3, + ESTABLISHED = 4, + FIN_WAIT_1 = 5, + FIN_WAIT_2 = 6, + CLOSE_WAIT = 7, + CLOSING = 8, + LAST_ACK = 9, + TIME_WAIT = 10 +}; + +typedef u8_t tcpflags_t; +#define TF_NODELAY 0x40U + +// SOF_* socket options, as used by AsyncClient::setKeepAlive() +#define SOF_REUSEADDR 0x04U +#define SOF_KEEPALIVE 0x08U + +struct tcp_pcb { + ip_addr_t local_ip; + ip_addr_t remote_ip; + u8_t so_options; + + enum tcp_state state; + void *callback_arg; + + u16_t local_port; + u16_t remote_port; + + tcpflags_t flags; + u16_t mss; + u16_t snd_buf; + u16_t snd_queuelen; + + u32_t keep_idle; + u32_t keep_intvl; + u32_t keep_cnt; + + tcp_sent_fn sent; + tcp_recv_fn recv; + tcp_connected_fn connected; + tcp_poll_fn poll; + tcp_err_fn errf; + tcp_accept_fn accept; +}; + +struct tcp_pcb *tcp_new_ip_type(u8_t type); + +void tcp_arg(struct tcp_pcb *pcb, void *arg); +void tcp_recv(struct tcp_pcb *pcb, tcp_recv_fn recv); +void tcp_sent(struct tcp_pcb *pcb, tcp_sent_fn sent); +void tcp_err(struct tcp_pcb *pcb, tcp_err_fn err); +void tcp_accept(struct tcp_pcb *pcb, tcp_accept_fn accept); +void tcp_poll(struct tcp_pcb *pcb, tcp_poll_fn poll, u8_t interval); + +err_t tcp_bind(struct tcp_pcb *pcb, const ip_addr_t *ipaddr, u16_t port); +struct tcp_pcb *tcp_listen_with_backlog(struct tcp_pcb *pcb, u8_t backlog); + +err_t tcp_connect(struct tcp_pcb *pcb, const ip_addr_t *ipaddr, u16_t port, tcp_connected_fn connected); +err_t tcp_close(struct tcp_pcb *pcb); +void tcp_abort(struct tcp_pcb *pcb); + +err_t tcp_write(struct tcp_pcb *pcb, const void *dataptr, u16_t len, u8_t apiflags); +err_t tcp_output(struct tcp_pcb *pcb); +void tcp_recved(struct tcp_pcb *pcb, u16_t len); + +#define TCP_WRITE_FLAG_COPY 0x01 +#define TCP_WRITE_FLAG_MORE 0x02 + +// Real lwIP implements these as macros over pcb fields; keep that shape so tests +// can assert on the pcb directly. +#define tcp_sndbuf(pcb) ((pcb)->snd_buf) +#define tcp_sndqueuelen(pcb) ((pcb)->snd_queuelen) +#define tcp_mss(pcb) ((pcb)->mss) +#define tcp_nagle_disable(pcb) ((pcb)->flags = (tcpflags_t)((pcb)->flags | TF_NODELAY)) +#define tcp_nagle_enable(pcb) ((pcb)->flags = (tcpflags_t)((pcb)->flags & (tcpflags_t)(~TF_NODELAY))) +#define tcp_nagle_disabled(pcb) (((pcb)->flags & TF_NODELAY) != 0) + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/test/test_native/mocks/include/lwip/tcpip.h b/test/test_native/mocks/include/lwip/tcpip.h new file mode 100644 index 00000000..00a822cb --- /dev/null +++ b/test/test_native/mocks/include/lwip/tcpip.h @@ -0,0 +1,28 @@ +// Host-native mock of lwip/tcpip.h +#ifndef MOCK_LWIP_TCPIP_H +#define MOCK_LWIP_TCPIP_H + +#include "lwip/err.h" +#include "lwip/opt.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define LWIP_CORE_LOCK_QUERY_HOLDER 2 + +int sys_thread_tcpip(int type); + +// The mock is single-threaded: the "TCPIP core lock" is a plain recursion +// counter so the library's lock/unlock bookkeeping still balances. +void mock_lock_tcpip_core(void); +void mock_unlock_tcpip_core(void); + +#define LOCK_TCPIP_CORE() mock_lock_tcpip_core() +#define UNLOCK_TCPIP_CORE() mock_unlock_tcpip_core() + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/test/test_native/mocks/include/sdkconfig.h b/test/test_native/mocks/include/sdkconfig.h new file mode 100644 index 00000000..cf6e4e6b --- /dev/null +++ b/test/test_native/mocks/include/sdkconfig.h @@ -0,0 +1,8 @@ +// Host-native mock of sdkconfig.h. +#ifndef MOCK_SDKCONFIG_H +#define MOCK_SDKCONFIG_H + +#define CONFIG_FREERTOS_UNICORE 1 +#define CONFIG_ESP_TASK_WDT_TIMEOUT_S 5 + +#endif diff --git a/test/test_native/mocks/mock_lwip.cpp b/test/test_native/mocks/mock_lwip.cpp new file mode 100644 index 00000000..db622d58 --- /dev/null +++ b/test/test_native/mocks/mock_lwip.cpp @@ -0,0 +1,629 @@ +// Implementation of the mock lwIP stack. +// +// Behavior is modeled on real lwIP wherever it matters for AsyncTCP: +// * tcp_abort() frees the pcb and *then* runs the error callback +// * tcp_close() frees the pcb, and what was written still reaches the peer +// * tcp_listen_with_backlog() frees the old pcb and returns a NEW one +// * a fatal error frees the pcb before the error callback sees it +// * a callback handed a pcb returns ERR_ABRT exactly when it aborted that pcb +// Those are exactly the edges AsyncTCP's lifetime handling gets wrong or right. + +#include "mock_lwip.h" + +#include +#include +#include +#include +#include +#include + +extern "C" { +#include "lwip/inet.h" +#include "lwip/priv/tcpip_priv.h" +#include "lwip/tcpip.h" +} + +namespace { + +std::vector g_calls; +std::set g_pcbs; +std::set g_pbufs; +std::map g_written; +std::set g_ports; +std::map g_pcb_port; // ports this pcb holds +mocklwip::Faults g_faults; +uint16_t g_next_ephemeral = 49152; +int g_core_lock_depth = 0; +int g_on_lwip_thread = 0; // inside tcpip_api_call() or a fire_* helper +unsigned g_reentrant_api_calls = 0; +unsigned g_unlocked_calls = 0; +std::string g_first_unlocked_call; +std::vector g_aborted; // every pcb tcp_abort() freed, in order +unsigned g_return_violations = 0; +std::string g_first_return_violation; + +void rec(const char *fn, const void *pcb = nullptr, long a = 0, long b = 0) { + g_calls.push_back(mocklwip::Call{fn, pcb, a, b}); +} + +// The raw API is only safe on the LwIP thread or under the TCPIP core lock. Only the +// library calls tcp_* and dns_* functions; tests reach the mock through mocklwip. +void check_context(const char *fn) { + if (g_on_lwip_thread > 0 || g_core_lock_depth > 0) { + return; + } + if (g_unlocked_calls++ == 0) { + g_first_unlocked_call = fn; + } +} + +void release_port(const tcp_pcb *pcb) { + auto it = g_pcb_port.find(pcb); + if (it != g_pcb_port.end()) { + g_ports.erase(it->second); + g_pcb_port.erase(it); + } +} + +tcp_pcb *alloc_pcb() { + tcp_pcb *pcb = new tcp_pcb(); + memset(pcb, 0, sizeof(*pcb)); + pcb->state = CLOSED; + pcb->mss = TCP_MSS; + pcb->snd_buf = TCP_SND_BUF; + IP_SET_TYPE_VAL(pcb->local_ip, IPADDR_TYPE_V4); + IP_SET_TYPE_VAL(pcb->remote_ip, IPADDR_TYPE_V4); + g_pcbs.insert(pcb); + g_written.erase(pcb); // left by a closed pcb at the same address + return pcb; +} + +void free_pcb(tcp_pcb *pcb) { + if (!pcb) { + return; + } + release_port(pcb); + g_written.erase(pcb); + g_pcbs.erase(pcb); + delete pcb; +} + +// tcp_abort(): frees the pcb and then reports ERR_ABRT through the error callback (except +// for LISTEN pcbs, which have no error callback path). +void abort_pcb(tcp_pcb *pcb) { + tcp_err_fn errf = (pcb->state == LISTEN) ? nullptr : pcb->errf; + void *arg = pcb->callback_arg; + g_aborted.push_back(pcb); + free_pcb(pcb); + if (errf) { + errf(arg, ERR_ABRT); + } +} + +const char *err_name(err_t err) { + static const char *const names[] = {"ERR_OK", "ERR_MEM", "ERR_BUF", "ERR_TIMEOUT", "ERR_RTE", "ERR_INPROGRESS", + "ERR_VAL", "ERR_WOULDBLOCK", "ERR_USE", "ERR_ALREADY", "ERR_ISCONN", "ERR_CONN", + "ERR_IF", "ERR_ABRT", "ERR_RST", "ERR_CLSD", "ERR_ARG"}; + return (err <= 0 && err >= ERR_ARG) ? names[-err] : "an unknown err_t"; +} + +// A callback handed a pcb tells lwIP by its return whether the pcb survived: ERR_ABRT +// if and only if it called tcp_abort() on it (tcp_abort() in tcp.c). On anything else +// lwIP goes on using the pcb -- tcp_input(), tcp_process(), tcp_slowtmr() -- and on +// ERR_ABRT it never touches it again. Returns whether was aborted since g_aborted +// held entries. +bool check_return(const char *cb, const tcp_pcb *pcb, size_t mark, err_t r) { + const bool aborted = std::find(g_aborted.begin() + mark, g_aborted.end(), pcb) != g_aborted.end(); + if (aborted != (r == ERR_ABRT) && g_return_violations++ == 0) { + g_first_return_violation = std::string(cb) + " callback returned " + err_name(r) + ", pcb " + + (aborted ? "freed (lwIP would use it after free)" : "alive (lwIP would treat it as freed)"); + } + return aborted; +} + +} // namespace + +// =========================================================================== +// pbuf +// =========================================================================== +extern "C" struct pbuf *pbuf_alloc(pbuf_layer, u16_t length, pbuf_type) { + pbuf *p = new pbuf(); + memset(p, 0, sizeof(*p)); + p->payload = length ? ::operator new(length) : nullptr; + if (p->payload) { + memset(p->payload, 0, length); + } + p->len = length; + p->tot_len = length; + p->ref = 1; + g_pbufs.insert(p); + rec("pbuf_alloc", nullptr, length); + return p; +} + +extern "C" u8_t pbuf_free(struct pbuf *p) { + u8_t freed = 0; + while (p) { + if (p->ref > 0) { + p->ref--; + } + if (p->ref != 0) { + break; + } + pbuf *next = p->next; + if (p->payload) { + ::operator delete(p->payload); + } + g_pbufs.erase(p); + delete p; + freed++; + p = next; + } + return freed; +} + +// =========================================================================== +// tcp +// =========================================================================== +extern "C" struct tcp_pcb *tcp_new_ip_type(u8_t type) { + check_context("tcp_new_ip_type"); + tcp_pcb *pcb = alloc_pcb(); + IP_SET_TYPE_VAL(pcb->local_ip, type); + IP_SET_TYPE_VAL(pcb->remote_ip, type); + rec("tcp_new_ip_type", pcb, type); + return pcb; +} + +extern "C" void tcp_arg(struct tcp_pcb *pcb, void *arg) { + check_context("tcp_arg"); + rec("tcp_arg", pcb, (long)(intptr_t)arg); + if (pcb) { + pcb->callback_arg = arg; + } +} + +extern "C" void tcp_recv(struct tcp_pcb *pcb, tcp_recv_fn recv) { + check_context("tcp_recv"); + rec("tcp_recv", pcb, recv ? 1 : 0); + if (pcb) { + pcb->recv = recv; + } +} + +extern "C" void tcp_sent(struct tcp_pcb *pcb, tcp_sent_fn sent) { + check_context("tcp_sent"); + rec("tcp_sent", pcb, sent ? 1 : 0); + if (pcb) { + pcb->sent = sent; + } +} + +extern "C" void tcp_err(struct tcp_pcb *pcb, tcp_err_fn err) { + check_context("tcp_err"); + rec("tcp_err", pcb, err ? 1 : 0); + if (pcb) { + pcb->errf = err; + } +} + +extern "C" void tcp_accept(struct tcp_pcb *pcb, tcp_accept_fn accept) { + check_context("tcp_accept"); + rec("tcp_accept", pcb, accept ? 1 : 0); + if (pcb) { + pcb->accept = accept; + } +} + +extern "C" void tcp_poll(struct tcp_pcb *pcb, tcp_poll_fn poll, u8_t interval) { + check_context("tcp_poll"); + rec("tcp_poll", pcb, poll ? 1 : 0, interval); + if (pcb) { + pcb->poll = poll; + } +} + +extern "C" err_t tcp_bind(struct tcp_pcb *pcb, const ip_addr_t *ipaddr, u16_t port) { + check_context("tcp_bind"); + rec("tcp_bind", pcb, port, ipaddr ? (long)ip_addr_get_ip4_u32(ipaddr) : 0); + if (!pcb) { + return ERR_ARG; + } + if (port == 0) { + port = g_next_ephemeral++; + } + if (g_ports.count(port)) { + return ERR_USE; + } + if (ipaddr) { + pcb->local_ip = *ipaddr; + } + pcb->local_port = port; + g_ports.insert(port); + g_pcb_port[pcb] = port; + return ERR_OK; +} + +extern "C" struct tcp_pcb *tcp_listen_with_backlog(struct tcp_pcb *pcb, u8_t backlog) { + check_context("tcp_listen_with_backlog"); + rec("tcp_listen_with_backlog", pcb, backlog); + if (!pcb) { + return nullptr; + } + // Real lwIP swaps the pcb for a smaller listen pcb and frees the original. + tcp_pcb *lpcb = alloc_pcb(); + lpcb->local_ip = pcb->local_ip; + lpcb->local_port = pcb->local_port; + lpcb->callback_arg = pcb->callback_arg; + lpcb->state = LISTEN; + // Move the port reservation across before freeing the old pcb. + auto it = g_pcb_port.find(pcb); + if (it != g_pcb_port.end()) { + g_pcb_port[lpcb] = it->second; + g_pcb_port.erase(it); + } + g_written.erase(pcb); + g_pcbs.erase(pcb); + delete pcb; + return lpcb; +} + +extern "C" err_t tcp_connect(struct tcp_pcb *pcb, const ip_addr_t *ipaddr, u16_t port, tcp_connected_fn connected) { + check_context("tcp_connect"); + rec("tcp_connect", pcb, port, ipaddr ? (long)ip_addr_get_ip4_u32(ipaddr) : 0); + if (!pcb) { + return ERR_ARG; + } + if (ipaddr) { + pcb->remote_ip = *ipaddr; + } + pcb->remote_port = port; + pcb->connected = connected; + pcb->state = SYN_SENT; + if (pcb->local_port == 0) { + uint16_t p = g_next_ephemeral++; + pcb->local_port = p; + g_ports.insert(p); + g_pcb_port[pcb] = p; + } + return ERR_OK; +} + +extern "C" err_t tcp_close(struct tcp_pcb *pcb) { + check_context("tcp_close"); + rec("tcp_close", pcb, pcb ? (long)pcb->state : -1); + if (!pcb) { + return ERR_ARG; + } + // lwIP keeps the pcb until the peer acks our FIN, so queued data goes out first. + auto sent = g_written.extract(pcb); + free_pcb(pcb); + if (sent) { + g_written.insert(std::move(sent)); + } + return ERR_OK; +} + +extern "C" void tcp_abort(struct tcp_pcb *pcb) { + check_context("tcp_abort"); + rec("tcp_abort", pcb, pcb ? (long)pcb->state : -1); + if (pcb) { + abort_pcb(pcb); + } +} + +extern "C" err_t tcp_write(struct tcp_pcb *pcb, const void *dataptr, u16_t len, u8_t apiflags) { + check_context("tcp_write"); + rec("tcp_write", pcb, len, apiflags); + if (!pcb || !dataptr) { + return ERR_ARG; + } + g_written[pcb].append((const char *)dataptr, len); + pcb->snd_buf -= len; + return ERR_OK; +} + +extern "C" err_t tcp_output(struct tcp_pcb *pcb) { + check_context("tcp_output"); + rec("tcp_output", pcb); + if (!pcb) { + return ERR_ARG; + } + return ERR_OK; +} + +extern "C" void tcp_recved(struct tcp_pcb *pcb, u16_t len) { + check_context("tcp_recved"); + rec("tcp_recved", pcb, len); +} + +// =========================================================================== +// dns +// =========================================================================== +extern "C" err_t dns_gethostbyname(const char *hostname, ip_addr_t *addr, dns_found_callback found, void *callback_arg) { + check_context("dns_gethostbyname"); + rec("dns_gethostbyname"); + if (g_faults.dns_result == ERR_OK) { + if (addr) { + memset(addr, 0, sizeof(*addr)); + ip_addr_set_ip4_u32(addr, g_faults.dns_addr); + } + return ERR_OK; + } + return g_faults.dns_result; +} + +// =========================================================================== +// tcpip_api_call and the core lock +// =========================================================================== +/* + Real tcpip_api_call() either takes the core lock or posts to the LwIP mailbox and waits + for it. Called from the LwIP thread itself it deadlocks - on the lock, or waiting for a + message only that thread could drain. Here it just calls straight through, so nothing + would go wrong and the bug would be invisible. Count it instead; the runner fails the + test if it ever happens. +*/ +extern "C" err_t tcpip_api_call(tcpip_api_call_fn fn, struct tcpip_api_call_data *call) { + rec("tcpip_api_call"); + if (g_on_lwip_thread) { + ++g_reentrant_api_calls; + } + ++g_on_lwip_thread; + err_t r = fn(call); + --g_on_lwip_thread; + return r; +} + +// Scope guard for the fire_* helpers: while one runs we are standing in for the LwIP +// thread, so an api call made underneath it is the same deadlock. +namespace { +struct on_lwip_thread { + on_lwip_thread() { + ++g_on_lwip_thread; + } + ~on_lwip_thread() { + --g_on_lwip_thread; + } +}; +} // namespace + +extern "C" int sys_thread_tcpip(int) { + // The LwIP thread holds the core lock while it runs callbacks. + return (g_on_lwip_thread > 0 || g_core_lock_depth > 0) ? 1 : 0; +} + +extern "C" void mock_lock_tcpip_core(void) { + g_core_lock_depth++; +} + +extern "C" void mock_unlock_tcpip_core(void) { + if (g_core_lock_depth > 0) { + g_core_lock_depth--; + } +} + +int mocklwip::core_lock_depth() { + return g_core_lock_depth; +} + +// =========================================================================== +// Test-facing API +// =========================================================================== +namespace mocklwip { + +size_t recved(const tcp_pcb *pcb) { + size_t total = 0; + for (const Call &c : g_calls) { + if (c.fn == "tcp_recved" && c.pcb == pcb) { + total += (size_t)c.a; + } + } + return total; +} + +void reset() { + // Swapped rather than cleared: a stale pointer left in the buffer would hide a leak. + std::vector().swap(g_calls); + for (pbuf *p : std::set(g_pbufs)) { + if (p->payload) { + ::operator delete(p->payload); + } + delete p; + } + g_pbufs.clear(); + for (tcp_pcb *p : std::set(g_pcbs)) { + delete p; + } + g_pcbs.clear(); + g_written.clear(); + g_ports.clear(); + g_pcb_port.clear(); + g_faults = Faults{}; + g_reentrant_api_calls = 0; + g_unlocked_calls = 0; + g_first_unlocked_call.clear(); + std::vector().swap(g_aborted); + g_return_violations = 0; + g_first_return_violation.clear(); + g_on_lwip_thread = 0; + g_next_ephemeral = 49152; + g_core_lock_depth = 0; +} + +size_t live_pcbs() { + return g_pcbs.size(); +} + +std::vector pcbs() { + return std::vector(g_pcbs.begin(), g_pcbs.end()); +} + +bool is_live(const tcp_pcb *pcb) { + return g_pcbs.count(const_cast(pcb)) != 0; +} + +tcp_pcb *dialled_pcb() { + for (auto it = g_calls.rbegin(); it != g_calls.rend(); ++it) { + if (it->fn == "tcp_connect") { + return const_cast(static_cast(it->pcb)); + } + } + return nullptr; +} + +size_t live_pbufs() { + return g_pbufs.size(); +} + +std::string written(const tcp_pcb *pcb) { + auto it = g_written.find(pcb); + return it == g_written.end() ? std::string() : it->second; +} + +Faults &faults() { + return g_faults; +} + +pbuf *make_pbuf(const void *data, size_t len) { + pbuf *p = pbuf_alloc(PBUF_TRANSPORT, (u16_t)len, PBUF_RAM); + if (p && data && len) { + memcpy(p->payload, data, len); + } + return p; +} + +err_t fire_connected(tcp_pcb *pcb, err_t err) { + on_lwip_thread lwip; + if (!pcb || !is_live(pcb)) { + return ERR_ARG; + } + if (err == ERR_OK) { + pcb->state = ESTABLISHED; + } + if (!pcb->connected) { + return ERR_OK; + } + const size_t mark = g_aborted.size(); + err_t r = pcb->connected(pcb->callback_arg, pcb, err); + check_return("connected", pcb, mark, r); + return r; +} + +void fire_error(tcp_pcb *pcb, err_t err) { + on_lwip_thread lwip; + if (!pcb || !is_live(pcb)) { + return; + } + tcp_err_fn errf = pcb->errf; + void *arg = pcb->callback_arg; + // lwIP has already released the pcb by the time the callback runs. + free_pcb(pcb); + if (errf) { + errf(arg, err); + } +} + +err_t fire_recv_pbuf(tcp_pcb *pcb, pbuf *p, err_t err) { + on_lwip_thread lwip; + if (!pcb || !is_live(pcb)) { + if (p) { + pbuf_free(p); + } + return ERR_ARG; + } + if (!pcb->recv) { + if (p) { + pbuf_free(p); + } + return ERR_OK; + } + const size_t mark = g_aborted.size(); + err_t r = pcb->recv(pcb->callback_arg, pcb, p, err); + check_return("recv", pcb, mark, r); + return r; +} + +err_t fire_recv(tcp_pcb *pcb, const void *data, size_t len, err_t err) { + return fire_recv_pbuf(pcb, make_pbuf(data, len), err); +} + +err_t fire_fin(tcp_pcb *pcb, err_t err) { + on_lwip_thread lwip; + if (!pcb || !is_live(pcb)) { + return ERR_ARG; + } + if (pcb->state == ESTABLISHED) { + pcb->state = CLOSE_WAIT; + } + if (!pcb->recv) { + return ERR_OK; + } + const size_t mark = g_aborted.size(); + err_t r = pcb->recv(pcb->callback_arg, pcb, nullptr, err); + check_return("recv", pcb, mark, r); + return r; +} + +err_t fire_sent(tcp_pcb *pcb, uint16_t len) { + on_lwip_thread lwip; + if (!pcb || !is_live(pcb)) { + return ERR_ARG; + } + pcb->snd_buf = (u16_t)((pcb->snd_buf + len > TCP_SND_BUF) ? TCP_SND_BUF : pcb->snd_buf + len); + if (!pcb->sent) { + return ERR_OK; + } + const size_t mark = g_aborted.size(); + err_t r = pcb->sent(pcb->callback_arg, pcb, len); + check_return("sent", pcb, mark, r); + return r; +} + +tcp_pcb *fire_accept(tcp_pcb *listen_pcb, err_t err) { + ip_addr_t peer; + memset(&peer, 0, sizeof(peer)); + ip_addr_set_ip4_u32_val(peer, 0x0A000002); // 10.0.0.2 + return fire_accept(listen_pcb, peer, 40000, err); +} + +tcp_pcb *fire_accept(tcp_pcb *listen_pcb, const ip_addr_t &peer, uint16_t port, err_t err) { + on_lwip_thread lwip; + if (!listen_pcb || !is_live(listen_pcb) || !listen_pcb->accept) { + return nullptr; + } + tcp_pcb *newpcb = alloc_pcb(); + newpcb->state = ESTABLISHED; + newpcb->local_ip = listen_pcb->local_ip; + newpcb->local_port = listen_pcb->local_port; + newpcb->remote_ip = peer; + newpcb->remote_port = port; + rec("accept_offered", newpcb, err); + const size_t mark = g_aborted.size(); + err_t r = listen_pcb->accept(listen_pcb->callback_arg, newpcb, err); + // tcp_process(), SYN_RCVD: any other error and lwIP aborts the pcb itself. + if (!check_return("accept", newpcb, mark, r) && r != ERR_OK && r != ERR_ABRT && is_live(newpcb)) { + abort_pcb(newpcb); + } + return newpcb; +} + +unsigned reentrant_api_calls() { + return g_reentrant_api_calls; +} + +unsigned unlocked_calls() { + return g_unlocked_calls; +} + +const char *first_unlocked_call() { + return g_first_unlocked_call.c_str(); +} + +unsigned callback_return_violations() { + return g_return_violations; +} + +const char *first_callback_return_violation() { + return g_first_return_violation.c_str(); +} + +} // namespace mocklwip diff --git a/test/test_native/mocks/mock_lwip.h b/test/test_native/mocks/mock_lwip.h new file mode 100644 index 00000000..36ce66ad --- /dev/null +++ b/test/test_native/mocks/mock_lwip.h @@ -0,0 +1,109 @@ +// Test-facing control surface for the mock lwIP stack. +// +// This header is for tests only -- the library never sees it. +#ifndef ASYNCTCP_MOCK_LWIP_H +#define ASYNCTCP_MOCK_LWIP_H + +#include +#include +#include +#include + +extern "C" { +#include "lwip/dns.h" +#include "lwip/pbuf.h" +#include "lwip/tcp.h" +} + +namespace mocklwip { + +// --------------------------------------------------------------------------- +// Call recording +// --------------------------------------------------------------------------- +struct Call { + std::string fn; // "tcp_close", "tcp_recved", ... + const void *pcb; // pcb the call targeted, or nullptr + long a; // first scalar argument (len, port, backlog, err, ...) + long b; // second scalar argument (apiflags, ...) +}; + +// Total bytes handed to tcp_recved() for : how much window was reopened. +size_t recved(const tcp_pcb *pcb); + +// --------------------------------------------------------------------------- +// Resource accounting +// --------------------------------------------------------------------------- +void reset(); // free everything and clear the log; call between tests + +size_t live_pcbs(); +std::vector pcbs(); +bool is_live(const tcp_pcb *pcb); +// The pcb handed to the most recent tcp_connect(), or nullptr. It may since have been +// freed, so check is_live() before dereferencing. +tcp_pcb *dialled_pcb(); +size_t live_pbufs(); + +// Everything a pcb was handed to tcp_write(), in order, kept after tcp_close(). +std::string written(const tcp_pcb *pcb); + +// How many times the TCPIP core lock is held. 0 whenever no api call is running. +int core_lock_depth(); + +// --------------------------------------------------------------------------- +// Failure injection. All are reset by reset(). +// --------------------------------------------------------------------------- +struct Faults { + err_t dns_result = 0; // ERR_OK (immediate) or an error + uint32_t dns_addr = 0; // address returned for an immediate ERR_OK lookup +}; +Faults &faults(); + +// --------------------------------------------------------------------------- +// Driving lwIP callbacks (i.e. playing the part of the lwIP thread). +// +// These call straight into the callbacks the library registered, exactly as +// lwIP would. Nothing is delivered to the application until you then call +// asynctcp_test_pump(). +// --------------------------------------------------------------------------- + +// Completes a connect: moves the pcb to ESTABLISHED (when err == ERR_OK) and +// invokes the tcp_connected_fn passed to tcp_connect(). +err_t fire_connected(tcp_pcb *pcb, err_t err = 0); + +// Fatal error. Models lwIP faithfully: the pcb is freed *and then* the error +// callback runs, so the pcb pointer is dangling by the time the library sees +// it. Any pointer you hold to `pcb` is invalid after this returns. +void fire_error(tcp_pcb *pcb, err_t err); + +// Delivers bytes to tcp_recv_fn. Allocates the pbuf for you. +err_t fire_recv(tcp_pcb *pcb, const void *data, size_t len, err_t err = 0); +// Remote FIN: tcp_recv_fn with a NULL pbuf. +err_t fire_fin(tcp_pcb *pcb, err_t err = 0); + +err_t fire_sent(tcp_pcb *pcb, uint16_t len); + +// Builds a fresh ESTABLISHED pcb and offers it to the listening pcb's +// tcp_accept_fn. Returns the new pcb (which may already have been freed by the +// library, so check is_live() before dereferencing). As in lwIP, a return other than +// ERR_OK or ERR_ABRT aborts the new pcb. The peer is 10.0.0.2:40000 unless given. The +// local address is the listener's. +tcp_pcb *fire_accept(tcp_pcb *listen_pcb, err_t err = 0); +tcp_pcb *fire_accept(tcp_pcb *listen_pcb, const ip_addr_t &peer, uint16_t port, err_t err = 0); + +// Non-zero if the library made an api call while standing on the LwIP thread, which on a +// real target would deadlock. +unsigned reentrant_api_calls(); + +// Calls into tcp_* or dns_* made neither on the LwIP thread nor under the TCPIP core +// lock, which on a real target race the LwIP thread. The first one's name. +unsigned unlocked_calls(); +const char *first_unlocked_call(); + +// Callbacks handed a pcb that returned ERR_ABRT without aborting it, or anything else +// after aborting it; lwIP would then leak the pcb or use it after free. The first one. +unsigned callback_return_violations(); +const char *first_callback_return_violation(); + +} // namespace mocklwip + +#endif diff --git a/test/test_native/mocks/mock_rtos.cpp b/test/test_native/mocks/mock_rtos.cpp new file mode 100644 index 00000000..dfa9ef89 --- /dev/null +++ b/test/test_native/mocks/mock_rtos.cpp @@ -0,0 +1,229 @@ +// Implementation of the FreeRTOS / clock / logging mocks. + +#include "mock_rtos.h" + +#include +#include +#include +#include +#include +#include + +extern "C" { +#include "esp_task_wdt.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +} + +// --------------------------------------------------------------------------- +// Clock +// --------------------------------------------------------------------------- +namespace { +uint64_t g_micros = 0; +} + +namespace mockclock { +void reset() { + g_micros = 0; +} +void set_millis(uint32_t ms) { + g_micros = (uint64_t)ms * 1000ULL; +} +void advance(uint32_t ms) { + g_micros += (uint64_t)ms * 1000ULL; +} +} // namespace mockclock + +extern "C" unsigned long millis(void) { + return (unsigned long)(g_micros / 1000ULL); +} + +extern "C" unsigned long micros(void) { + return (unsigned long)g_micros; +} + +// --------------------------------------------------------------------------- +// Logging +// --------------------------------------------------------------------------- +namespace { +int g_log_enabled = -1; // -1 = not yet resolved from the environment + +bool log_enabled() { + if (g_log_enabled < 0) { + const char *v = getenv("ASYNCTCP_TEST_VERBOSE"); + g_log_enabled = (v && *v && *v != '0') ? 1 : 0; + } + return g_log_enabled != 0; +} +} // namespace + +extern "C" void asynctcp_mock_log(char level, const char *file, int line, const char *fmt, ...) { + if (!log_enabled()) { + return; + } + const char *base = strrchr(file, '/'); + fprintf(stderr, "[%c][%s:%d] ", level, base ? base + 1 : file, line); + va_list ap; + va_start(ap, fmt); + vfprintf(stderr, fmt, ap); + va_end(ap); + fputc('\n', stderr); +} + +// --------------------------------------------------------------------------- +// Semaphores +// --------------------------------------------------------------------------- +namespace { + +struct MockSemaphore { + bool recursive; + int depth; +}; + +std::set g_semaphores; +unsigned g_deadlocks = 0; +unsigned g_null_semaphores = 0; + +} // namespace + +extern "C" SemaphoreHandle_t xSemaphoreCreateMutex(void) { + MockSemaphore *s = new MockSemaphore{false, 0}; + g_semaphores.insert(s); + return (SemaphoreHandle_t)s; +} + +extern "C" BaseType_t xSemaphoreTake(SemaphoreHandle_t xSemaphore, TickType_t) { + MockSemaphore *s = (MockSemaphore *)xSemaphore; + if (!s) { + // FreeRTOS faults on a null handle - configASSERT, or a null dereference. + // Record it rather than returning a quiet pdFALSE that hides the bug. + g_null_semaphores++; + return pdFALSE; + } + // Single-threaded, so we can never actually block; a re-take of a + // non-recursive mutex would deadlock on the target, so record it. + if (!s->recursive && s->depth > 0) { + g_deadlocks++; + } + s->depth++; + return pdTRUE; +} + +extern "C" BaseType_t xSemaphoreGive(SemaphoreHandle_t xSemaphore) { + MockSemaphore *s = (MockSemaphore *)xSemaphore; + if (!s) { + g_null_semaphores++; + return pdFALSE; + } + if (s->depth > 0) { + s->depth--; + } + return pdTRUE; +} + +// --------------------------------------------------------------------------- +// Tasks and the pump +// --------------------------------------------------------------------------- +namespace { + +struct PumpExit {}; // thrown by ulTaskNotifyTake to unwind out of the task loop + +TaskFunction_t g_task_fn = nullptr; +void *g_task_param = nullptr; +int g_task_handle_storage = 0; // address used as the fake TaskHandle_t +bool g_in_pump = false; + +BaseType_t create_task(TaskFunction_t fn, void *param, TaskHandle_t *handle) { + g_task_fn = fn; + g_task_param = param; + if (handle) { + *handle = (TaskHandle_t)&g_task_handle_storage; + } + return pdPASS; +} + +} // namespace + +extern "C" BaseType_t xTaskCreate(TaskFunction_t fn, const char *, const configSTACK_DEPTH_TYPE, void *param, UBaseType_t, TaskHandle_t *handle) { + return create_task(fn, param, handle); +} + +extern "C" void vTaskDelete(TaskHandle_t) { + // The task loop only reaches this after its infinite for(;;), which the pump + // unwinds out of, so this is unreachable in practice. +} + +extern "C" BaseType_t xPortGetCoreID(void) { + return 0; +} + +extern "C" BaseType_t xTaskNotifyGive(TaskHandle_t) { + return pdPASS; +} + +extern "C" uint32_t ulTaskNotifyTake(BaseType_t, TickType_t) { + if (g_in_pump) { + // The task has drained its queue and is about to sleep. Unwind back out to + // asynctcp_test_pump() rather than blocking forever. + throw PumpExit{}; + } + return 0; +} + +extern "C" esp_err_t esp_task_wdt_add(TaskHandle_t) { + return ESP_OK; +} + +extern "C" esp_err_t esp_task_wdt_delete(TaskHandle_t) { + return ESP_OK; +} + +extern "C" esp_err_t esp_task_wdt_reset(void) { + return ESP_OK; +} + +void asynctcp_test_pump() { + if (!g_task_fn) { + return; // library never started its task + } + g_in_pump = true; + try { + g_task_fn(g_task_param); + } catch (const PumpExit &) { + // Expected: the task reached its idle wait. + } catch (...) { + // A failed assertion in a callback, ending the test. + g_in_pump = false; + throw; + } + g_in_pump = false; +} + +bool asynctcp_test_task_started() { + return g_task_fn != nullptr; +} + +namespace mockrtos { + +void reset() { + // NOTE: g_task_fn is deliberately *not* cleared. The library caches its task + // handle in a file-static that we cannot reach, so once started it stays + // started for the life of the process -- which is fine, the task is + // stateless. + g_deadlocks = 0; + g_null_semaphores = 0; + for (MockSemaphore *s : g_semaphores) { + s->depth = 0; + } +} + +unsigned null_semaphores() { + return g_null_semaphores; +} + +unsigned deadlocks() { + return g_deadlocks; +} + +} // namespace mockrtos diff --git a/test/test_native/mocks/mock_rtos.h b/test/test_native/mocks/mock_rtos.h new file mode 100644 index 00000000..90af1824 --- /dev/null +++ b/test/test_native/mocks/mock_rtos.h @@ -0,0 +1,56 @@ +// Test-facing control surface for the FreeRTOS / clock / logging mocks. +// +// This header is for tests only -- the library never sees it. +#ifndef ASYNCTCP_MOCK_RTOS_H +#define ASYNCTCP_MOCK_RTOS_H + +#include + +// --------------------------------------------------------------------------- +// The async-task pump +// --------------------------------------------------------------------------- +// +// AsyncTCP starts its event-service task with xTaskCreateUniversal(). The mock +// records the task function and parameter but never runs it. Calling +// asynctcp_test_pump() invokes that recorded function on the *calling* thread; +// it drains the library's event queue exactly as the real task would, and when +// it reaches its ulTaskNotifyTake() "sleep" the mock unwinds out of it with a +// private C++ exception which asynctcp_test_pump() catches. +// +// Net effect: pump() == "run the async task until its queue is empty, then +// return". Everything stays on one thread, so tests are fully deterministic. +// +// Safe because the library's task loop holds no live objects across the +// ulTaskNotifyTake() call -- the queue mutex guard lives and dies inside +// _get_async_event(). If a future rewrite changes that, the unwinding is still +// a proper C++ unwind (destructors run), unlike a longjmp. +// +void asynctcp_test_pump(); + +// True once the library has created its async service task. +bool asynctcp_test_task_started(); + +namespace mockrtos { + +// Clears task and semaphore state. Call between tests. +void reset(); + +// Number of times a non-recursive mutex was taken while already held. On the +// real target that is a deadlock; here it is recorded and allowed to proceed. +unsigned deadlocks(); + +// Takes or gives against a null handle. FreeRTOS faults on those; the runner treats +// any occurrence as a test failure. +unsigned null_semaphores(); + +} // namespace mockrtos + +namespace mockclock { + +void reset(); // back to t=0 +void set_millis(uint32_t ms); +void advance(uint32_t ms); + +} // namespace mockclock + +#endif diff --git a/test/test_native/runner.h b/test/test_native/runner.h new file mode 100644 index 00000000..6b0e9406 --- /dev/null +++ b/test/test_native/runner.h @@ -0,0 +1,18 @@ +// Test invocation for the host-native suite. +// +// The tests themselves are ordinary Unity. This only changes how they are called: +// each one runs in a forked child, so a crash costs one test rather than the rest of +// the run. Unity leaves RUN_TEST overridable for exactly this. +// +// Include this instead of . +#ifndef ASYNCTCP_TEST_RUNNER_H +#define ASYNCTCP_TEST_RUNNER_H + +#include + +void asynctcp_run_test(void (*fn)(void), const char *name, int line); + +#undef RUN_TEST +#define RUN_TEST(func) asynctcp_run_test(func, #func, __LINE__) + +#endif diff --git a/test/test_native/test_main.cpp b/test/test_native/test_main.cpp new file mode 100644 index 00000000..3292a011 --- /dev/null +++ b/test/test_native/test_main.cpp @@ -0,0 +1,300 @@ +// Host-native AsyncTCP tests. +// +// pio test -e native run everything +// ASYNCTCP_TEST_VERBOSE=1 also print the library's log_* output +// ASYNCTCP_TEST_NOFORK=1 run in-process, for a debugger +// +// Each tests/*.cpp file exposes one run_*_tests() listing what it contains. + +#include "runner.h" + +#include +#include +#include +#include +#include + +#if defined(__unix__) || defined(__APPLE__) +#define ASYNCTCP_TEST_CAN_FORK 1 +#include +#include +#include +#endif + +#include "mocks/mock_lwip.h" +#include "mocks/mock_rtos.h" + +#ifdef __GLIBC__ +#include +#endif + +void run_client_tests(void); + +namespace { + +// Thrown by TEST_ABORT() -- see unity_config.h. Unity calls it after it has printed a +// failure, from inside the failed assertion. +struct TestAbort {}; + +// Set where there is nothing left to unwind to. TEST_ABORT() then just returns. +bool g_abort_returns = false; + +// -l: print each test's name rather than run it. +bool g_list_only = false; + +// Runs one stage of a test. A failed assertion ends the stage; Unity has already +// recorded it. +void guarded(void (*fn)(void)) { + try { + fn(); + } catch (const TestAbort &) {} +} + +void reset_mocks(void) { + mocklwip::reset(); + mockrtos::reset(); + mockclock::reset(); + // As on a target that has been up a while. A test about millis() == 0 sets it itself. + mockclock::set_millis(1000); +} + +void check_invariants(void) { + // Whole-library invariants, checked after every test rather than restated in each one, + // and reported against the test's own line. + const UNITY_LINE_TYPE line = Unity.CurrentTestLineNumber; + UNITY_TEST_ASSERT(!mockrtos::null_semaphores(), line, "semaphore used before it was created"); + UNITY_TEST_ASSERT(!mockrtos::deadlocks(), line, "non-recursive mutex taken while held (would deadlock)"); + UNITY_TEST_ASSERT(!mocklwip::reentrant_api_calls(), line, "tcpip_api_call from the LwIP thread (would deadlock)"); + UNITY_TEST_ASSERT_EQUAL_INT(0, mocklwip::core_lock_depth(), line, "TCPIP core lock still held"); + if (mocklwip::unlocked_calls()) { + const std::string msg = std::string(mocklwip::first_unlocked_call()) + "() called off the LwIP thread without the TCPIP core lock"; + UNITY_TEST_FAIL(line, msg.c_str()); + } + if (mocklwip::callback_return_violations()) { + UNITY_TEST_FAIL(line, mocklwip::first_callback_return_violation()); + } +} + +// The test UnityDefaultTestRun() is running, called through run_current_test(). +void (*g_current_test)(void) = nullptr; + +void run_current_test(void) { + if (!Unity.CurrentTestFailed && !Unity.CurrentTestIgnored) { + guarded(g_current_test); + } +} + +void run_test(void (*fn)(void), const char *name, int line) { + g_current_test = fn; + UnityDefaultTestRun(&run_current_test, name, line); +} + +} // namespace + +void setUp(void) { + guarded(reset_mocks); +} + +void tearDown(void) { + guarded(check_invariants); + + // Drop anything the test left behind so the next one starts clean. + mocklwip::reset(); +} + +namespace { + +// How a child process reports back, as an exit status. Anything else means it died +// without concluding the test. +enum ChildResult { + CHILD_PASSED = 80, + CHILD_FAILED = 81, + CHILD_IGNORED = 82 +}; + +// A stand-in for a test whose real body ran in a child that printed nothing. Unity +// runs this in the parent, so it prints and counts exactly as any other test does. +const char *g_verdict = nullptr; +int g_verdict_line = 0; + +void report_verdict(void) { + // Report against the test's own line, not this file's. + UNITY_TEST_FAIL((UNITY_LINE_TYPE)g_verdict_line, g_verdict); +} + +void run_verdict(const char *name, int line, const char *message) { + g_verdict = message; + g_verdict_line = line; + run_test(&report_verdict, name, line); +} + +// Count a test whose line the child already printed. +void tally(int result) { + Unity.NumberOfTests++; + if (result == CHILD_FAILED) { + Unity.TestFailures++; + } else if (result == CHILD_IGNORED) { + Unity.TestIgnores++; + } +} + +// UnityDefaultTestRun() ends in UnityConcludeTest(), which folds CurrentTestFailed +// and CurrentTestIgnored into these totals and then clears them -- so the verdict has +// to be read as a delta across the run, not from the per-test flags afterwards. +struct UnityTally { + UNITY_COUNTER_TYPE failures; + UNITY_COUNTER_TYPE ignores; +}; + +UnityTally tally_now(void) { + return UnityTally{Unity.TestFailures, Unity.TestIgnores}; +} + +int result_since(const UnityTally &before) { + if (Unity.TestFailures != before.failures) { + return CHILD_FAILED; + } + return Unity.TestIgnores != before.ignores ? CHILD_IGNORED : CHILD_PASSED; +} + +#ifdef ASYNCTCP_TEST_CAN_FORK +// Shared with the parent: the verdict the child has printed so far, if any. A child +// that dies after printing a failure must not then be reported a second time. +const int NOTHING_PRINTED = -1; +volatile int *g_printed = nullptr; + +UnityTally g_child_before; + +[[noreturn]] +void end_child(void) { + fflush(stdout); + _exit(result_since(g_child_before)); +} + +// A throw from a noexcept context -- a failed assertion in a callback run from a +// destructor, say -- lands here, with nothing left to unwind. Conclude the test as +// failed, once. +[[noreturn]] +void on_terminate(void) { + g_abort_returns = true; + if (!Unity.CurrentTestFailed && !Unity.CurrentTestIgnored) { + UnityFail("std::terminate() called", Unity.CurrentTestLineNumber); + } + UnityConcludeTest(); + end_child(); +} +#endif + +} // namespace + +extern "C" void asynctcp_test_abort(void) { +#ifdef ASYNCTCP_TEST_CAN_FORK + if (g_printed) { + *g_printed = Unity.CurrentTestIgnored ? CHILD_IGNORED : CHILD_FAILED; + } +#endif + if (!g_abort_returns) { + throw TestAbort{}; + } +} + +void asynctcp_run_test(void (*fn)(void), const char *name, int line) { + Unity.CurrentTestName = name; + if (!UnityTestMatches()) { + return; + } + if (g_list_only) { + printf("%s:%s\n", Unity.TestFile, name); + return; + } + +#ifdef ASYNCTCP_TEST_CAN_FORK + if (getenv("ASYNCTCP_TEST_NOFORK") == nullptr) { + if (!g_printed) { + void *shared = mmap(nullptr, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0); + if (shared == MAP_FAILED) { + run_verdict(name, line, "mmap() failed"); + return; + } + g_printed = (volatile int *)shared; + } + *g_printed = NOTHING_PRINTED; + + fflush(stdout); + fflush(stderr); + + pid_t pid = fork(); + if (pid < 0) { + run_verdict(name, line, "fork() failed"); + return; + } + + if (pid == 0) { + g_child_before = tally_now(); + std::set_terminate(on_terminate); + run_test(fn, name, line); + end_child(); + } + + int status = 0; + waitpid(pid, &status, 0); + + const int result = WIFEXITED(status) ? WEXITSTATUS(status) : -1; + if (result == CHILD_PASSED || result == CHILD_FAILED || result == CHILD_IGNORED) { + tally(result); + return; + } + + static char msg[128]; + if (WIFSIGNALED(status)) { + snprintf(msg, sizeof(msg), "killed by signal %d (%s)", WTERMSIG(status), strsignal(WTERMSIG(status))); + } else { + snprintf(msg, sizeof(msg), "exited with status %d", result); + } + if (*g_printed != NOTHING_PRINTED) { + // The child died after printing its verdict: finish that line. + printf(" -- then %s\n", msg); + tally(*g_printed); + return; + } + // The child died partway through, so it never printed a verdict of its own. + if (!WIFSIGNALED(status)) { + strncat(msg, " without a verdict", sizeof(msg) - strlen(msg) - 1); + } + run_verdict(name, line, msg); + return; + } +#endif + + // In-process, for a debugger. + run_test(fn, name, line); +} + +// Fresh allocations are filled with 0xfe, as locals are (see platformio.ini), so a value +// read before it was written is the same on every run. +int main(int argc, char **argv) { +#ifdef __GLIBC__ + mallopt(M_PERTURB, 0x01); // glibc fills with the complement +#endif + + // Unity's own options; see README.md. + const int options = UnityParseOptions(argc, argv); + if (options > 0) { + return options; + } + g_list_only = options < 0; + + UNITY_BEGIN(); + + run_client_tests(); + + if (g_list_only) { + return 0; + } + UNITY_END(); + + // PlatformIO's native runner reads the verdict out of the Unity output above, and + // passes any non-zero exit code to signal.Signals(), which renames a plain test + // failure to "received signal SIGHUP". + return 0; +} diff --git a/test/test_native/tests/test_client.cpp b/test/test_native/tests/test_client.cpp new file mode 100644 index 00000000..5651a00f --- /dev/null +++ b/test/test_native/tests/test_client.cpp @@ -0,0 +1,85 @@ +// End-to-end smoke tests: one pass down each main path. The topic files hold the detail. + +#include "fixtures.h" + +#include + +using namespace mocklwip; + +static void test_client_connects_exchanges_data_and_closes(void) { + AsyncClient c; + Recorder t; + t.attach(c, "CEDRA"); + + tcp_pcb *pcb = establish(c); + TEST_ASSERT_EQUAL_STRING("C", t.seq.c_str()); + + TEST_ASSERT_EQUAL_size_t(4, c.write("ping", 4)); + TEST_ASSERT_EQUAL_STRING("ping", written(pcb).c_str()); + fire_sent(pcb, 4); + fire_recv(pcb, "pong", 4); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("CAR", t.seq.c_str()); + TEST_ASSERT_EQUAL_size_t(4, t.acked); + TEST_ASSERT_EQUAL_STRING("pong", t.data.c_str()); + TEST_ASSERT_EQUAL_size_t(4, recved(pcb)); + + c.close(); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("CARD", t.seq.c_str()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_client_reset_reports_the_error_then_the_disconnect(void) { + AsyncClient c; + Recorder t; + t.attach(c); + + tcp_pcb *pcb = establish(c); + fire_error(pcb, ERR_RST); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("CED", t.seq.c_str()); + TEST_ASSERT_EQUAL_INT8(ERR_RST, t.err); + TEST_ASSERT_TRUE(c.disconnected()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_client_server_answers_a_request(void) { + AsyncServer s(kServerPort); + std::string request; + Accepted accepted(s, [&](AsyncClient *c) { + c->onData([&](void *, AsyncClient *cl, void *d, size_t n) { + request.append((const char *)d, n); + cl->write("pong", 4); + }); + }); + s.begin(); + + tcp_pcb *conn = fire_accept(listen_pcb()); + TEST_ASSERT_NOT_NULL(conn); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(1, accepted.size()); + + fire_recv(conn, "ping", 4); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("ping", request.c_str()); + TEST_ASSERT_EQUAL_STRING("pong", written(conn).c_str()); + + fire_fin(conn); // the peer hangs up + asynctcp_test_pump(); + TEST_ASSERT_TRUE(accepted[0]->disconnected()); + + accepted.clear(); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +void run_client_tests(void) { + UnitySetTestFile(__FILE__); + RUN_TEST(test_client_connects_exchanges_data_and_closes); + RUN_TEST(test_client_reset_reports_the_error_then_the_disconnect); + RUN_TEST(test_client_server_answers_a_request); +} diff --git a/test/test_native/unity_config.h b/test/test_native/unity_config.h new file mode 100644 index 00000000..642f3d69 --- /dev/null +++ b/test/test_native/unity_config.h @@ -0,0 +1,25 @@ +// Unity configuration for the host-native suite. Its presence here stops PlatformIO +// generating its own. +#ifndef UNITY_CONFIG_H +#define UNITY_CONFIG_H + +#define UNITY_USE_FLUSH_STDOUT + +// -f, -x and the rest, applied by the forking runner (see test_main.cpp). +#define UNITY_USE_COMMAND_LINE_ARGS + +// A failed assertion ends the test by throwing (see test_main.cpp), so the test's +// destructors run. Unity's default is a longjmp, which skips them. +#define UNITY_EXCLUDE_SETJMP_H + +#ifdef __cplusplus +extern "C" { +#endif +void asynctcp_test_abort(void); +#ifdef __cplusplus +} +#endif + +#define UNITY_TEST_ABORT() asynctcp_test_abort() + +#endif From 6e79eb45cac8a24fd3951d4bfb4437c2790cfba0 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Wed, 30 Sep 2026 23:29:03 -0400 Subject: [PATCH 03/24] test: Add a sanitizer environment `pio test -e native-asan` runs the same suite under AddressSanitizer and UndefinedBehaviorSanitizer, with leak detection. Co-Authored-By: Claude Opus 5.5 --- platformio.ini | 14 ++++++++++++ test/test_native/README.md | 9 +++++--- test/test_native/runner.h | 2 +- test/test_native/test_main.cpp | 42 ++++++++++++++++++++++++++++++++-- 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/platformio.ini b/platformio.ini index 6d411225..3a59de6c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -95,6 +95,7 @@ custom_versions.freertos = 9.0.0 ; Host-native tests -- see test/test_native/README.md ; ; pio test -e native +; pio test -e native-asan [env:native] platform = native @@ -122,3 +123,16 @@ build_flags = -D CONFIG_ASYNC_TCP_PRIORITY=10 -D CONFIG_ASYNC_TCP_QUEUE_SIZE=64 -D CONFIG_ASYNC_TCP_STACK_SIZE=8192 + +; Leak detection earns its keep here: ownership of connections and buffers passes +; between the application, the library and LwIP, and a dropped owner surfaces as a leak +; rather than as a crash. Each test is forked, so a leak is reported against the test +; that caused it. Undefined behavior is fatal, so it fails the test rather than +; scrolling past. +[env:native-asan] +extends = env:native +build_flags = + ${env:native.build_flags} + -fsanitize=address,undefined + -fno-sanitize-recover=undefined + -fno-omit-frame-pointer diff --git a/test/test_native/README.md b/test/test_native/README.md index 64877d70..1797b4e1 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -10,9 +10,10 @@ Nothing under `src/` is modified or needs to be. ``` pio test -e native # the suite +pio test -e native-asan # ... under ASan/UBSan, with leak detection ``` -It reports each test separately and exits non-zero if any fails. +Each reports each test separately and exits non-zero if any fails. To work under a debugger, run the built program directly; `ASYNCTCP_TEST_NOFORK=1` keeps every test in one process: @@ -48,7 +49,9 @@ pio test -e native | awk -F': ' '/\[FAILED\]/ { print $2 }' | sort -u `test_main.cpp` runs every test in a child process. A test that segfaults then costs one red result instead of the rest of the run -- which matters, because the -bugs this suite is aimed at tend to crash rather than to fail an assertion. +bugs this suite is aimed at tend to crash rather than to fail an assertion. It +also lets `tearDown()` run LeakSanitizer's check on one test at a time, so a +leak fails the test that caused it. ## How a failed assertion ends a test @@ -258,4 +261,4 @@ written(pcb) // everything handed to tcp_write(), concatenated the run. * Memory the library reads before writing holds 0xfe bytes, on the stack and on the heap, so a failure that depends on it reads the same on every run. The - heap fill needs glibc. + heap fill needs glibc or ASan. diff --git a/test/test_native/runner.h b/test/test_native/runner.h index 6b0e9406..8e0612f3 100644 --- a/test/test_native/runner.h +++ b/test/test_native/runner.h @@ -2,7 +2,7 @@ // // The tests themselves are ordinary Unity. This only changes how they are called: // each one runs in a forked child, so a crash costs one test rather than the rest of -// the run. Unity leaves RUN_TEST overridable for exactly this. +// the run, and the sanitizers' leak check reports against the test that leaked. Unity leaves RUN_TEST overridable for exactly this. // // Include this instead of . #ifndef ASYNCTCP_TEST_RUNNER_H diff --git a/test/test_native/test_main.cpp b/test/test_native/test_main.cpp index 3292a011..dcfb3f61 100644 --- a/test/test_native/test_main.cpp +++ b/test/test_native/test_main.cpp @@ -1,6 +1,7 @@ // Host-native AsyncTCP tests. // // pio test -e native run everything +// pio test -e native-asan ... under ASan/UBSan // ASYNCTCP_TEST_VERBOSE=1 also print the library's log_* output // ASYNCTCP_TEST_NOFORK=1 run in-process, for a debugger // @@ -24,6 +25,18 @@ #include "mocks/mock_lwip.h" #include "mocks/mock_rtos.h" +#if defined(__SANITIZE_ADDRESS__) +#define ASYNCTCP_TEST_LSAN 1 +#elif defined(__has_feature) +#if __has_feature(address_sanitizer) +#define ASYNCTCP_TEST_LSAN 1 +#endif +#endif + +#ifdef ASYNCTCP_TEST_LSAN +#include +#endif + #ifdef __GLIBC__ #include #endif @@ -39,6 +52,8 @@ struct TestAbort {}; // Set where there is nothing left to unwind to. TEST_ABORT() then just returns. bool g_abort_returns = false; +bool g_in_child = false; + // -l: print each test's name rather than run it. bool g_list_only = false; @@ -75,6 +90,19 @@ void check_invariants(void) { } } +// Only in a forked child: in one process, a leak would be reported again by every later +// test. A test that has already failed is not reported twice. +void check_leaks(void) { +#ifdef ASYNCTCP_TEST_LSAN + if (!g_in_child || Unity.CurrentTestFailed) { + return; + } + if (__lsan_do_recoverable_leak_check()) { + UnityFail("leaked memory (see the LeakSanitizer report)", Unity.CurrentTestLineNumber); + } +#endif +} + // The test UnityDefaultTestRun() is running, called through run_current_test(). void (*g_current_test)(void) = nullptr; @@ -100,12 +128,14 @@ void tearDown(void) { // Drop anything the test left behind so the next one starts clean. mocklwip::reset(); + + guarded(check_leaks); } namespace { -// How a child process reports back, as an exit status. Anything else means it died -// without concluding the test. +// How a child process reports back, as an exit status. Anything else -- 1 from a +// sanitizer, say -- means it died without concluding the test. enum ChildResult { CHILD_PASSED = 80, CHILD_FAILED = 81, @@ -165,6 +195,7 @@ volatile int *g_printed = nullptr; UnityTally g_child_before; +// _exit(), so the sanitizers' exit-time leak check cannot report the test again. [[noreturn]] void end_child(void) { fflush(stdout); @@ -230,6 +261,7 @@ void asynctcp_run_test(void (*fn)(void), const char *name, int line) { } if (pid == 0) { + g_in_child = true; g_child_before = tally_now(); std::set_terminate(on_terminate); run_test(fn, name, line); @@ -272,6 +304,12 @@ void asynctcp_run_test(void (*fn)(void), const char *name, int line) { // Fresh allocations are filled with 0xfe, as locals are (see platformio.ini), so a value // read before it was written is the same on every run. +#ifdef ASYNCTCP_TEST_LSAN +extern "C" const char *__asan_default_options(void) { + return "malloc_fill_byte=254:max_malloc_fill_size=1048576"; +} +#endif + int main(int argc, char **argv) { #ifdef __GLIBC__ mallopt(M_PERTURB, 0x01); // glibc fills with the complement From 80e082d9a0428fd08593e3dba4d9cee383557e91 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Wed, 30 Sep 2026 23:29:15 -0400 Subject: [PATCH 04/24] test: Fail the build on a test that is never run Unity runs only what RUN_TEST lists, so a test that is written but not listed, or a file whose run_*_tests() is never called from main, is silently dead. Co-Authored-By: Claude Opus 5.5 --- .pre-commit-config.yaml | 10 ++++++ test/check_registration.py | 70 ++++++++++++++++++++++++++++++++++++++ test/test_native/README.md | 4 +++ 3 files changed, 84 insertions(+) create mode 100644 test/check_registration.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 660d000a..38a111a1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,6 +33,16 @@ repos: - id: codespell exclude: ^.*\.(svd|SVD)$ + - repo: local + hooks: + # Unity runs what RUN_TEST lists, so an unregistered test is silently dead + - id: tests-registered + name: every test is registered to run + entry: python3 test/check_registration.py + language: system + files: ^test/(check_registration\.py|test_native/(test_main\.cpp|tests/.*\.cpp))$ + pass_filenames: false + - repo: https://github.com/pre-commit/mirrors-clang-format rev: "v18.1.3" hooks: diff --git a/test/check_registration.py b/test/check_registration.py new file mode 100644 index 00000000..7fae92af --- /dev/null +++ b/test/check_registration.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Check that every test function is actually registered to run. + +Unity is told what to run by explicit RUN_TEST lines, so a test that is written but +never listed is silently dead. This fails the build instead. + +It also checks that each test is named for its file: tests in test_foo.cpp are +test_foo_*. + + python3 test/check_registration.py +""" + +import pathlib +import re +import sys + +NATIVE = pathlib.Path(__file__).parent / "test_native" +TESTS = NATIVE / "tests" +MAIN = NATIVE / "test_main.cpp" + +DEFINED = re.compile(r"^\s*static\s+void\s+(test_\w+)\s*\(\s*void\s*\)", re.M) +REGISTERED = re.compile(r"^\s*RUN_TEST\s*\(\s*(test_\w+)\s*\)", re.M) +ENTRY = re.compile(r"^\s*void\s+(run_\w+_tests)\s*\(\s*void\s*\)\s*\{", re.M) +CALLED = re.compile(r"^\s*(run_\w+_tests)\s*\(\s*\)\s*;", re.M) + + +def main() -> int: + problems = [] + total = 0 + + for path in sorted(TESTS.glob("*.cpp")): + text = path.read_text() + defined = set(DEFINED.findall(text)) + registered = REGISTERED.findall(text) + total += len(defined) + + for name in sorted(defined - set(registered)): + problems.append(f"{path}: {name} is never run -- add RUN_TEST({name})") + for name in sorted(set(registered) - defined): + problems.append(f"{path}: RUN_TEST({name}) has no such test") + for name in sorted({n for n in registered if registered.count(n) > 1}): + problems.append(f"{path}: {name} is run more than once") + + prefix = path.stem + "_" + for name in sorted(defined): + if not name.startswith(prefix): + problems.append(f"{path}: {name} should be named {prefix}*") + + # A file can be fully self-consistent and still never be reached, if main() does + # not call its entry point. + main_text = MAIN.read_text() + called = set(CALLED.findall(main_text)) + for path in sorted(TESTS.glob("*.cpp")): + entries = ENTRY.findall(path.read_text()) + if not entries: + problems.append(f"{path}: no run_*_tests() entry point") + for entry in entries: + if entry not in called: + problems.append(f"{path}: {entry}() is never called from {MAIN.name}") + + if problems: + print("\n".join(problems), file=sys.stderr) + return 1 + + print(f"{total} tests, all registered") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/test_native/README.md b/test/test_native/README.md index 1797b4e1..d78f63b6 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -205,6 +205,10 @@ is what redirects `RUN_TEST` into the forking runner. A new file's * `Accepted`: takes delivery of a server's clients and deletes whatever is left of them at scope exit. +`python3 test/check_registration.py` (run by pre-commit) fails if a test is +never registered, or is not named for its file: tests in `test_topic.cpp` are +`test_topic_*`. + `test_main.cpp` calls `mocklwip::reset()`, `mockrtos::reset()` and `mockclock::reset()` before every test, and `mocklwip::reset()` again after, so each test starts from zero pcbs, zero pbufs, no faults and `millis() == 1000`. A From 4206b2c7021e1b33f10b67fce513c3dd82fe3b5e Mon Sep 17 00:00:00 2001 From: Will Miles Date: Wed, 30 Sep 2026 23:31:26 -0400 Subject: [PATCH 05/24] test: Cover the outbound path Co-Authored-By: Claude Opus 5.5 --- test/test_native/README.md | 21 + test/test_native/mocks/include/lwip/opt.h | 15 +- test/test_native/mocks/include/lwip/tcp.h | 12 +- test/test_native/mocks/mock_lwip.cpp | 110 +++- test/test_native/mocks/mock_lwip.h | 14 +- test/test_native/test_main.cpp | 2 + test/test_native/tests/test_write.cpp | 739 ++++++++++++++++++++++ 7 files changed, 903 insertions(+), 10 deletions(-) create mode 100644 test/test_native/tests/test_write.cpp diff --git a/test/test_native/README.md b/test/test_native/README.md index d78f63b6..04717ce0 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -116,6 +116,7 @@ test/test_native/ listen_pcb(), Recorder, Accepted tests/ one file per topic; add files here, they are picked up test_client.cpp end-to-end smoke tests, one per main path + test_write.cpp the outbound path mocks/ include/ fake system headers -- on the include path FIRST, so #include "lwip/tcp.h" etc. resolve here @@ -140,6 +141,14 @@ on it: so the pointer the library sees is already dangling. * `tcp_listen_with_backlog()` allocates a **new** listen pcb and frees the one passed in. +* `tcp_write()` checks what `tcp_write_checks()` does: `ERR_CONN` outside + `ESTABLISHED`, `CLOSE_WAIT`, `SYN_SENT` and `SYN_RCVD`; `ERR_MEM` for more than + `tcp_sndbuf()`, or for more segments than `TCP_SND_QUEUELEN`. Segments are cut as + ESP-IDF's lwIP cuts them (one pbuf each, topped up to the MSS until `tcp_output()`), + and leave the queue as `fire_sent()` acks them. +* lwIP is built with window scaling, so the send buffer is 32 bits wide and + `tcp_sndbuf()` saturates at 65535, as it does in lwIP. `set_send_buffer(pcb, n)` + resizes a pcb's. * A callback handed a pcb must return `ERR_ABRT` if and only if it aborted that pcb, as `tcp_abort()` in lwIP's `tcp.c` requires. An accept callback returning any other error has the new pcb aborted, as `tcp_process()` does. @@ -241,14 +250,26 @@ To add a callback the mock does not fire yet: store the callback pointer on `tcp_*` setter in `mock_lwip.cpp`, and add a `fire_*` that checks `is_live(pcb)` and then calls it with `pcb->callback_arg`. +### Injecting failures + +`mocklwip::faults()` returns a mutable struct, reset before each test: + +```cpp +faults().write_result = ERR_MEM; +``` + ### Asserting ```cpp live_pcbs() // outstanding pcbs -- the leak check live_pbufs() is_live(pcb) +count("tcp_close") // number of calls +count("tcp_close", pcb) recved(pcb) // total bytes handed to tcp_recved(): the window reopened written(pcb) // everything handed to tcp_write(), concatenated +mockrtos::deadlocks() // non-recursive mutex re-taken -- a deadlock on target +mockclock::advance(1500) // move millis() forward ``` ## Caveats diff --git a/test/test_native/mocks/include/lwip/opt.h b/test/test_native/mocks/include/lwip/opt.h index 61c38904..6d588e1b 100644 --- a/test/test_native/mocks/include/lwip/opt.h +++ b/test/test_native/mocks/include/lwip/opt.h @@ -4,10 +4,17 @@ #include "lwip/arch.h" -#define LWIP_IPV4 1 -#define LWIP_IPV6 1 -#define TCP_MSS 1436 -#define TCP_SND_BUF (4 * TCP_MSS) +#define LWIP_IPV4 1 +#define LWIP_IPV6 1 +#define TCP_MSS 1436 +#define TCP_SND_BUF (4 * TCP_MSS) +#define TCP_SND_QUEUELEN ((4 * (TCP_SND_BUF) + (TCP_MSS - 1)) / (TCP_MSS)) + +// Window scaling, which ESP-IDF offers as CONFIG_LWIP_WND_SCALE, widens the send buffer +// past 16 bits. +#ifndef LWIP_WND_SCALE +#define LWIP_WND_SCALE 1 +#endif #define LWIP_TCPIP_CORE_LOCKING 1 #endif diff --git a/test/test_native/mocks/include/lwip/tcp.h b/test/test_native/mocks/include/lwip/tcp.h index a3894468..5f38df29 100644 --- a/test/test_native/mocks/include/lwip/tcp.h +++ b/test/test_native/mocks/include/lwip/tcp.h @@ -39,6 +39,14 @@ enum tcp_state { }; typedef u8_t tcpflags_t; + +#if LWIP_WND_SCALE +typedef u32_t tcpwnd_size_t; +#define TCPWND16(x) ((u16_t)((x) < 0xFFFF ? (x) : 0xFFFF)) +#else +typedef u16_t tcpwnd_size_t; +#define TCPWND16(x) (x) +#endif #define TF_NODELAY 0x40U // SOF_* socket options, as used by AsyncClient::setKeepAlive() @@ -58,7 +66,7 @@ struct tcp_pcb { tcpflags_t flags; u16_t mss; - u16_t snd_buf; + tcpwnd_size_t snd_buf; u16_t snd_queuelen; u32_t keep_idle; @@ -98,7 +106,7 @@ void tcp_recved(struct tcp_pcb *pcb, u16_t len); // Real lwIP implements these as macros over pcb fields; keep that shape so tests // can assert on the pcb directly. -#define tcp_sndbuf(pcb) ((pcb)->snd_buf) +#define tcp_sndbuf(pcb) (TCPWND16((pcb)->snd_buf)) #define tcp_sndqueuelen(pcb) ((pcb)->snd_queuelen) #define tcp_mss(pcb) ((pcb)->mss) #define tcp_nagle_disable(pcb) ((pcb)->flags = (tcpflags_t)((pcb)->flags | TF_NODELAY)) diff --git a/test/test_native/mocks/mock_lwip.cpp b/test/test_native/mocks/mock_lwip.cpp index db622d58..b8483949 100644 --- a/test/test_native/mocks/mock_lwip.cpp +++ b/test/test_native/mocks/mock_lwip.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include #include @@ -29,6 +30,22 @@ std::vector g_calls; std::set g_pcbs; std::set g_pbufs; std::map g_written; + +// What tcp_write() has queued on a pcb and the peer has not yet acked, one entry per +// segment. ESP-IDF builds lwIP with LWIP_NETIF_TX_SINGLE_PBUF, so every segment is one +// pbuf and every write is copied. +struct Segment { + u16_t len; + bool sent; // handed to tcp_output(), so later writes start a new segment +}; +struct SendQueue { + std::deque segs; + size_t acked = 0; // of the first segment + // As lwIP built with TCP_SND_BUF == size would have them. + tcpwnd_size_t size = TCP_SND_BUF; + size_t max_segs = TCP_SND_QUEUELEN; +}; +std::map g_sendq; std::set g_ports; std::map g_pcb_port; // ports this pcb holds mocklwip::Faults g_faults; @@ -84,6 +101,7 @@ void free_pcb(tcp_pcb *pcb) { } release_port(pcb); g_written.erase(pcb); + g_sendq.erase(pcb); g_pcbs.erase(pcb); delete pcb; } @@ -262,6 +280,7 @@ extern "C" struct tcp_pcb *tcp_listen_with_backlog(struct tcp_pcb *pcb, u8_t bac g_pcb_port.erase(it); } g_written.erase(pcb); + g_sendq.erase(pcb); g_pcbs.erase(pcb); delete pcb; return lpcb; @@ -317,6 +336,40 @@ extern "C" err_t tcp_write(struct tcp_pcb *pcb, const void *dataptr, u16_t len, if (!pcb || !dataptr) { return ERR_ARG; } + if (g_faults.write_result != ERR_OK) { + return g_faults.write_result; + } + // tcp_write_checks() + if (pcb->state != ESTABLISHED && pcb->state != CLOSE_WAIT && pcb->state != SYN_SENT && pcb->state != SYN_RCVD) { + return ERR_CONN; + } + if (len == 0) { + return ERR_OK; + } + SendQueue &q = g_sendq[pcb]; + if (len > pcb->snd_buf || pcb->snd_queuelen >= q.max_segs) { + return ERR_MEM; + } + // tcp_write(): top up the last unsent segment to the MSS, then add segments, failing + // with nothing queued if that takes more than TCP_SND_QUEUELEN. + u16_t topup = 0; + if (!q.segs.empty() && !q.segs.back().sent) { + topup = (u16_t)std::min(pcb->mss - q.segs.back().len, len); + } + std::vector added; + for (int left = len - topup; left > 0; left -= pcb->mss) { + added.push_back((u16_t)std::min(left, pcb->mss)); + } + if (pcb->snd_queuelen + added.size() > q.max_segs) { + return ERR_MEM; + } + if (topup) { + q.segs.back().len = (u16_t)(q.segs.back().len + topup); + } + for (u16_t n : added) { + q.segs.push_back(Segment{n, false}); + } + pcb->snd_queuelen = (u16_t)(pcb->snd_queuelen + added.size()); g_written[pcb].append((const char *)dataptr, len); pcb->snd_buf -= len; return ERR_OK; @@ -328,7 +381,12 @@ extern "C" err_t tcp_output(struct tcp_pcb *pcb) { if (!pcb) { return ERR_ARG; } - return ERR_OK; + if (g_faults.output_result == ERR_OK) { + for (Segment &seg : g_sendq[pcb].segs) { + seg.sent = true; + } + } + return g_faults.output_result; } extern "C" void tcp_recved(struct tcp_pcb *pcb, u16_t len) { @@ -410,6 +468,35 @@ int mocklwip::core_lock_depth() { // =========================================================================== namespace mocklwip { +size_t count(const char *fn) { + size_t n = 0; + for (const Call &c : g_calls) { + if (c.fn == fn) { + n++; + } + } + return n; +} + +size_t count(const char *fn, const void *pcb) { + size_t n = 0; + for (const Call &c : g_calls) { + if (c.fn == fn && c.pcb == pcb) { + n++; + } + } + return n; +} + +const Call *nth(const char *fn, size_t n) { + for (const Call &c : g_calls) { + if (c.fn == fn && n-- == 0) { + return &c; + } + } + return nullptr; +} + size_t recved(const tcp_pcb *pcb) { size_t total = 0; for (const Call &c : g_calls) { @@ -435,6 +522,7 @@ void reset() { } g_pcbs.clear(); g_written.clear(); + g_sendq.clear(); g_ports.clear(); g_pcb_port.clear(); g_faults = Faults{}; @@ -483,6 +571,13 @@ Faults &faults() { return g_faults; } +void set_send_buffer(tcp_pcb *pcb, uint32_t size) { + SendQueue &q = g_sendq[pcb]; + q.size = (tcpwnd_size_t)size; + q.max_segs = (4 * size + (TCP_MSS - 1)) / TCP_MSS; // lwIP's default TCP_SND_QUEUELEN + pcb->snd_buf = (tcpwnd_size_t)size; +} + pbuf *make_pbuf(const void *data, size_t len) { pbuf *p = pbuf_alloc(PBUF_TRANSPORT, (u16_t)len, PBUF_RAM); if (p && data && len) { @@ -568,7 +663,18 @@ err_t fire_sent(tcp_pcb *pcb, uint16_t len) { if (!pcb || !is_live(pcb)) { return ERR_ARG; } - pcb->snd_buf = (u16_t)((pcb->snd_buf + len > TCP_SND_BUF) ? TCP_SND_BUF : pcb->snd_buf + len); + // tcp_receive(): a segment leaves the queue once all of it is acked. + SendQueue &q = g_sendq[pcb]; + pcb->snd_buf = (pcb->snd_buf + len > q.size) ? q.size : pcb->snd_buf + len; + q.acked += len; + while (!q.segs.empty() && q.acked >= q.segs.front().len) { + q.acked -= q.segs.front().len; + q.segs.pop_front(); + pcb->snd_queuelen--; + } + if (q.segs.empty()) { + q.acked = 0; + } if (!pcb->sent) { return ERR_OK; } diff --git a/test/test_native/mocks/mock_lwip.h b/test/test_native/mocks/mock_lwip.h index 36ce66ad..09f88d88 100644 --- a/test/test_native/mocks/mock_lwip.h +++ b/test/test_native/mocks/mock_lwip.h @@ -27,6 +27,10 @@ struct Call { long b; // second scalar argument (apiflags, ...) }; +size_t count(const char *fn); +size_t count(const char *fn, const void *pcb); +// Nth (0-based) recorded call to , or nullptr. +const Call *nth(const char *fn, size_t n); // Total bytes handed to tcp_recved() for : how much window was reopened. size_t recved(const tcp_pcb *pcb); @@ -46,6 +50,10 @@ size_t live_pbufs(); // Everything a pcb was handed to tcp_write(), in order, kept after tcp_close(). std::string written(const tcp_pcb *pcb); +// Empties 's send buffer and resizes it, with the queue limit lwIP would derive +// for that TCP_SND_BUF. Above 65535 only with LWIP_WND_SCALE. +void set_send_buffer(tcp_pcb *pcb, uint32_t size); + // How many times the TCPIP core lock is held. 0 whenever no api call is running. int core_lock_depth(); @@ -53,8 +61,10 @@ int core_lock_depth(); // Failure injection. All are reset by reset(). // --------------------------------------------------------------------------- struct Faults { - err_t dns_result = 0; // ERR_OK (immediate) or an error - uint32_t dns_addr = 0; // address returned for an immediate ERR_OK lookup + err_t write_result = 0; // ERR_OK; set ERR_MEM to fail tcp_write + err_t output_result = 0; // ERR_OK + err_t dns_result = 0; // ERR_OK (immediate) or an error + uint32_t dns_addr = 0; // address returned for an immediate ERR_OK lookup }; Faults &faults(); diff --git a/test/test_native/test_main.cpp b/test/test_native/test_main.cpp index dcfb3f61..b8a1159a 100644 --- a/test/test_native/test_main.cpp +++ b/test/test_native/test_main.cpp @@ -42,6 +42,7 @@ #endif void run_client_tests(void); +void run_write_tests(void); namespace { @@ -325,6 +326,7 @@ int main(int argc, char **argv) { UNITY_BEGIN(); run_client_tests(); + run_write_tests(); if (g_list_only) { return 0; diff --git a/test/test_native/tests/test_write.cpp b/test/test_native/tests/test_write.cpp new file mode 100644 index 00000000..cab859db --- /dev/null +++ b/test/test_native/tests/test_write.cpp @@ -0,0 +1,739 @@ +// AsyncClient's outbound path: space(), canSend(), add(), send(), write(), onAck. + +#include "fixtures.h" + +#include +#include + +using namespace mocklwip; + +namespace { + +// The apiflags argument of the nth (0-based) tcp_write() call. +long write_flags(size_t n) { + const Call *call = nth("tcp_write", n); + TEST_ASSERT_NOT_NULL(call); + return call->b; +} + +} // namespace + +// --------------------------------------------------------------------------- +// space() +// --------------------------------------------------------------------------- + +static void test_write_space_is_zero_before_connect(void) { + AsyncClient c; + TEST_ASSERT_EQUAL_size_t(0, c.space()); +} + +static void test_write_space_is_zero_while_connecting(void) { + AsyncClient c; + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + // The pcb is in SYN_SENT: there is nowhere for data to go yet. + tcp_pcb *pcb = dialled_pcb(); + TEST_ASSERT_NOT_NULL(pcb); + TEST_ASSERT_EQUAL_INT((int)SYN_SENT, (int)pcb->state); + TEST_ASSERT_EQUAL_size_t(0, c.space()); + c.close(); +} + +static void test_write_space_is_the_lwip_send_buffer_once_connected(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + TEST_ASSERT_EQUAL_size_t(tcp_sndbuf(pcb), c.space()); + TEST_ASSERT_TRUE(c.space() > 0); + c.close(); +} + +static void test_write_space_is_zero_after_close(void) { + AsyncClient c; + establish(c); + c.close(); + TEST_ASSERT_NULL(c.pcb()); + TEST_ASSERT_EQUAL_size_t(0, c.space()); +} + +static void test_write_space_shrinks_by_what_was_queued(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + const size_t before = c.space(); + + TEST_ASSERT_EQUAL_size_t(5, c.add("hello", 5)); + TEST_ASSERT_EQUAL_size_t(before - 5, c.space()); + TEST_ASSERT_EQUAL_size_t(before - 5, tcp_sndbuf(pcb)); + + c.close(); +} + +static void test_write_space_is_restored_when_the_peer_acks(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + const size_t sndbuf = c.space(); + + std::string full(sndbuf, 'x'); + TEST_ASSERT_EQUAL_size_t(sndbuf, c.write(full.data(), full.size())); + TEST_ASSERT_EQUAL_size_t(0, c.space()); + + // lwIP frees send-buffer space as the peer acks it. + fire_sent(pcb, 100); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(100, c.space()); + + c.close(); +} + +// --------------------------------------------------------------------------- +// canSend() +// --------------------------------------------------------------------------- + +static void test_write_canSend_is_false_before_connect(void) { + AsyncClient c; + TEST_ASSERT_FALSE(c.canSend()); +} + +static void test_write_canSend_is_false_while_connecting(void) { + AsyncClient c; + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + TEST_ASSERT_FALSE(c.canSend()); + c.close(); +} + +static void test_write_canSend_is_true_once_connected(void) { + AsyncClient c; + establish(c); + TEST_ASSERT_TRUE(c.canSend()); + c.close(); +} + +static void test_write_canSend_is_false_when_the_send_buffer_is_full(void) { + AsyncClient c; + establish(c); + std::string full(c.space(), 'x'); + TEST_ASSERT_EQUAL_size_t(full.size(), c.add(full.data(), full.size())); + TEST_ASSERT_FALSE(c.canSend()); + c.close(); +} + +static void test_write_canSend_is_false_after_close(void) { + AsyncClient c; + establish(c); + c.close(); + TEST_ASSERT_FALSE(c.canSend()); +} + +// --------------------------------------------------------------------------- +// add() +// --------------------------------------------------------------------------- + +static void test_write_add_returns_what_it_queued(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + TEST_ASSERT_EQUAL_size_t(5, c.add("hello", 5)); + TEST_ASSERT_EQUAL_STRING("hello", written(pcb).c_str()); + TEST_ASSERT_EQUAL_size_t(1, count("tcp_write", pcb)); + c.close(); +} + +static void test_write_add_does_not_output(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + TEST_ASSERT_EQUAL_size_t(5, c.add("hello", 5)); + // add() is tcp_write() only; nothing goes on the wire until send(). + TEST_ASSERT_EQUAL_size_t(0, count("tcp_output", pcb)); + c.close(); +} + +static void test_write_add_beyond_space_queues_only_what_fits(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + const size_t sndbuf = c.space(); + + std::string big(sndbuf + 100, 'x'); + TEST_ASSERT_EQUAL_size_t(sndbuf, c.add(big.data(), big.size())); + TEST_ASSERT_EQUAL_size_t(sndbuf, written(pcb).size()); + TEST_ASSERT_EQUAL_size_t(0, c.space()); + + c.close(); +} + +static void test_write_add_queues_nothing_when_the_buffer_is_full(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + std::string full(c.space(), 'x'); + c.add(full.data(), full.size()); + + TEST_ASSERT_EQUAL_size_t(0, c.add("more", 4)); + // The full-buffer case is rejected before lwIP is asked. + TEST_ASSERT_EQUAL_size_t(1, count("tcp_write", pcb)); + + c.close(); +} + +static void test_write_add_of_zero_bytes_queues_nothing(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + TEST_ASSERT_EQUAL_size_t(0, c.add("hello", 0)); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_write", pcb)); + c.close(); +} + +static void test_write_add_of_null_queues_nothing(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + TEST_ASSERT_EQUAL_size_t(0, c.add(nullptr, 4)); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_write", pcb)); + c.close(); +} + +static void test_write_add_before_connect_queues_nothing(void) { + AsyncClient c; + TEST_ASSERT_EQUAL_size_t(0, c.add("hello", 5)); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_write")); +} + +static void test_write_add_while_connecting_queues_nothing(void) { + AsyncClient c; + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + TEST_ASSERT_EQUAL_size_t(0, c.add("hello", 5)); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_write")); + c.close(); +} + +static void test_write_add_after_close_queues_nothing(void) { + AsyncClient c; + establish(c); + c.close(); + TEST_ASSERT_EQUAL_size_t(0, c.add("hello", 5)); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_write")); +} + +static void test_write_add_queues_nothing_when_lwip_rejects_the_write(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + faults().write_result = ERR_MEM; + + TEST_ASSERT_EQUAL_size_t(0, c.add("hello", 5)); + TEST_ASSERT_EQUAL_size_t(1, count("tcp_write", pcb)); + TEST_ASSERT_EQUAL_STRING("", written(pcb).c_str()); + + c.close(); +} + +static void test_write_add_defaults_to_copying_the_data(void) { + AsyncClient c; + establish(c); + c.add("hello", 5); + // The default apiflags reach lwIP as TCP_WRITE_FLAG_COPY, so the caller's buffer + // need not outlive the call. + TEST_ASSERT_EQUAL_INT(TCP_WRITE_FLAG_COPY, (int)write_flags(0)); + c.close(); +} + +static void test_write_add_without_the_copy_flag_passes_it_through_unset(void) { + AsyncClient c; + establish(c); + // Zero apiflags: lwIP keeps a reference to the caller's buffer rather than copying. + c.add("hello", 5, 0); + TEST_ASSERT_EQUAL_INT(0, (int)write_flags(0)); + c.close(); +} + +static void test_write_add_passes_the_more_flag_through(void) { + AsyncClient c; + establish(c); + c.add("hello", 5, ASYNC_WRITE_FLAG_COPY | ASYNC_WRITE_FLAG_MORE); + // ASYNC_WRITE_FLAG_MORE is lwIP's TCP_WRITE_FLAG_MORE: suppress PSH, more to come. + TEST_ASSERT_EQUAL_INT(TCP_WRITE_FLAG_COPY | TCP_WRITE_FLAG_MORE, (int)write_flags(0)); + c.close(); +} + +// --------------------------------------------------------------------------- +// send() +// --------------------------------------------------------------------------- + +static void test_write_several_adds_reach_the_pcb_in_order_on_one_send(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + + TEST_ASSERT_EQUAL_size_t(3, c.add("one", 3)); + TEST_ASSERT_EQUAL_size_t(3, c.add("two", 3)); + TEST_ASSERT_EQUAL_size_t(5, c.add("three", 5)); + TEST_ASSERT_TRUE(c.send()); + + TEST_ASSERT_EQUAL_STRING("onetwothree", written(pcb).c_str()); + TEST_ASSERT_EQUAL_size_t(3, count("tcp_write", pcb)); + // One flush for the batch, which is the whole point of add() + send(). + TEST_ASSERT_EQUAL_size_t(1, count("tcp_output", pcb)); + + c.close(); +} + +static void test_write_send_with_nothing_added_succeeds(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + // tcp_output() on a pcb with an empty send queue is a successful no-op in lwIP. + TEST_ASSERT_TRUE(c.send()); + TEST_ASSERT_EQUAL_size_t(1, count("tcp_output", pcb)); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_write", pcb)); + c.close(); +} + +static void test_write_send_before_connect_fails(void) { + AsyncClient c; + TEST_ASSERT_FALSE(c.send()); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_output")); +} + +static void test_write_send_after_close_fails(void) { + AsyncClient c; + establish(c); + c.close(); + TEST_ASSERT_FALSE(c.send()); +} + +static void test_write_send_fails_when_tcp_output_fails(void) { + AsyncClient c; + establish(c); + faults().output_result = ERR_MEM; + TEST_ASSERT_FALSE(c.send()); + c.close(); +} + +// --------------------------------------------------------------------------- +// write() +// --------------------------------------------------------------------------- + +static void test_write_queues_and_flushes_in_one_call(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + + TEST_ASSERT_EQUAL_size_t(4, c.write("abcd", 4)); + TEST_ASSERT_EQUAL_STRING("abcd", written(pcb).c_str()); + TEST_ASSERT_EQUAL_size_t(1, count("tcp_write", pcb)); + TEST_ASSERT_EQUAL_size_t(1, count("tcp_output", pcb)); + + c.close(); +} + +static void test_write_truncates_to_the_available_space(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + const size_t sndbuf = c.space(); + + std::string big(sndbuf * 2, 'x'); + TEST_ASSERT_EQUAL_size_t(sndbuf, c.write(big.data(), big.size())); + TEST_ASSERT_EQUAL_size_t(sndbuf, written(pcb).size()); + + // The rest is the caller's problem, and there is no room for it yet. + TEST_ASSERT_EQUAL_size_t(0, c.write(big.data(), big.size())); + TEST_ASSERT_EQUAL_size_t(sndbuf, written(pcb).size()); + + c.close(); +} + +static void test_write_returns_zero_when_the_buffer_is_full(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + std::string full(c.space(), 'x'); + c.write(full.data(), full.size()); + + TEST_ASSERT_EQUAL_size_t(0, c.write("more", 4)); + // Nothing was queued, so nothing should have been flushed for it either. + TEST_ASSERT_EQUAL_size_t(1, count("tcp_output", pcb)); + + c.close(); +} + +static void test_write_cstring_writes_the_whole_string(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + TEST_ASSERT_EQUAL_size_t(11, c.write("hello world")); + TEST_ASSERT_EQUAL_STRING("hello world", written(pcb).c_str()); + c.close(); +} + +static void test_write_cstring_of_null_writes_nothing(void) { + AsyncClient c; + establish(c); + TEST_ASSERT_EQUAL_size_t(0, c.write((const char *)nullptr)); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_write")); + c.close(); +} + +static void test_write_before_connect_writes_nothing(void) { + AsyncClient c; + TEST_ASSERT_EQUAL_size_t(0, c.write("abcd", 4)); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_write")); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_output")); +} + +static void test_write_while_connecting_writes_nothing(void) { + AsyncClient c; + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + TEST_ASSERT_EQUAL_size_t(0, c.write("abcd", 4)); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_write")); + c.close(); +} + +static void test_write_after_close_writes_nothing(void) { + AsyncClient c; + establish(c); + c.close(); + TEST_ASSERT_EQUAL_size_t(0, c.write("abcd", 4)); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_write")); +} + +static void test_write_after_a_reset_writes_nothing(void) { + AsyncClient c; + bool wrote = true; + c.onError([&](void *, AsyncClient *cl, int8_t) { + // The pcb is already freed by the time lwIP reports a fatal error, so the write + // path has to notice that rather than follow a dangling pointer. + wrote = cl->write("abcd", 4) != 0; + }); + + tcp_pcb *pcb = establish(c); + fire_error(pcb, ERR_RST); + asynctcp_test_pump(); + + TEST_ASSERT_FALSE(wrote); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_write_from_inside_on_disconnect_writes_nothing(void) { + AsyncClient c; + bool wrote = true; + c.onDisconnect([&](void *, AsyncClient *cl) { + wrote = cl->write("abcd", 4) != 0; + }); + + tcp_pcb *pcb = establish(c); + fire_fin(pcb); + asynctcp_test_pump(); + + TEST_ASSERT_FALSE(wrote); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +// tcp_output() failing does not unqueue what tcp_write() already accepted -- lwIP +// keeps it on the pcb and sends it with the next output or ack. Reporting 0 tells +// the caller those bytes were dropped, so a caller that retries sends them twice. +static void test_write_reports_the_bytes_it_queued_when_output_fails(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + faults().output_result = ERR_MEM; + + TEST_ASSERT_EQUAL_size_t(4, c.write("abcd", 4)); + TEST_ASSERT_EQUAL_STRING("abcd", written(pcb).c_str()); + + c.close(); +} + +// lwIP also refuses a write when too many segments are already queued, however much +// buffer is left. What write() reports has to be exactly what lwIP took. +static void test_write_reports_only_what_lwip_took_when_its_queue_is_full(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + + size_t total = 0, n = 0; + do { + n = c.write("x", 1); + total += n; + } while (n && total < 1000); + + TEST_ASSERT_TRUE(total < 1000); + TEST_ASSERT_EQUAL_size_t(written(pcb).size(), total); + c.close(); +} + +static void test_write_succeeds_again_once_the_peer_acks_a_full_queue(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + size_t total = 0; + while (total < 1000 && c.write("x", 1)) { + total++; + } + + fire_sent(pcb, 1); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(1, c.write("y", 1)); + TEST_ASSERT_EQUAL_size_t(total + 1, written(pcb).size()); + + c.close(); +} + +static void test_write_after_the_peer_half_closes_is_queued(void) { + // A request that ends with the peer's FIN still gets its reply: lwIP takes writes in + // CLOSE_WAIT. + AsyncClient c; + size_t replied = 0; + c.onData([&](void *, AsyncClient *cl, void *, size_t) { + replied = cl->write("reply", 5); + }); + tcp_pcb *pcb = establish(c); + + fire_recv(pcb, "request", 7); + fire_fin(pcb); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(5, replied); + TEST_ASSERT_EQUAL_STRING("reply", written(pcb).c_str()); +} + +static void test_write_once_our_fin_is_out_writes_nothing(void) { + // lwIP refuses writes once it has sent our FIN. + AsyncClient c; + tcp_pcb *pcb = establish(c); + pcb->state = FIN_WAIT_1; + + TEST_ASSERT_EQUAL_size_t(0, c.write("abcd", 4)); + TEST_ASSERT_EQUAL_STRING("", written(pcb).c_str()); + + pcb->state = ESTABLISHED; + c.close(); +} + +#if LWIP_WND_SCALE +// tcp_write() takes at most 65535 bytes at a time, though with window scaling the send +// buffer can hold more. +static void test_write_add_reports_what_lwip_took_from_a_wide_send_buffer(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + set_send_buffer(pcb, 100000); + + std::string big(100000, 'x'); + const size_t n = c.add(big.data(), big.size()); + TEST_ASSERT_TRUE(n > 0); + TEST_ASSERT_EQUAL_size_t(written(pcb).size(), n); + + c.close(); +} + +static void test_write_reports_what_lwip_took_from_a_wide_send_buffer(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + set_send_buffer(pcb, 70000); + + std::string big(70000, 'x'); + const size_t n = c.write(big.data(), big.size()); + TEST_ASSERT_TRUE(n > 0); + TEST_ASSERT_EQUAL_size_t(written(pcb).size(), n); + + c.close(); +} + +static void test_write_wide_send_buffer_is_filled_by_successive_writes(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + set_send_buffer(pcb, 100000); + + std::string big(100000, 'x'); + size_t total = 0, n = 0; + do { + n = c.write(big.data() + total, big.size() - total); + total += n; + } while (n && total < big.size()); + + TEST_ASSERT_EQUAL_size_t(big.size(), total); + TEST_ASSERT_EQUAL_size_t(big.size(), written(pcb).size()); + + c.close(); +} +#endif + +// --------------------------------------------------------------------------- +// onAck +// --------------------------------------------------------------------------- + +static void test_write_onAck_reports_the_acked_length(void) { + AsyncClient c; + size_t acked = 0; + c.onAck([&](void *, AsyncClient *, size_t len, uint32_t) { + acked = len; + }); + + tcp_pcb *pcb = establish(c); + TEST_ASSERT_EQUAL_size_t(4, c.write("abcd", 4)); + + fire_sent(pcb, 4); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(4, acked); + + c.close(); +} + +static void test_write_onAck_waits_for_the_async_task(void) { + AsyncClient c; + int acks = 0; + c.onAck([&](void *, AsyncClient *, size_t, uint32_t) { + acks++; + }); + + tcp_pcb *pcb = establish(c); + c.write("abcd", 4); + + fire_sent(pcb, 4); + TEST_ASSERT_EQUAL_INT(0, acks); // queued, not delivered + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, acks); + + c.close(); +} + +static void test_write_onAck_fires_once_per_ack_across_several_sends(void) { + AsyncClient c; + std::vector acked; + c.onAck([&](void *, AsyncClient *, size_t len, uint32_t) { + acked.push_back(len); + }); + + tcp_pcb *pcb = establish(c); + + TEST_ASSERT_EQUAL_size_t(3, c.write("abc", 3)); + fire_sent(pcb, 3); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(2, c.write("de", 2)); + fire_sent(pcb, 2); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(2, acked.size()); + TEST_ASSERT_EQUAL_size_t(3, acked[0]); + TEST_ASSERT_EQUAL_size_t(2, acked[1]); + + c.close(); +} + +static void test_write_onAck_loses_nothing_when_two_acks_queue_together(void) { + AsyncClient c; + std::vector acked; + c.onAck([&](void *, AsyncClient *, size_t len, uint32_t) { + acked.push_back(len); + }); + + tcp_pcb *pcb = establish(c); + c.write("abcde", 5); + + // Unlike polls, no ack may be dropped: they may be merged, but every byte is reported. + fire_sent(pcb, 2); + fire_sent(pcb, 3); + asynctcp_test_pump(); + + TEST_ASSERT_TRUE(acked == std::vector({5}) || acked == std::vector({2, 3})); + + c.close(); +} + +static void test_write_onAck_reports_the_time_since_the_send(void) { + AsyncClient c; + uint32_t elapsed = 0xFFFFFFFF; + c.onAck([&](void *, AsyncClient *, size_t, uint32_t t) { + elapsed = t; + }); + + tcp_pcb *pcb = establish(c); + + mockclock::advance(100); + c.write("abc", 3); // onAck's elapsed time runs from here + mockclock::advance(40); + fire_sent(pcb, 3); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_UINT32(40, elapsed); + + // The start moves with each send, so the second ack is timed from the second one. + mockclock::advance(500); + c.write("de", 2); + mockclock::advance(7); + fire_sent(pcb, 2); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_UINT32(7, elapsed); + + c.close(); +} + +static void test_write_from_inside_onAck_does_not_deadlock(void) { + AsyncClient c; + int acks = 0; + c.onAck([&](void *, AsyncClient *cl, size_t, uint32_t) { + if (++acks == 1) { + // Re-entering the write path from a callback is the classic deadlock shape. + cl->write("more", 4); + } + }); + + tcp_pcb *pcb = establish(c); + c.write("abc", 3); + fire_sent(pcb, 3); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("abcmore", written(pcb).c_str()); + TEST_ASSERT_EQUAL_UINT(0, mockrtos::deadlocks()); + + c.close(); +} + +void run_write_tests(void) { + UnitySetTestFile(__FILE__); + + RUN_TEST(test_write_space_is_zero_before_connect); + RUN_TEST(test_write_space_is_zero_while_connecting); + RUN_TEST(test_write_space_is_the_lwip_send_buffer_once_connected); + RUN_TEST(test_write_space_is_zero_after_close); + RUN_TEST(test_write_space_shrinks_by_what_was_queued); + RUN_TEST(test_write_space_is_restored_when_the_peer_acks); + + RUN_TEST(test_write_canSend_is_false_before_connect); + RUN_TEST(test_write_canSend_is_false_while_connecting); + RUN_TEST(test_write_canSend_is_true_once_connected); + RUN_TEST(test_write_canSend_is_false_when_the_send_buffer_is_full); + RUN_TEST(test_write_canSend_is_false_after_close); + + RUN_TEST(test_write_add_returns_what_it_queued); + RUN_TEST(test_write_add_does_not_output); + RUN_TEST(test_write_add_beyond_space_queues_only_what_fits); + RUN_TEST(test_write_add_queues_nothing_when_the_buffer_is_full); + RUN_TEST(test_write_add_of_zero_bytes_queues_nothing); + RUN_TEST(test_write_add_of_null_queues_nothing); + RUN_TEST(test_write_add_before_connect_queues_nothing); + RUN_TEST(test_write_add_while_connecting_queues_nothing); + RUN_TEST(test_write_add_after_close_queues_nothing); + RUN_TEST(test_write_add_queues_nothing_when_lwip_rejects_the_write); + RUN_TEST(test_write_add_defaults_to_copying_the_data); + RUN_TEST(test_write_add_without_the_copy_flag_passes_it_through_unset); + RUN_TEST(test_write_add_passes_the_more_flag_through); + + RUN_TEST(test_write_several_adds_reach_the_pcb_in_order_on_one_send); + RUN_TEST(test_write_send_with_nothing_added_succeeds); + RUN_TEST(test_write_send_before_connect_fails); + RUN_TEST(test_write_send_after_close_fails); + RUN_TEST(test_write_send_fails_when_tcp_output_fails); + + RUN_TEST(test_write_queues_and_flushes_in_one_call); + RUN_TEST(test_write_truncates_to_the_available_space); + RUN_TEST(test_write_returns_zero_when_the_buffer_is_full); + RUN_TEST(test_write_cstring_writes_the_whole_string); + RUN_TEST(test_write_cstring_of_null_writes_nothing); + RUN_TEST(test_write_before_connect_writes_nothing); + RUN_TEST(test_write_while_connecting_writes_nothing); + RUN_TEST(test_write_after_close_writes_nothing); + RUN_TEST(test_write_after_a_reset_writes_nothing); + RUN_TEST(test_write_from_inside_on_disconnect_writes_nothing); + RUN_TEST(test_write_reports_the_bytes_it_queued_when_output_fails); + RUN_TEST(test_write_reports_only_what_lwip_took_when_its_queue_is_full); + RUN_TEST(test_write_succeeds_again_once_the_peer_acks_a_full_queue); + RUN_TEST(test_write_after_the_peer_half_closes_is_queued); + RUN_TEST(test_write_once_our_fin_is_out_writes_nothing); +#if LWIP_WND_SCALE + RUN_TEST(test_write_add_reports_what_lwip_took_from_a_wide_send_buffer); + RUN_TEST(test_write_reports_what_lwip_took_from_a_wide_send_buffer); + RUN_TEST(test_write_wide_send_buffer_is_filled_by_successive_writes); +#endif + + RUN_TEST(test_write_onAck_reports_the_acked_length); + RUN_TEST(test_write_onAck_waits_for_the_async_task); + RUN_TEST(test_write_onAck_fires_once_per_ack_across_several_sends); + RUN_TEST(test_write_onAck_loses_nothing_when_two_acks_queue_together); + RUN_TEST(test_write_onAck_reports_the_time_since_the_send); + RUN_TEST(test_write_from_inside_onAck_does_not_deadlock); +} From 4aca8980ee594c6ddf051426e59613970c4951e2 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Wed, 30 Sep 2026 23:32:09 -0400 Subject: [PATCH 06/24] test: Cover the inbound path Co-Authored-By: Claude Opus 5.5 --- test/test_native/README.md | 3 + test/test_native/mocks/include/lwip/pbuf.h | 1 + test/test_native/mocks/mock_lwip.cpp | 26 + test/test_native/mocks/mock_lwip.h | 8 + test/test_native/test_main.cpp | 2 + test/test_native/tests/test_recv.cpp | 738 +++++++++++++++++++++ 6 files changed, 778 insertions(+) create mode 100644 test/test_native/tests/test_recv.cpp diff --git a/test/test_native/README.md b/test/test_native/README.md index 04717ce0..c716b6e4 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -117,6 +117,7 @@ test/test_native/ tests/ one file per topic; add files here, they are picked up test_client.cpp end-to-end smoke tests, one per main path test_write.cpp the outbound path + test_recv.cpp the inbound path mocks/ include/ fake system headers -- on the include path FIRST, so #include "lwip/tcp.h" etc. resolve here @@ -237,6 +238,7 @@ whole point — it is where the queue-handling bugs live. ```cpp fire_connected(pcb, ERR_OK); // tcp_connected_fn; moves pcb to ESTABLISHED fire_recv(pcb, "data", 4); // tcp_recv_fn with a pbuf the mock allocates +fire_recv_pbuf(pcb, chain); // ... or with a chain you built via make_pbuf() fire_fin(pcb); // tcp_recv_fn with a NULL pbuf fire_sent(pcb, 4); // tcp_sent_fn fire_error(pcb, ERR_RST); // frees pcb, then tcp_err_fn -- pcb is dead after @@ -267,6 +269,7 @@ is_live(pcb) count("tcp_close") // number of calls count("tcp_close", pcb) recved(pcb) // total bytes handed to tcp_recved(): the window reopened +saw("tcp_recved", pcb, 42) // called on this pcb with first arg 42 written(pcb) // everything handed to tcp_write(), concatenated mockrtos::deadlocks() // non-recursive mutex re-taken -- a deadlock on target mockclock::advance(1500) // move millis() forward diff --git a/test/test_native/mocks/include/lwip/pbuf.h b/test/test_native/mocks/include/lwip/pbuf.h index 16afeab9..36ab0e0c 100644 --- a/test/test_native/mocks/include/lwip/pbuf.h +++ b/test/test_native/mocks/include/lwip/pbuf.h @@ -34,6 +34,7 @@ struct pbuf { struct pbuf *pbuf_alloc(pbuf_layer l, u16_t length, pbuf_type type); u8_t pbuf_free(struct pbuf *p); +void pbuf_cat(struct pbuf *head, struct pbuf *tail); #ifdef __cplusplus } diff --git a/test/test_native/mocks/mock_lwip.cpp b/test/test_native/mocks/mock_lwip.cpp index b8483949..880c5e0b 100644 --- a/test/test_native/mocks/mock_lwip.cpp +++ b/test/test_native/mocks/mock_lwip.cpp @@ -180,6 +180,19 @@ extern "C" u8_t pbuf_free(struct pbuf *p) { return freed; } +extern "C" void pbuf_cat(struct pbuf *head, struct pbuf *tail) { + if (!head || !tail) { + return; + } + pbuf *p = head; + while (p->next) { + p->tot_len = (u16_t)(p->tot_len + tail->tot_len); + p = p->next; + } + p->tot_len = (u16_t)(p->tot_len + tail->tot_len); + p->next = tail; +} + // =========================================================================== // tcp // =========================================================================== @@ -468,6 +481,10 @@ int mocklwip::core_lock_depth() { // =========================================================================== namespace mocklwip { +const std::vector &calls() { + return g_calls; +} + size_t count(const char *fn) { size_t n = 0; for (const Call &c : g_calls) { @@ -488,6 +505,15 @@ size_t count(const char *fn, const void *pcb) { return n; } +bool saw(const char *fn, const void *pcb, long a) { + for (const Call &c : g_calls) { + if (c.fn == fn && c.pcb == pcb && c.a == a) { + return true; + } + } + return false; +} + const Call *nth(const char *fn, size_t n) { for (const Call &c : g_calls) { if (c.fn == fn && n-- == 0) { diff --git a/test/test_native/mocks/mock_lwip.h b/test/test_native/mocks/mock_lwip.h index 09f88d88..221c61b8 100644 --- a/test/test_native/mocks/mock_lwip.h +++ b/test/test_native/mocks/mock_lwip.h @@ -27,8 +27,11 @@ struct Call { long b; // second scalar argument (apiflags, ...) }; +const std::vector &calls(); size_t count(const char *fn); size_t count(const char *fn, const void *pcb); +// True if was called on with first scalar argument == a. +bool saw(const char *fn, const void *pcb, long a); // Nth (0-based) recorded call to , or nullptr. const Call *nth(const char *fn, size_t n); // Total bytes handed to tcp_recved() for : how much window was reopened. @@ -87,6 +90,8 @@ void fire_error(tcp_pcb *pcb, err_t err); // Delivers bytes to tcp_recv_fn. Allocates the pbuf for you. err_t fire_recv(tcp_pcb *pcb, const void *data, size_t len, err_t err = 0); +// Delivers an already-built pbuf chain (ownership passes to the library). +err_t fire_recv_pbuf(tcp_pcb *pcb, pbuf *p, err_t err = 0); // Remote FIN: tcp_recv_fn with a NULL pbuf. err_t fire_fin(tcp_pcb *pcb, err_t err = 0); @@ -100,6 +105,9 @@ err_t fire_sent(tcp_pcb *pcb, uint16_t len); tcp_pcb *fire_accept(tcp_pcb *listen_pcb, err_t err = 0); tcp_pcb *fire_accept(tcp_pcb *listen_pcb, const ip_addr_t &peer, uint16_t port, err_t err = 0); +// Convenience: build a pbuf holding bytes of . +pbuf *make_pbuf(const void *data, size_t len); + // Non-zero if the library made an api call while standing on the LwIP thread, which on a // real target would deadlock. unsigned reentrant_api_calls(); diff --git a/test/test_native/test_main.cpp b/test/test_native/test_main.cpp index b8a1159a..8f618d04 100644 --- a/test/test_native/test_main.cpp +++ b/test/test_native/test_main.cpp @@ -43,6 +43,7 @@ void run_client_tests(void); void run_write_tests(void); +void run_recv_tests(void); namespace { @@ -327,6 +328,7 @@ int main(int argc, char **argv) { run_client_tests(); run_write_tests(); + run_recv_tests(); if (g_list_only) { return 0; diff --git a/test/test_native/tests/test_recv.cpp b/test/test_native/tests/test_recv.cpp new file mode 100644 index 00000000..7b39cd82 --- /dev/null +++ b/test/test_native/tests/test_recv.cpp @@ -0,0 +1,738 @@ +// AsyncClient inbound data path: onData, onPacket, and window management. + +#include "fixtures.h" + +#include +#include +#include + +extern "C" { +#include "lwip/pbuf.h" +} + +using namespace mocklwip; + +namespace { + +// Position of the first call to in the log, or SIZE_MAX if it never happened. +size_t call_index(const char *fn) { + const std::vector &log = calls(); + for (size_t i = 0; i < log.size(); i++) { + if (log[i].fn == fn) { + return i; + } + } + return SIZE_MAX; +} + +// "aaa" + "bb" + "c": a three-segment chain, as lwIP would hand over reassembled +// out-of-order segments. +pbuf *three_segment_chain() { + pbuf *p = make_pbuf("aaa", 3); + pbuf_cat(p, make_pbuf("bb", 2)); + pbuf_cat(p, make_pbuf("c", 1)); + TEST_ASSERT_EQUAL_UINT16(6, p->tot_len); + return p; +} + +} // namespace + +// --------------------------------------------------------------------------- +// onData +// --------------------------------------------------------------------------- + +static void test_recv_onData_delivers_the_payload_and_acks_the_window(void) { + AsyncClient c; + std::string got; + c.onData([&](void *, AsyncClient *, void *data, size_t len) { + got.assign((const char *)data, len); + }); + tcp_pcb *pcb = establish(c); + + fire_recv(pcb, "hello world", 11); + TEST_ASSERT_EQUAL_STRING("", got.c_str()); // still queued + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("hello world", got.c_str()); + TEST_ASSERT_EQUAL_size_t(11, recved(pcb)); + // The library owns the pbuf in the onData path and must release it. + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + + c.close(); +} + +static void test_recv_with_no_handler_still_acks_the_window(void) { + // No onData, no onPacket. The data is dropped, but the window has to reopen or the + // peer stalls for good. + AsyncClient c; + tcp_pcb *pcb = establish(c); + + fire_recv(pcb, "unwanted", 8); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(8, recved(pcb)); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + + c.close(); +} + +static void test_recv_delivers_each_packet_separately(void) { + AsyncClient c; + std::vector got; + c.onData([&](void *, AsyncClient *, void *data, size_t len) { + got.push_back(std::string((const char *)data, len)); + }); + tcp_pcb *pcb = establish(c); + + fire_recv(pcb, "one", 3); + fire_recv(pcb, "two", 3); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(2, got.size()); + TEST_ASSERT_EQUAL_STRING("one", got[0].c_str()); + TEST_ASSERT_EQUAL_STRING("two", got[1].c_str()); + TEST_ASSERT_EQUAL_size_t(6, recved(pcb)); + + c.close(); +} + +static void test_recv_chain_is_delivered_in_order_and_in_full(void) { + // A chain is split: onData runs once per segment, and between them the payload must + // read back as the original byte stream. + AsyncClient c; + std::string got; + int calls_made = 0; + c.onData([&](void *, AsyncClient *, void *data, size_t len) { + calls_made++; + got.append((const char *)data, len); + }); + tcp_pcb *pcb = establish(c); + + fire_recv_pbuf(pcb, three_segment_chain()); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_INT(3, calls_made); + TEST_ASSERT_EQUAL_STRING("aaabbc", got.c_str()); + TEST_ASSERT_EQUAL_size_t(6, recved(pcb)); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + + c.close(); +} + +// --------------------------------------------------------------------------- +// ackLater() / ack() +// --------------------------------------------------------------------------- + +static void test_recv_ackLater_withholds_the_ack_until_ack_is_called(void) { + AsyncClient c; + c.onData([&](void *, AsyncClient *cl, void *, size_t) { + cl->ackLater(); + }); + tcp_pcb *pcb = establish(c); + + fire_recv(pcb, "0123456789", 10); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(0, recved(pcb)); + // Withholding the ack must not hold on to the pbuf: onData copied what it wanted. + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + + TEST_ASSERT_EQUAL_size_t(10, c.ack(10)); + TEST_ASSERT_EQUAL_size_t(10, recved(pcb)); + + c.close(); +} + +static void test_recv_partial_ack_leaves_the_rest_owed(void) { + AsyncClient c; + c.onData([&](void *, AsyncClient *cl, void *, size_t) { + cl->ackLater(); + }); + tcp_pcb *pcb = establish(c); + + fire_recv(pcb, "0123456789", 10); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(4, c.ack(4)); + TEST_ASSERT_EQUAL_size_t(4, recved(pcb)); + TEST_ASSERT_EQUAL_size_t(6, c.ack(6)); + TEST_ASSERT_EQUAL_size_t(10, recved(pcb)); + + c.close(); +} + +static void test_recv_ack_is_clamped_to_what_is_owed(void) { + AsyncClient c; + c.onData([&](void *, AsyncClient *cl, void *, size_t) { + cl->ackLater(); + }); + tcp_pcb *pcb = establish(c); + + fire_recv(pcb, "0123456789", 10); + asynctcp_test_pump(); + + // Acking more than arrived would reopen a window the peer never filled. + TEST_ASSERT_EQUAL_size_t(10, c.ack(1000)); + TEST_ASSERT_EQUAL_size_t(10, recved(pcb)); + // ...and nothing is left owed. + TEST_ASSERT_EQUAL_size_t(0, c.ack(10)); + TEST_ASSERT_EQUAL_size_t(10, recved(pcb)); + + c.close(); +} + +static void test_recv_ack_without_ackLater_acks_nothing(void) { + // The packet was already acked automatically; a stray ack() must not double-count it. + AsyncClient c; + c.onData([&](void *, AsyncClient *, void *, size_t) {}); + tcp_pcb *pcb = establish(c); + + fire_recv(pcb, "abcde", 5); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(5, recved(pcb)); + + TEST_ASSERT_EQUAL_size_t(0, c.ack(5)); + TEST_ASSERT_EQUAL_size_t(5, recved(pcb)); + + c.close(); +} + +static void test_recv_ackLater_applies_only_to_the_current_packet(void) { + // ackLater() is armed per packet, so the next one acks by itself. + AsyncClient c; + int packets = 0; + c.onData([&](void *, AsyncClient *cl, void *, size_t) { + if (++packets == 1) { + cl->ackLater(); + } + }); + tcp_pcb *pcb = establish(c); + + fire_recv(pcb, "abc", 3); + fire_recv(pcb, "defg", 4); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_INT(2, packets); + TEST_ASSERT_EQUAL_size_t(4, recved(pcb)); // only the second + + TEST_ASSERT_EQUAL_size_t(3, c.ack(3)); + TEST_ASSERT_EQUAL_size_t(7, recved(pcb)); + + c.close(); +} + +static void test_recv_close_from_onData_acks_the_packet_in_flight(void) { + // Closing without acking what the application just consumed makes lwIP send an RST + // rather than a FIN, so close() has to flush the packet it is standing on. + AsyncClient c; + tcp_pcb *pcb = establish(c); + c.onData([&](void *, AsyncClient *cl, void *, size_t) { + cl->ackLater(); + cl->close(); + }); + + fire_recv(pcb, "abcde", 5); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(5, recved(pcb)); + TEST_ASSERT_TRUE(call_index("tcp_recved") < call_index("tcp_close")); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +// --------------------------------------------------------------------------- +// onPacket / ackPacket +// --------------------------------------------------------------------------- + +static void test_recv_onPacket_receives_the_raw_pbuf(void) { + AsyncClient c; + std::string got; + int packets = 0; + c.onPacket([&](void *, AsyncClient *cl, pbuf *pb) { + packets++; + got.assign((const char *)pb->payload, pb->len); + cl->ackPacket(pb); + }); + tcp_pcb *pcb = establish(c); + + fire_recv(pcb, "raw bytes", 9); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_INT(1, packets); + TEST_ASSERT_EQUAL_STRING("raw bytes", got.c_str()); + + c.close(); +} + +static void test_recv_ackPacket_frees_the_pbuf_exactly_once(void) { + // The whole point of the zero-copy path: the callback owns the pbuf, and ackPacket() + // is the one and only release. A second free shows up as a use-after-free under + // ASan; a missing one shows up as a non-zero live_pbufs(). + AsyncClient c; + c.onPacket([&](void *, AsyncClient *cl, pbuf *pb) { + cl->ackPacket(pb); + }); + tcp_pcb *pcb = establish(c); + + fire_recv(pcb, "zero copy", 9); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + TEST_ASSERT_EQUAL_size_t(9, recved(pcb)); + + c.close(); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_recv_onPacket_leaves_the_pbuf_to_the_callback(void) { + // Documented contract: without ackPacket() nothing is freed and nothing is acked -- + // the application is holding the packet. + AsyncClient c; + pbuf *held = nullptr; + c.onPacket([&](void *, AsyncClient *, pbuf *pb) { + held = pb; + }); + tcp_pcb *pcb = establish(c); + + fire_recv(pcb, "held", 4); + asynctcp_test_pump(); + + TEST_ASSERT_NOT_NULL(held); + TEST_ASSERT_EQUAL_size_t(1, live_pbufs()); + TEST_ASSERT_EQUAL_size_t(0, recved(pcb)); + + c.ackPacket(held); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + TEST_ASSERT_EQUAL_size_t(4, recved(pcb)); + + c.close(); +} + +static void test_recv_onPacket_takes_precedence_over_onData(void) { + // "called if onPacket is not used" -- onData must stay silent, and the pbuf stays the + // packet handler's, so nothing is freed or acked behind its back. + AsyncClient c; + int packets = 0, data_calls = 0; + c.onPacket([&](void *, AsyncClient *cl, pbuf *pb) { + packets++; + cl->ackPacket(pb); + }); + c.onData([&](void *, AsyncClient *, void *, size_t) { + data_calls++; + }); + tcp_pcb *pcb = establish(c); + + fire_recv(pcb, "either", 6); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_INT(1, packets); + TEST_ASSERT_EQUAL_INT(0, data_calls); + TEST_ASSERT_EQUAL_size_t(6, recved(pcb)); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + + c.close(); +} + +static void test_recv_onPacket_sees_each_chain_segment_singly(void) { + AsyncClient c; + std::string got; + int packets = 0; + bool all_detached = true; + c.onPacket([&](void *, AsyncClient *cl, pbuf *pb) { + packets++; + // Each segment is handed over on its own, so the callback can free it without + // dragging the rest of the chain with it. + all_detached = all_detached && (pb->next == nullptr); + got.append((const char *)pb->payload, pb->len); + cl->ackPacket(pb); + }); + tcp_pcb *pcb = establish(c); + + fire_recv_pbuf(pcb, three_segment_chain()); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_INT(3, packets); + TEST_ASSERT_TRUE(all_detached); + TEST_ASSERT_EQUAL_STRING("aaabbc", got.c_str()); + TEST_ASSERT_EQUAL_size_t(6, recved(pcb)); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + + c.close(); +} + +static void test_recv_onPacket_delivers_each_chain_unbroken_by_another_connection(void) { + // Two chains on one connection, then a packet on another: that packet may come before, + // between or after the chains, but never inside one. + std::string log; + auto record = [&](void *, AsyncClient *cl, pbuf *pb) { + log.append((const char *)pb->payload, pb->len); + cl->ackPacket(pb); + }; + AsyncClient a; + AsyncClient b; + a.onPacket(record); + b.onPacket(record); + tcp_pcb *pa = establish(a); + tcp_pcb *pb = establish(b); + + auto chain = [](const char *x, const char *y, const char *z) { + pbuf *p = make_pbuf(x, 2); + pbuf_cat(p, make_pbuf(y, 2)); + pbuf_cat(p, make_pbuf(z, 2)); + return p; + }; + fire_recv_pbuf(pa, chain("a1", "a2", "a3")); + fire_recv_pbuf(pa, chain("a4", "a5", "a6")); + fire_recv(pb, "b1", 2); + asynctcp_test_pump(); + + TEST_ASSERT_TRUE(log == "b1a1a2a3a4a5a6" || log == "a1a2a3b1a4a5a6" || log == "a1a2a3a4a5a6b1"); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + + a.close(); + b.close(); +} + +static void test_recv_onPacket_delivers_a_self_consistent_pbuf(void) { + // lwIP's invariant: tot_len is this pbuf plus everything chained after it, so the + // last (here: only) pbuf of a chain has tot_len == len. Splitting a chain is + // pbuf_dechain(), which fixes tot_len up; a segment handed to onPacket with a stale + // tot_len tells the application there are bytes after it that it can no longer reach. + AsyncClient c; + std::vector lens, tot_lens; + c.onPacket([&](void *, AsyncClient *cl, pbuf *pb) { + lens.push_back(pb->len); + tot_lens.push_back(pb->tot_len); + cl->ackPacket(pb); + }); + tcp_pcb *pcb = establish(c); + + fire_recv_pbuf(pcb, three_segment_chain()); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(3, lens.size()); + for (size_t i = 0; i < lens.size(); i++) { + TEST_ASSERT_EQUAL_UINT16(lens[i], tot_lens[i]); + } + + c.close(); +} + +static void test_recv_ackPacket_after_close_still_frees_the_pbuf(void) { + // There is no window left to reopen, but the packet is still the application's to + // give back, and ackPacket() is the only way it can. + AsyncClient c; + pbuf *held = nullptr; + c.onPacket([&](void *, AsyncClient *, pbuf *pb) { + held = pb; + }); + tcp_pcb *pcb = establish(c); + + fire_recv(pcb, "late", 4); + asynctcp_test_pump(); + TEST_ASSERT_NOT_NULL(held); + + c.close(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(1, live_pbufs()); + + c.ackPacket(held); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + TEST_ASSERT_EQUAL_size_t(0, recved(pcb)); +} + +static void test_recv_ackPacket_ignores_a_null_pbuf(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + + c.ackPacket(nullptr); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_recved", pcb)); + + c.close(); +} + +// --------------------------------------------------------------------------- +// FIN and teardown +// --------------------------------------------------------------------------- + +static void test_recv_data_before_fin_is_delivered_before_the_disconnect(void) { + AsyncClient c; + int order = 0, data_order = 0, disc_order = 0; + std::string got; + c.onData([&](void *, AsyncClient *, void *data, size_t len) { + data_order = ++order; + got.assign((const char *)data, len); + }); + c.onDisconnect([&](void *, AsyncClient *) { + disc_order = ++order; + }); + tcp_pcb *pcb = establish(c); + + // Both arrive from lwIP before the async task gets a turn. + fire_recv(pcb, "last words", 10); + fire_fin(pcb); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("last words", got.c_str()); + TEST_ASSERT_EQUAL_INT(1, data_order); + TEST_ASSERT_EQUAL_INT(2, disc_order); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_recv_fin_acks_data_withheld_by_ackLater(void) { + // The application did consume the data, so the close has to carry the ack with it -- + // otherwise the peer sees unacknowledged data on a closing connection and resets. + AsyncClient c; + c.onData([&](void *, AsyncClient *cl, void *, size_t) { + cl->ackLater(); + }); + tcp_pcb *pcb = establish(c); + + fire_recv(pcb, "consumed", 8); + fire_fin(pcb); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(8, recved(pcb)); + TEST_ASSERT_TRUE(call_index("tcp_recved") < call_index("tcp_close")); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_recv_fin_alone_delivers_no_data(void) { + // A NULL pbuf is lwIP's end-of-stream marker, not a zero-length packet. + AsyncClient c; + int data_calls = 0, packets = 0, disconnects = 0; + c.onData([&](void *, AsyncClient *, void *, size_t) { + data_calls++; + }); + c.onPacket([&](void *, AsyncClient *cl, pbuf *pb) { + packets++; + cl->ackPacket(pb); + }); + c.onDisconnect([&](void *, AsyncClient *) { + disconnects++; + }); + tcp_pcb *pcb = establish(c); + + fire_fin(pcb); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_INT(0, data_calls); + TEST_ASSERT_EQUAL_INT(0, packets); + TEST_ASSERT_EQUAL_INT(1, disconnects); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_recv_queued_data_is_released_when_the_application_closes(void) { + // The data is still waiting for the async task. Closing drops it, and the pbuf has + // to go with it. + AsyncClient c; + int data_calls = 0; + c.onData([&](void *, AsyncClient *, void *, size_t) { + data_calls++; + }); + tcp_pcb *pcb = establish(c); + + fire_recv(pcb, "in flight", 9); + TEST_ASSERT_EQUAL_size_t(1, live_pbufs()); + + c.close(); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(0, data_calls); +} + +static void test_recv_queued_data_is_released_when_the_peer_resets(void) { + AsyncClient c; + int data_calls = 0, errors = 0; + c.onData([&](void *, AsyncClient *, void *, size_t) { + data_calls++; + }); + c.onError([&](void *, AsyncClient *, int8_t) { + errors++; + }); + tcp_pcb *pcb = establish(c); + + fire_recv(pcb, "in flight", 9); + TEST_ASSERT_EQUAL_size_t(1, live_pbufs()); + + // The reset ends the connection before the data is delivered, so it never is. + fire_error(pcb, ERR_RST); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(0, data_calls); + TEST_ASSERT_EQUAL_INT(1, errors); +} + +static void test_recv_after_close_reaches_nobody(void) { + // close() takes the library's recv callback off the pcb and hands the pcb to lwIP, so + // a late arrival has nowhere to go and nothing to leak. + AsyncClient c; + int data_calls = 0; + c.onData([&](void *, AsyncClient *, void *, size_t) { + data_calls++; + }); + tcp_pcb *pcb = establish(c); + + c.close(); + TEST_ASSERT_NULL(c.pcb()); + + TEST_ASSERT_EQUAL_INT((int)ERR_ARG, (int)fire_recv(pcb, "too late", 8)); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(0, data_calls); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +// --------------------------------------------------------------------------- +// The rest of a chain, when the callback tears the connection down partway through +// --------------------------------------------------------------------------- + +static void test_recv_chain_is_abandoned_when_onData_closes(void) { + AsyncClient c; + int data_calls = 0; + tcp_pcb *pcb = establish(c); + c.onData([&](void *, AsyncClient *cl, void *, size_t) { + if (++data_calls == 1) { + cl->close(); + } + }); + + fire_recv_pbuf(pcb, three_segment_chain()); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_INT(1, data_calls); + // The two segments never delivered are the library's to free. + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + // Only the segment the application actually saw is acked. + TEST_ASSERT_EQUAL_size_t(3, recved(pcb)); +} + +static void test_recv_chain_is_abandoned_when_onPacket_closes(void) { + AsyncClient c; + int packets = 0; + tcp_pcb *pcb = establish(c); + c.onPacket([&](void *, AsyncClient *cl, pbuf *pb) { + packets++; + cl->ackPacket(pb); + cl->close(); + }); + + fire_recv_pbuf(pcb, three_segment_chain()); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_INT(1, packets); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(3, recved(pcb)); +} + +static void test_recv_chain_is_not_delivered_to_a_reconnected_client(void) { + // The callback closes and reconnects, so the rest of the chain belongs to a pcb the + // client has let go. It must not be delivered, and its bytes must not be acked + // against the connection that replaced it. + AsyncClient c; + AsyncClient decoy; + int data_calls = 0; + bool reconnected = false; + tcp_pcb *first = establish(c); + c.onData([&](void *, AsyncClient *cl, void *, size_t) { + if (++data_calls == 1) { + cl->close(); + // The decoy takes the pcb address the close just freed. Without it the allocator + // hands the same address straight back, and the two connections' acks could not + // be told apart. + decoy.connect(kPeer, 9999); + reconnected = cl->connect(kPeer, kPort); + } + }); + + fire_recv_pbuf(first, three_segment_chain()); + asynctcp_test_pump(); + + TEST_ASSERT_TRUE(reconnected); + tcp_pcb *second = dialled_pcb(); + TEST_ASSERT_NOT_NULL(second); + TEST_ASSERT_TRUE(second != first); + + TEST_ASSERT_EQUAL_INT(1, data_calls); + TEST_ASSERT_EQUAL_size_t(3, recved(first)); + TEST_ASSERT_EQUAL_size_t(0, recved(second)); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + + c.close(); + decoy.close(); +} + +// --------------------------------------------------------------------------- +// Edge cases +// --------------------------------------------------------------------------- + +static void test_recv_empty_pbuf_is_not_acked_and_does_not_leak(void) { + // Real lwIP never delivers a zero-length data pbuf, but the API takes one: it must + // not reopen a window nobody consumed, and it must not leak. + AsyncClient c; + c.onData([&](void *, AsyncClient *, void *, size_t) {}); + tcp_pcb *pcb = establish(c); + + fire_recv(pcb, nullptr, 0); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(0, recved(pcb)); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + TEST_ASSERT_TRUE(c.connected()); + + c.close(); +} + +static void test_recv_client_destroyed_with_data_queued_leaks_nothing(void) { + AsyncClient *c = new AsyncClient(); + int data_calls = 0; + c->onData([&](void *, AsyncClient *, void *, size_t) { + data_calls++; + }); + tcp_pcb *pcb = establish(*c); + + fire_recv(pcb, "orphaned", 8); + delete c; + + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(0, data_calls); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +void run_recv_tests(void) { + UnitySetTestFile(__FILE__); + RUN_TEST(test_recv_onData_delivers_the_payload_and_acks_the_window); + RUN_TEST(test_recv_with_no_handler_still_acks_the_window); + RUN_TEST(test_recv_delivers_each_packet_separately); + RUN_TEST(test_recv_chain_is_delivered_in_order_and_in_full); + RUN_TEST(test_recv_ackLater_withholds_the_ack_until_ack_is_called); + RUN_TEST(test_recv_partial_ack_leaves_the_rest_owed); + RUN_TEST(test_recv_ack_is_clamped_to_what_is_owed); + RUN_TEST(test_recv_ack_without_ackLater_acks_nothing); + RUN_TEST(test_recv_ackLater_applies_only_to_the_current_packet); + RUN_TEST(test_recv_close_from_onData_acks_the_packet_in_flight); + RUN_TEST(test_recv_onPacket_receives_the_raw_pbuf); + RUN_TEST(test_recv_ackPacket_frees_the_pbuf_exactly_once); + RUN_TEST(test_recv_onPacket_leaves_the_pbuf_to_the_callback); + RUN_TEST(test_recv_onPacket_takes_precedence_over_onData); + RUN_TEST(test_recv_onPacket_sees_each_chain_segment_singly); + RUN_TEST(test_recv_onPacket_delivers_each_chain_unbroken_by_another_connection); + RUN_TEST(test_recv_onPacket_delivers_a_self_consistent_pbuf); + RUN_TEST(test_recv_ackPacket_after_close_still_frees_the_pbuf); + RUN_TEST(test_recv_ackPacket_ignores_a_null_pbuf); + RUN_TEST(test_recv_data_before_fin_is_delivered_before_the_disconnect); + RUN_TEST(test_recv_fin_acks_data_withheld_by_ackLater); + RUN_TEST(test_recv_fin_alone_delivers_no_data); + RUN_TEST(test_recv_queued_data_is_released_when_the_application_closes); + RUN_TEST(test_recv_queued_data_is_released_when_the_peer_resets); + RUN_TEST(test_recv_after_close_reaches_nobody); + RUN_TEST(test_recv_chain_is_abandoned_when_onData_closes); + RUN_TEST(test_recv_chain_is_abandoned_when_onPacket_closes); + RUN_TEST(test_recv_chain_is_not_delivered_to_a_reconnected_client); + RUN_TEST(test_recv_empty_pbuf_is_not_acked_and_does_not_leak); + RUN_TEST(test_recv_client_destroyed_with_data_queued_leaks_nothing); +} From d26c1ac94623f780a66133d6309613fe0cbb9895 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Wed, 30 Sep 2026 23:32:41 -0400 Subject: [PATCH 07/24] test: Cover the timers Co-Authored-By: Claude Opus 5.5 --- test/test_native/README.md | 2 + test/test_native/fixtures.h | 20 +- test/test_native/mocks/mock_lwip.cpp | 14 + test/test_native/mocks/mock_lwip.h | 1 + test/test_native/test_main.cpp | 2 + test/test_native/tests/test_timeout.cpp | 978 ++++++++++++++++++++++++ 6 files changed, 1013 insertions(+), 4 deletions(-) create mode 100644 test/test_native/tests/test_timeout.cpp diff --git a/test/test_native/README.md b/test/test_native/README.md index c716b6e4..b04a5ca8 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -118,6 +118,7 @@ test/test_native/ test_client.cpp end-to-end smoke tests, one per main path test_write.cpp the outbound path test_recv.cpp the inbound path + test_timeout.cpp the rx and ack timeouts, onPoll, keepalive mocks/ include/ fake system headers -- on the include path FIRST, so #include "lwip/tcp.h" etc. resolve here @@ -241,6 +242,7 @@ fire_recv(pcb, "data", 4); // tcp_recv_fn with a pbuf the mock allocates fire_recv_pbuf(pcb, chain); // ... or with a chain you built via make_pbuf() fire_fin(pcb); // tcp_recv_fn with a NULL pbuf fire_sent(pcb, 4); // tcp_sent_fn +fire_poll(pcb); // tcp_poll_fn fire_error(pcb, ERR_RST); // frees pcb, then tcp_err_fn -- pcb is dead after fire_accept(listen_pcb); // builds a new ESTABLISHED pcb, offers it fire_accept(listen_pcb, peer, 41234); // ... from the peer at that ip_addr_t and port diff --git a/test/test_native/fixtures.h b/test/test_native/fixtures.h index 1d02c6b7..cb527d78 100644 --- a/test/test_native/fixtures.h +++ b/test/test_native/fixtures.h @@ -5,6 +5,7 @@ #include "runner.h" +#include #include #include #include @@ -48,16 +49,17 @@ inline tcp_pcb *listen_pcb(uint16_t port = 0) { // silence. // // C onConnect E onError D onDisconnect R onData -// A onAck +// A onAck T onTimeout // // attach() registers only the callbacks it is given -- by default the three that open and // end a connection -- because registering a handler can change what the library does. class Recorder { public: std::string seq; - int8_t err = 0; // what the last onError was given - std::string data; // everything onData was given, concatenated - size_t acked = 0; // what the last onAck was given + int8_t err = 0; // what the last onError was given + std::string data; // everything onData was given, concatenated + size_t acked = 0; // what the last onAck was given + uint32_t timed_out = 0; // what the last onTimeout was given Recorder() = default; Recorder(const Recorder &) = delete; @@ -92,12 +94,22 @@ class Recorder { acked = n; }); } + if (strchr(events, 'T')) { + c.onTimeout([this](void *, AsyncClient *cl, uint32_t t) { + note('T', cl); + timed_out = t; + }); + } } // Records for client , as the handlers attach() registers do. void note(char event, const AsyncClient *c, int8_t e = 0) { seq += event; } + + int count(char event) const { + return (int)std::count(seq.begin(), seq.end(), event); + } }; // Takes delivery of a server's clients, in order, and deletes whatever is left of them when diff --git a/test/test_native/mocks/mock_lwip.cpp b/test/test_native/mocks/mock_lwip.cpp index 880c5e0b..ae04124e 100644 --- a/test/test_native/mocks/mock_lwip.cpp +++ b/test/test_native/mocks/mock_lwip.cpp @@ -710,6 +710,20 @@ err_t fire_sent(tcp_pcb *pcb, uint16_t len) { return r; } +err_t fire_poll(tcp_pcb *pcb) { + on_lwip_thread lwip; + if (!pcb || !is_live(pcb)) { + return ERR_ARG; + } + if (!pcb->poll) { + return ERR_OK; + } + const size_t mark = g_aborted.size(); + err_t r = pcb->poll(pcb->callback_arg, pcb); + check_return("poll", pcb, mark, r); + return r; +} + tcp_pcb *fire_accept(tcp_pcb *listen_pcb, err_t err) { ip_addr_t peer; memset(&peer, 0, sizeof(peer)); diff --git a/test/test_native/mocks/mock_lwip.h b/test/test_native/mocks/mock_lwip.h index 221c61b8..429fe21e 100644 --- a/test/test_native/mocks/mock_lwip.h +++ b/test/test_native/mocks/mock_lwip.h @@ -96,6 +96,7 @@ err_t fire_recv_pbuf(tcp_pcb *pcb, pbuf *p, err_t err = 0); err_t fire_fin(tcp_pcb *pcb, err_t err = 0); err_t fire_sent(tcp_pcb *pcb, uint16_t len); +err_t fire_poll(tcp_pcb *pcb); // Builds a fresh ESTABLISHED pcb and offers it to the listening pcb's // tcp_accept_fn. Returns the new pcb (which may already have been freed by the diff --git a/test/test_native/test_main.cpp b/test/test_native/test_main.cpp index 8f618d04..86a9474d 100644 --- a/test/test_native/test_main.cpp +++ b/test/test_native/test_main.cpp @@ -44,6 +44,7 @@ void run_client_tests(void); void run_write_tests(void); void run_recv_tests(void); +void run_timeout_tests(void); namespace { @@ -329,6 +330,7 @@ int main(int argc, char **argv) { run_client_tests(); run_write_tests(); run_recv_tests(); + run_timeout_tests(); if (g_list_only) { return 0; diff --git a/test/test_native/tests/test_timeout.cpp b/test/test_native/tests/test_timeout.cpp new file mode 100644 index 00000000..a891c1b0 --- /dev/null +++ b/test/test_native/tests/test_timeout.cpp @@ -0,0 +1,978 @@ +// AsyncClient's timers: the rx timeout, the ack timeout, onPoll and TCP keepalive. +// +// All three are driven by lwIP's poll callback, which lwIP calls on a timer and which the +// harness stands in for with fire_poll() + a pump. Time only moves when a test moves it, so an +// "idle connection" here is a fire_poll() after a mockclock::advance(). + +#include "fixtures.h" + +using namespace mocklwip; + +namespace { + +// One round of "lwIP's poll timer went off". +void poll(tcp_pcb *pcb) { + fire_poll(pcb); + asynctcp_test_pump(); +} + +} // namespace + +// --------------------------------------------------------------------------- +// The accessors +// --------------------------------------------------------------------------- + +static void test_timeout_rx_timeout_defaults_to_disabled(void) { + AsyncClient c; + TEST_ASSERT_EQUAL_UINT32(0, c.getRxTimeout()); +} + +static void test_timeout_rx_timeout_round_trips(void) { + AsyncClient c; + c.setRxTimeout(30); + TEST_ASSERT_EQUAL_UINT32(30, c.getRxTimeout()); + c.setRxTimeout(0); // 0 is the "off" value, not a zero-length timeout + TEST_ASSERT_EQUAL_UINT32(0, c.getRxTimeout()); +} + +static void test_timeout_ack_timeout_defaults_to_the_configured_maximum(void) { + AsyncClient c; + TEST_ASSERT_EQUAL_UINT32(CONFIG_ASYNC_TCP_MAX_ACK_TIME, c.getAckTimeout()); +} + +static void test_timeout_ack_timeout_round_trips(void) { + AsyncClient c; + c.setAckTimeout(250); + TEST_ASSERT_EQUAL_UINT32(250, c.getAckTimeout()); + c.setAckTimeout(0); + TEST_ASSERT_EQUAL_UINT32(0, c.getAckTimeout()); +} + +static void test_timeout_settings_survive_a_connection(void) { + // Both are properties of the client, not of the connection, so connecting and closing + // must not reset them. + AsyncClient c; + c.setRxTimeout(7); + c.setAckTimeout(123); + + establish(c); + TEST_ASSERT_EQUAL_UINT32(7, c.getRxTimeout()); + TEST_ASSERT_EQUAL_UINT32(123, c.getAckTimeout()); + + c.close(); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_UINT32(7, c.getRxTimeout()); + TEST_ASSERT_EQUAL_UINT32(123, c.getAckTimeout()); +} + +// --------------------------------------------------------------------------- +// The rx timeout +// --------------------------------------------------------------------------- + +static void test_timeout_rx_timeout_closes_an_idle_connection(void) { + AsyncClient c; + int disconnects = 0; + int errors = 0; + c.onDisconnect([&](void *, AsyncClient *) { + disconnects++; + }); + c.onError([&](void *, AsyncClient *, int8_t) { + errors++; + }); + + tcp_pcb *pcb = establish(c); + c.setRxTimeout(2); + + mockclock::advance(2000); + poll(pcb); + + // An orderly close, not an abort: the application hears onDisconnect only. + TEST_ASSERT_EQUAL_INT(1, disconnects); + TEST_ASSERT_EQUAL_INT(0, errors); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_TRUE(c.disconnected()); +} + +static void test_timeout_rx_timeout_is_measured_in_seconds(void) { + // setRxTimeout() takes seconds while setAckTimeout() takes milliseconds; the + // boundary is inclusive. + AsyncClient c; + tcp_pcb *pcb = establish(c); + c.setRxTimeout(2); + + mockclock::advance(1999); + poll(pcb); + TEST_ASSERT_TRUE(is_live(pcb)); + + mockclock::advance(1); + poll(pcb); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_timeout_rx_timeout_of_zero_never_closes(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + c.setRxTimeout(0); + + mockclock::advance(3600000); // an hour of silence + poll(pcb); + + TEST_ASSERT_TRUE(c.connected()); + c.close(); +} + +static void test_timeout_rx_timeout_is_refreshed_by_arriving_data(void) { + // Each packet counts as activity, so a connection that keeps receiving is never idle + // however long it lives. + AsyncClient c; + int disconnects = 0; + c.onData([&](void *, AsyncClient *, void *, size_t) {}); + c.onDisconnect([&](void *, AsyncClient *) { + disconnects++; + }); + tcp_pcb *pcb = establish(c); + c.setRxTimeout(2); + + for (int i = 0; i < 4; i++) { + mockclock::advance(1500); + fire_recv(pcb, "alive", 5); + asynctcp_test_pump(); + poll(pcb); + TEST_ASSERT_TRUE(is_live(pcb)); + } + TEST_ASSERT_EQUAL_INT(0, disconnects); + TEST_ASSERT_TRUE(c.connected()); + + // 6s of traffic, then 2s of silence, and it goes. + mockclock::advance(2000); + poll(pcb); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_timeout_rx_timeout_is_refreshed_by_an_ack(void) { + // A bare ack is still a packet from the peer, so it proves the peer is alive. + AsyncClient c; + tcp_pcb *pcb = establish(c); + c.setRxTimeout(2); + c.write("x", 1); + + mockclock::advance(1500); + fire_sent(pcb, 1); + asynctcp_test_pump(); + + mockclock::advance(1000); // 2500ms since connect, 1000ms since the ack + poll(pcb); + TEST_ASSERT_TRUE(is_live(pcb)); + + c.close(); +} + +static void test_timeout_rx_timeout_runs_from_the_handshake_not_the_dial(void) { + // The idle period starts when the handshake lands, not at connect(); a slow handshake + // must not eat into it. + AsyncClient c; + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + tcp_pcb *pcb = dialled_pcb(); + + mockclock::advance(3000); + fire_connected(pcb); + asynctcp_test_pump(); + c.setRxTimeout(2); + + mockclock::advance(1500); // 4500ms since connect(), 1500ms since ESTABLISHED + poll(pcb); + TEST_ASSERT_TRUE(is_live(pcb)); + + mockclock::advance(500); + poll(pcb); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_timeout_rx_timeout_is_not_applied_while_connecting(void) { + // No rx timeout before ESTABLISHED: lwIP owns the SYN timeout. + AsyncClient c; + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + tcp_pcb *pcb = dialled_pcb(); + c.setRxTimeout(1); + + mockclock::advance(30000); + poll(pcb); + + TEST_ASSERT_TRUE(is_live(pcb)); + c.close(); +} + +// --------------------------------------------------------------------------- +// The ack timeout +// --------------------------------------------------------------------------- + +static void test_timeout_ack_timeout_reports_the_age_of_the_unacked_send(void) { + AsyncClient c; + uint32_t reported = 0; + AsyncClient *seen = nullptr; + c.onTimeout([&](void *, AsyncClient *self, uint32_t t) { + reported = t; + seen = self; + }); + + tcp_pcb *pcb = establish(c); + c.setAckTimeout(100); + c.write("x", 1); + + mockclock::advance(250); + poll(pcb); + + TEST_ASSERT_EQUAL_UINT32(250, reported); + TEST_ASSERT_EQUAL_PTR(&c, seen); + c.close(); +} + +static void test_timeout_ack_timeout_passes_the_callback_argument(void) { + AsyncClient c; + int marker = 0; + void *arg = nullptr; + c.onTimeout( + [&](void *a, AsyncClient *, uint32_t) { + arg = a; + }, + &marker + ); + + tcp_pcb *pcb = establish(c); + c.setAckTimeout(100); + c.write("x", 1); + + mockclock::advance(250); + poll(pcb); + + TEST_ASSERT_EQUAL_PTR(&marker, arg); + c.close(); +} + +static void test_timeout_ack_timeout_falls_between_the_deadlines_of_two_unacked_sends(void) { + // With two sends unacked, the timeout may run from either: not before the first is a + // full period old, and no later than the second is. + AsyncClient c; + uint32_t reported = 0; + int timeouts = 0; + c.onTimeout([&](void *, AsyncClient *, uint32_t t) { + reported = t; + timeouts++; + }); + + tcp_pcb *pcb = establish(c); + c.setAckTimeout(350); + c.write("a", 1); + + mockclock::advance(300); + c.write("b", 1); + + mockclock::advance(40); // 340ms since the first send + poll(pcb); + TEST_ASSERT_EQUAL_INT(0, timeouts); + + mockclock::advance(310); // 650ms since the first send, 350ms since the second + poll(pcb); + TEST_ASSERT_EQUAL_INT(1, timeouts); + TEST_ASSERT_UINT32_WITHIN(150, 500, reported); // 350..650ms + + c.close(); +} + +static void test_timeout_ack_timeout_does_not_fire_once_the_peer_acks(void) { + // An ack newer than the last send means nothing is outstanding to time out. + AsyncClient c; + int timeouts = 0; + c.onTimeout([&](void *, AsyncClient *, uint32_t) { + timeouts++; + }); + + tcp_pcb *pcb = establish(c); + c.setAckTimeout(100); + c.write("x", 1); + + mockclock::advance(10); + fire_sent(pcb, 1); + asynctcp_test_pump(); + + mockclock::advance(5000); + poll(pcb); + + TEST_ASSERT_EQUAL_INT(0, timeouts); + TEST_ASSERT_TRUE(c.connected()); + c.close(); +} + +static void test_timeout_ack_timeout_does_not_fire_without_a_send(void) { + AsyncClient c; + int timeouts = 0; + c.onTimeout([&](void *, AsyncClient *, uint32_t) { + timeouts++; + }); + + tcp_pcb *pcb = establish(c); + c.setAckTimeout(100); + + mockclock::advance(5000); + poll(pcb); + + TEST_ASSERT_EQUAL_INT(0, timeouts); + c.close(); +} + +static void test_timeout_ack_timeout_of_zero_disables_it(void) { + AsyncClient c; + int timeouts = 0; + c.onTimeout([&](void *, AsyncClient *, uint32_t) { + timeouts++; + }); + + tcp_pcb *pcb = establish(c); + c.setAckTimeout(0); + c.write("x", 1); + + mockclock::advance(3600000); + poll(pcb); + + TEST_ASSERT_EQUAL_INT(0, timeouts); + TEST_ASSERT_TRUE(c.connected()); + c.close(); +} + +static void test_timeout_ack_timeout_leaves_the_connection_up(void) { + // onTimeout is a report, not a teardown: the library never closes on its own, so + // with no handler registered the connection simply stays up. + AsyncClient c; + int disconnects = 0; + c.onDisconnect([&](void *, AsyncClient *) { + disconnects++; + }); + + tcp_pcb *pcb = establish(c); + c.setAckTimeout(100); + c.write("x", 1); + + mockclock::advance(5000); + poll(pcb); + + TEST_ASSERT_EQUAL_INT(0, disconnects); + TEST_ASSERT_TRUE(c.connected()); + c.close(); +} + +static void test_timeout_ack_timeout_repeats_until_the_handler_acts(void) { + // Nothing clears the condition but an ack or a close, so every poll re-reports it + // with a longer elapsed time. + AsyncClient c; + int timeouts = 0; + uint32_t last = 0; + c.onTimeout([&](void *, AsyncClient *, uint32_t t) { + timeouts++; + last = t; + }); + + tcp_pcb *pcb = establish(c); + c.setAckTimeout(100); + c.write("x", 1); + + mockclock::advance(150); + poll(pcb); + mockclock::advance(150); + poll(pcb); + mockclock::advance(150); + poll(pcb); + + TEST_ASSERT_EQUAL_INT(3, timeouts); + TEST_ASSERT_EQUAL_UINT32(450, last); + c.close(); +} + +static void test_timeout_ack_timeout_handler_may_close_the_connection(void) { + // The usual handler: close from inside the callback, which runs on the async task + // with the poll event still in flight. + AsyncClient c; + int timeouts = 0; + int disconnects = 0; + c.onTimeout([&](void *, AsyncClient *self, uint32_t) { + timeouts++; + self->close(); + }); + c.onDisconnect([&](void *, AsyncClient *) { + disconnects++; + }); + + tcp_pcb *pcb = establish(c); + c.setAckTimeout(100); + c.write("x", 1); + + mockclock::advance(150); + poll(pcb); + + TEST_ASSERT_EQUAL_INT(1, timeouts); + TEST_ASSERT_EQUAL_INT(1, disconnects); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_TRUE(c.disconnected()); +} + +static void test_timeout_ack_timeout_handler_may_abort_the_connection(void) { + AsyncClient c; + int errors = 0; + c.onTimeout([&](void *, AsyncClient *self, uint32_t) { + self->abort(); + }); + c.onError([&](void *, AsyncClient *, int8_t err) { + errors++; + TEST_ASSERT_EQUAL_INT(ERR_ABRT, err); + }); + + tcp_pcb *pcb = establish(c); + c.setAckTimeout(100); + c.write("x", 1); + + mockclock::advance(150); + poll(pcb); + + TEST_ASSERT_EQUAL_INT(1, errors); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_timeout_ack_timeout_arms_for_a_send_at_millis_zero(void) { + // A send that lands on millis() == 0 must be timed out like any other. millis() is 0 + // at boot and again every 49.7 days when it wraps. + mockclock::set_millis(0); + AsyncClient c; + int timeouts = 0; + c.onTimeout([&](void *, AsyncClient *, uint32_t) { + timeouts++; + }); + + tcp_pcb *pcb = establish(c); + c.setAckTimeout(100); + c.write("x", 1); + + mockclock::advance(5000); + poll(pcb); + + TEST_ASSERT_EQUAL_INT(1, timeouts); + c.close(); +} + +// --------------------------------------------------------------------------- +// What starts the ack timeout +// --------------------------------------------------------------------------- + +static void test_timeout_queuing_without_sending_does_not_arm_the_ack_timeout(void) { + // add() fills the send buffer; send() is what puts it on the wire and starts the + // clock on it. + AsyncClient c; + int timeouts = 0; + c.onTimeout([&](void *, AsyncClient *, uint32_t) { + timeouts++; + }); + + tcp_pcb *pcb = establish(c); + c.setAckTimeout(100); + + // A completed round trip first, so the "disarmed" state below is a real ack rather + // than the never-sent-anything default. + c.write("a", 1); + mockclock::advance(10); + fire_sent(pcb, 1); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(1, c.add("b", 1)); + mockclock::advance(500); + poll(pcb); + TEST_ASSERT_EQUAL_INT(0, timeouts); + + TEST_ASSERT_TRUE(c.send()); + mockclock::advance(500); + poll(pcb); + TEST_ASSERT_EQUAL_INT(1, timeouts); + + c.close(); +} + +static void test_timeout_a_send_lwip_cannot_output_still_times_out(void) { + // lwIP keeps data it couldn't output and retries it, so that data waits on an ack like + // any other. A connection that never gets it out times out. + AsyncClient c; + int timeouts = 0; + c.onTimeout([&](void *, AsyncClient *, uint32_t) { + timeouts++; + }); + + tcp_pcb *pcb = establish(c); + c.setAckTimeout(100); + + c.write("a", 1); + mockclock::advance(10); + fire_sent(pcb, 1); + asynctcp_test_pump(); + + faults().output_result = ERR_MEM; + mockclock::advance(100); + c.write("b", 1); + + mockclock::advance(100); + poll(pcb); + TEST_ASSERT_EQUAL_INT(1, timeouts); + + c.close(); +} + +static void test_timeout_an_unacked_send_does_not_carry_into_the_next_connection(void) { + // An unacked write on the previous connection must not time out the new one. + AsyncClient c; + int timeouts = 0; + c.onTimeout([&](void *, AsyncClient *, uint32_t) { + timeouts++; + }); + + establish(c); + c.setAckTimeout(100); + c.write("x", 1); // never acked + c.close(); + asynctcp_test_pump(); + + mockclock::advance(10000); + tcp_pcb *pcb = establish(c); + mockclock::advance(500); + poll(pcb); + + TEST_ASSERT_EQUAL_INT(0, timeouts); + c.close(); +} + +// --------------------------------------------------------------------------- +// onPoll +// --------------------------------------------------------------------------- + +static void test_timeout_onPoll_runs_once_per_delivered_poll(void) { + AsyncClient c; + int polls = 0; + c.onPoll([&](void *, AsyncClient *) { + polls++; + }); + + tcp_pcb *pcb = establish(c); + + for (int i = 0; i < 3; i++) { + poll(pcb); + TEST_ASSERT_EQUAL_INT(i + 1, polls); + } + + c.close(); +} + +static void test_timeout_onPoll_needs_the_async_task(void) { + // fire_poll() only queues; nothing reaches the application until the task runs. + AsyncClient c; + int polls = 0; + c.onPoll([&](void *, AsyncClient *) { + polls++; + }); + + tcp_pcb *pcb = establish(c); + fire_poll(pcb); + TEST_ASSERT_EQUAL_INT(0, polls); + + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, polls); + + c.close(); +} + +static void test_timeout_queued_polls_coalesce_into_one(void) { + // A backlog of polls means the task fell behind; replaying them all would only make + // it worse, so consecutive polls for one client run onPoll once. + AsyncClient c; + int polls = 0; + c.onPoll([&](void *, AsyncClient *) { + polls++; + }); + + tcp_pcb *pcb = establish(c); + fire_poll(pcb); + fire_poll(pcb); + fire_poll(pcb); + + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, polls); + + c.close(); +} + +static void test_timeout_polls_split_by_an_ack_neither_swallow_it_nor_multiply(void) { + // The polls may merge across the ack, but the ack survives. + AsyncClient c; + int polls = 0; + int acks = 0; + c.onPoll([&](void *, AsyncClient *) { + polls++; + }); + c.onAck([&](void *, AsyncClient *, size_t, uint32_t) { + acks++; + }); + + tcp_pcb *pcb = establish(c); + c.setAckTimeout(0); // keep the ack timeout out of the way + c.write("x", 1); + + fire_poll(pcb); + fire_sent(pcb, 1); + fire_poll(pcb); + + asynctcp_test_pump(); + TEST_ASSERT_TRUE(polls == 1 || polls == 2); + TEST_ASSERT_EQUAL_INT(1, acks); + + c.close(); +} + +static void test_timeout_polls_for_different_clients_do_not_coalesce(void) { + // Coalescing is per connection; one busy client must not swallow another's poll. + AsyncClient a; + AsyncClient b; + int a_polls = 0; + int b_polls = 0; + a.onPoll([&](void *, AsyncClient *) { + a_polls++; + }); + b.onPoll([&](void *, AsyncClient *) { + b_polls++; + }); + + tcp_pcb *pa = establish(a); + tcp_pcb *pb = establish(b); + + fire_poll(pa); + fire_poll(pa); + fire_poll(pb); + + // a's two merge; b's survives. + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, a_polls); + TEST_ASSERT_EQUAL_INT(1, b_polls); + + a.close(); + b.close(); +} + +static void test_timeout_onPoll_is_skipped_while_the_ack_timeout_is_firing(void) { + // onPoll is the "everything is fine" callback, so while the ack timeout is being + // reported it is suppressed, until the peer acks. + AsyncClient c; + int polls = 0; + int timeouts = 0; + c.onPoll([&](void *, AsyncClient *) { + polls++; + }); + c.onTimeout([&](void *, AsyncClient *, uint32_t) { + timeouts++; + }); + + tcp_pcb *pcb = establish(c); + c.setAckTimeout(100); + c.write("x", 1); + + mockclock::advance(150); + poll(pcb); + TEST_ASSERT_EQUAL_INT(1, timeouts); + TEST_ASSERT_EQUAL_INT(0, polls); + + fire_sent(pcb, 1); + asynctcp_test_pump(); + poll(pcb); + TEST_ASSERT_EQUAL_INT(1, timeouts); + TEST_ASSERT_EQUAL_INT(1, polls); + + c.close(); +} + +// --------------------------------------------------------------------------- +// setKeepAlive +// --------------------------------------------------------------------------- + +static void test_timeout_setKeepAlive_reaches_the_pcb(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + TEST_ASSERT_EQUAL_UINT8(0, pcb->so_options & SOF_KEEPALIVE); + + c.setKeepAlive(7000, 4); + + TEST_ASSERT_EQUAL_UINT8(SOF_KEEPALIVE, pcb->so_options & SOF_KEEPALIVE); + // One interval serves as both the idle period and the probe spacing. + TEST_ASSERT_EQUAL_UINT32(7000, pcb->keep_idle); + TEST_ASSERT_EQUAL_UINT32(7000, pcb->keep_intvl); + TEST_ASSERT_EQUAL_UINT32(4, pcb->keep_cnt); + + c.close(); +} + +static void test_timeout_setKeepAlive_zero_turns_it_off(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + c.setKeepAlive(7000, 4); + + c.setKeepAlive(0, 0); + TEST_ASSERT_EQUAL_UINT8(0, pcb->so_options & SOF_KEEPALIVE); + + c.close(); +} + +static void test_timeout_setKeepAlive_zero_ignores_the_count(void) { + // ms == 0 means off whatever the count says. + AsyncClient c; + tcp_pcb *pcb = establish(c); + c.setKeepAlive(7000, 4); + + c.setKeepAlive(0, 9); + TEST_ASSERT_EQUAL_UINT8(0, pcb->so_options & SOF_KEEPALIVE); + + c.close(); +} + +static void test_timeout_setKeepAlive_leaves_other_socket_options_alone(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + pcb->so_options |= SOF_REUSEADDR; + + c.setKeepAlive(7000, 4); + TEST_ASSERT_EQUAL_UINT8(SOF_REUSEADDR, pcb->so_options & SOF_REUSEADDR); + + c.setKeepAlive(0, 0); + TEST_ASSERT_EQUAL_UINT8(SOF_REUSEADDR, pcb->so_options & SOF_REUSEADDR); + + c.close(); +} + +static void test_timeout_setKeepAlive_before_connect_is_a_no_op(void) { + // It writes straight through to the pcb and keeps no copy, so it has to be called on + // a live connection -- before connect() it is silently lost. + AsyncClient c; + c.setKeepAlive(7000, 4); + + tcp_pcb *pcb = establish(c); + TEST_ASSERT_EQUAL_UINT8(0, pcb->so_options & SOF_KEEPALIVE); + TEST_ASSERT_EQUAL_UINT32(0, pcb->keep_idle); + + c.close(); +} + +static void test_timeout_setKeepAlive_after_close_is_a_no_op(void) { + AsyncClient c; + establish(c); + c.close(); + asynctcp_test_pump(); + TEST_ASSERT_NULL(c.pcb()); + + c.setKeepAlive(7000, 4); // must not touch the pcb it no longer owns + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +// --------------------------------------------------------------------------- +// The two timeouts together +// --------------------------------------------------------------------------- + +static void test_timeout_rx_timeout_wins_when_it_expires_first(void) { + // Unacked data present, but the rx timeout is the shorter of the two: the + // connection is torn down rather than merely reported. + AsyncClient c; + int timeouts = 0; + int disconnects = 0; + c.onTimeout([&](void *, AsyncClient *, uint32_t) { + timeouts++; + }); + c.onDisconnect([&](void *, AsyncClient *) { + disconnects++; + }); + + tcp_pcb *pcb = establish(c); + c.setRxTimeout(1); + c.setAckTimeout(5000); + c.write("x", 1); + + mockclock::advance(1000); + poll(pcb); + + TEST_ASSERT_EQUAL_INT(0, timeouts); + TEST_ASSERT_EQUAL_INT(1, disconnects); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_timeout_rx_timeout_closes_an_idle_connection_with_unacked_data(void) { + // The rx timeout is the dead-peer backstop, and an unacked send is evidence *for* a + // dead peer -- so having one outstanding must not exempt the connection from it. + AsyncClient c; + int disconnects = 0; + c.onDisconnect([&](void *, AsyncClient *) { + disconnects++; + }); + + tcp_pcb *pcb = establish(c); + c.setRxTimeout(2); + c.setAckTimeout(100); // fires first, and no handler is registered to act on it + c.write("x", 1); + + for (int i = 0; i < 10; i++) { + mockclock::advance(1000); + if (live_pcbs() == 0) { + break; + } + poll(pcb); + } + + TEST_ASSERT_EQUAL_INT(1, disconnects); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +// --------------------------------------------------------------------------- +// millis() wrapping, every 49.7 days +// --------------------------------------------------------------------------- + +static void test_timeout_rx_timeout_runs_across_a_millis_wrap(void) { + mockclock::set_millis(0xFFFFFFFFu - 999); // 1000ms before the wrap + AsyncClient c; + Recorder t; + t.attach(c); + tcp_pcb *pcb = establish(c); + c.setRxTimeout(2); + + mockclock::advance(1999); // past the wrap, 1ms short + poll(pcb); + TEST_ASSERT_TRUE(is_live(pcb)); + TEST_ASSERT_EQUAL_STRING("C", t.seq.c_str()); + + mockclock::advance(1); + poll(pcb); + TEST_ASSERT_EQUAL_STRING("CD", t.seq.c_str()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_timeout_ack_timeout_runs_across_a_millis_wrap(void) { + mockclock::set_millis(0xFFFFFFFFu - 199); + AsyncClient c; + Recorder t; + t.attach(c, "CEDT"); + tcp_pcb *pcb = establish(c); + c.setAckTimeout(100); + + // A completed round trip first, so the send below is the only thing outstanding. + c.write("a", 1); + mockclock::advance(10); + fire_sent(pcb, 1); + asynctcp_test_pump(); + + mockclock::advance(140); // 50ms before the wrap + c.write("b", 1); + mockclock::advance(99); // past the wrap, 1ms short + poll(pcb); + TEST_ASSERT_EQUAL_INT(0, t.count('T')); + + mockclock::advance(1); + poll(pcb); + TEST_ASSERT_EQUAL_INT(1, t.count('T')); + TEST_ASSERT_EQUAL_UINT32(100, t.timed_out); + c.close(); +} + +static void test_timeout_ack_timeout_arms_for_a_send_just_after_a_wrap(void) { + mockclock::set_millis(0xFFFFFFFFu - 49); + AsyncClient c; + Recorder t; + t.attach(c, "CEDT"); + tcp_pcb *pcb = establish(c); + c.setAckTimeout(100); + + c.write("a", 1); + mockclock::advance(10); + fire_sent(pcb, 1); // acked before the wrap + asynctcp_test_pump(); + + mockclock::advance(50); // just past it + c.write("b", 1); + mockclock::advance(100); + poll(pcb); + TEST_ASSERT_EQUAL_INT(1, t.count('T')); + c.close(); +} + +// --------------------------------------------------------------------------- +// An ack of part of a send holds the ack timeout off +// --------------------------------------------------------------------------- + +static void test_timeout_partial_ack_holds_off_the_ack_timeout(void) { + // A partial ack is progress: the timeout can't expire within a full period of it. + AsyncClient c; + Recorder t; + t.attach(c, "CEDT"); + tcp_pcb *pcb = establish(c); + c.setAckTimeout(100); + + TEST_ASSERT_EQUAL_size_t(10, c.write("0123456789", 10)); + mockclock::advance(10); + fire_sent(pcb, 4); + asynctcp_test_pump(); + + mockclock::advance(99); // 109ms since the send, 99ms since the ack + poll(pcb); + TEST_ASSERT_EQUAL_INT(0, t.count('T')); + c.close(); +} + +void run_timeout_tests(void) { + UnitySetTestFile(__FILE__); + + RUN_TEST(test_timeout_rx_timeout_defaults_to_disabled); + RUN_TEST(test_timeout_rx_timeout_round_trips); + RUN_TEST(test_timeout_ack_timeout_defaults_to_the_configured_maximum); + RUN_TEST(test_timeout_ack_timeout_round_trips); + RUN_TEST(test_timeout_settings_survive_a_connection); + + RUN_TEST(test_timeout_rx_timeout_closes_an_idle_connection); + RUN_TEST(test_timeout_rx_timeout_is_measured_in_seconds); + RUN_TEST(test_timeout_rx_timeout_of_zero_never_closes); + RUN_TEST(test_timeout_rx_timeout_is_refreshed_by_arriving_data); + RUN_TEST(test_timeout_rx_timeout_is_refreshed_by_an_ack); + RUN_TEST(test_timeout_rx_timeout_runs_from_the_handshake_not_the_dial); + RUN_TEST(test_timeout_rx_timeout_is_not_applied_while_connecting); + + RUN_TEST(test_timeout_ack_timeout_reports_the_age_of_the_unacked_send); + RUN_TEST(test_timeout_ack_timeout_passes_the_callback_argument); + RUN_TEST(test_timeout_ack_timeout_falls_between_the_deadlines_of_two_unacked_sends); + RUN_TEST(test_timeout_ack_timeout_does_not_fire_once_the_peer_acks); + RUN_TEST(test_timeout_ack_timeout_does_not_fire_without_a_send); + RUN_TEST(test_timeout_ack_timeout_of_zero_disables_it); + RUN_TEST(test_timeout_ack_timeout_leaves_the_connection_up); + RUN_TEST(test_timeout_ack_timeout_repeats_until_the_handler_acts); + RUN_TEST(test_timeout_ack_timeout_handler_may_close_the_connection); + RUN_TEST(test_timeout_ack_timeout_handler_may_abort_the_connection); + RUN_TEST(test_timeout_ack_timeout_arms_for_a_send_at_millis_zero); + + RUN_TEST(test_timeout_queuing_without_sending_does_not_arm_the_ack_timeout); + RUN_TEST(test_timeout_a_send_lwip_cannot_output_still_times_out); + RUN_TEST(test_timeout_an_unacked_send_does_not_carry_into_the_next_connection); + + RUN_TEST(test_timeout_onPoll_runs_once_per_delivered_poll); + RUN_TEST(test_timeout_onPoll_needs_the_async_task); + RUN_TEST(test_timeout_queued_polls_coalesce_into_one); + RUN_TEST(test_timeout_polls_split_by_an_ack_neither_swallow_it_nor_multiply); + RUN_TEST(test_timeout_polls_for_different_clients_do_not_coalesce); + RUN_TEST(test_timeout_onPoll_is_skipped_while_the_ack_timeout_is_firing); + + RUN_TEST(test_timeout_setKeepAlive_reaches_the_pcb); + RUN_TEST(test_timeout_setKeepAlive_zero_turns_it_off); + RUN_TEST(test_timeout_setKeepAlive_zero_ignores_the_count); + RUN_TEST(test_timeout_setKeepAlive_leaves_other_socket_options_alone); + RUN_TEST(test_timeout_setKeepAlive_before_connect_is_a_no_op); + RUN_TEST(test_timeout_setKeepAlive_after_close_is_a_no_op); + + RUN_TEST(test_timeout_rx_timeout_wins_when_it_expires_first); + RUN_TEST(test_timeout_rx_timeout_closes_an_idle_connection_with_unacked_data); + + RUN_TEST(test_timeout_rx_timeout_runs_across_a_millis_wrap); + RUN_TEST(test_timeout_ack_timeout_runs_across_a_millis_wrap); + RUN_TEST(test_timeout_ack_timeout_arms_for_a_send_just_after_a_wrap); + RUN_TEST(test_timeout_partial_ack_holds_off_the_ack_timeout); +} From b853bddebc5bfa0123702991c003fc163ad2f8ca Mon Sep 17 00:00:00 2001 From: Will Miles Date: Wed, 30 Sep 2026 23:33:26 -0400 Subject: [PATCH 08/24] test: Cover name resolution Co-Authored-By: Claude Opus 5.5 --- test/test_native/README.md | 15 +- test/test_native/fixtures.h | 7 +- test/test_native/mocks/mock_lwip.cpp | 68 ++++++ test/test_native/mocks/mock_lwip.h | 25 ++- test/test_native/test_main.cpp | 2 + test/test_native/tests/test_dns.cpp | 315 +++++++++++++++++++++++++++ 6 files changed, 426 insertions(+), 6 deletions(-) create mode 100644 test/test_native/tests/test_dns.cpp diff --git a/test/test_native/README.md b/test/test_native/README.md index b04a5ca8..f5b4423c 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -119,6 +119,7 @@ test/test_native/ test_write.cpp the outbound path test_recv.cpp the inbound path test_timeout.cpp the rx and ack timeouts, onPoll, keepalive + test_dns.cpp name resolution, and lookups that are abandoned mocks/ include/ fake system headers -- on the include path FIRST, so #include "lwip/tcp.h" etc. resolve here @@ -171,6 +172,9 @@ thread. After every test the runner fails it if the library: else for one it had, which would leave lwIP using a freed pcb or dropping a live one. +Addresses are kept in **host** byte order throughout, which keeps +`IPAddress(10,0,0,1)` and `remote_ip4(pcb)` directly comparable. + ## Adding a test Add it to the file for its topic, or drop a new `.cpp` in `tests/`; PlatformIO @@ -207,8 +211,8 @@ is what redirects `RUN_TEST` into the forking runner. A new file's `fixtures.h` holds what most tests start with: -* `kPeer`, `kPort`, `kServerPort`: where clients dial and where servers - listen; +* `kPeer`, `kPort`, `kServerPort`, `kResolved`: where clients dial, where + servers listen, what a lookup answers; * `establish(c)`: connects `c` and delivers the handshake, returning its pcb; * `listen_pcb(port)`: the listening pcb, on `port` or on any port; * `Recorder`: records a client's callbacks as a string, one letter each, so @@ -246,6 +250,9 @@ fire_poll(pcb); // tcp_poll_fn fire_error(pcb, ERR_RST); // frees pcb, then tcp_err_fn -- pcb is dead after fire_accept(listen_pcb); // builds a new ESTABLISHED pcb, offers it fire_accept(listen_pcb, peer, 41234); // ... from the peer at that ip_addr_t and port +fire_dns(0x0A000005); // deferred dns_found_callback with an address +fire_dns(addr); // ... or with any ip_addr_t, IPv6 included +fire_dns_failure(); // ... or with NULL asynctcp_test_pump(); // now the application callbacks run ``` @@ -260,6 +267,9 @@ and then calls it with `pcb->callback_arg`. ```cpp faults().write_result = ERR_MEM; +faults().dns_result = ERR_INPROGRESS; // or ERR_OK / an error +faults().dns_addr = 0x0A000005; // used when dns_result == ERR_OK +faults().dns_fails_inside_the_call = true; // with ERR_INPROGRESS: answers NULL, runs the task, then returns ``` ### Asserting @@ -273,6 +283,7 @@ count("tcp_close", pcb) recved(pcb) // total bytes handed to tcp_recved(): the window reopened saw("tcp_recved", pcb, 42) // called on this pcb with first arg 42 written(pcb) // everything handed to tcp_write(), concatenated +remote_ip4(pcb) // the pcb's remote IPv4 address mockrtos::deadlocks() // non-recursive mutex re-taken -- a deadlock on target mockclock::advance(1500) // move millis() forward ``` diff --git a/test/test_native/fixtures.h b/test/test_native/fixtures.h index cb527d78..a693b8f1 100644 --- a/test/test_native/fixtures.h +++ b/test/test_native/fixtures.h @@ -19,9 +19,10 @@ extern "C" { #include "lwip/tcp.h" } -const IPAddress kPeer(10, 0, 0, 1); // where clients dial -const uint16_t kPort = 8080; // ... and on what port -const uint16_t kServerPort = 8081; // where servers listen +const IPAddress kPeer(10, 0, 0, 1); // where clients dial +const uint16_t kPort = 8080; // ... and on what port +const uint16_t kServerPort = 8081; // where servers listen +const uint32_t kResolved = 0x0A000005; // what a name lookup answers: 10.0.0.5 // Connects to kPeer and completes the handshake. Returns the client's pcb. inline tcp_pcb *establish(AsyncClient &c) { diff --git a/test/test_native/mocks/mock_lwip.cpp b/test/test_native/mocks/mock_lwip.cpp index ae04124e..3a51880b 100644 --- a/test/test_native/mocks/mock_lwip.cpp +++ b/test/test_native/mocks/mock_lwip.cpp @@ -9,6 +9,7 @@ // Those are exactly the edges AsyncTCP's lifetime handling gets wrong or right. #include "mock_lwip.h" +#include "mock_rtos.h" #include #include @@ -59,6 +60,13 @@ std::vector g_aborted; // every pcb tcp_abort() freed, in orde unsigned g_return_violations = 0; std::string g_first_return_violation; +struct PendingDns { + std::string host; + dns_found_callback cb = nullptr; + void *arg = nullptr; +}; +std::vector g_dns_queue; // LwIP cannot cancel a lookup, so these accumulate + void rec(const char *fn, const void *pcb = nullptr, long a = 0, long b = 0) { g_calls.push_back(mocklwip::Call{fn, pcb, a, b}); } @@ -420,6 +428,20 @@ extern "C" err_t dns_gethostbyname(const char *hostname, ip_addr_t *addr, dns_fo } return ERR_OK; } + if (g_faults.dns_result == ERR_INPROGRESS && g_faults.dns_fails_inside_the_call) { + // dns_enqueue() -> dns_call_found(NULL); the task, on another core, need not wait. + found(hostname, nullptr, callback_arg); + asynctcp_test_pump(); + return ERR_INPROGRESS; + } + if (g_faults.dns_result == ERR_INPROGRESS) { + PendingDns p; + p.host = hostname ? hostname : ""; + p.cb = found; + p.arg = callback_arg; + g_dns_queue.push_back(p); + return ERR_INPROGRESS; + } return g_faults.dns_result; } @@ -552,6 +574,7 @@ void reset() { g_ports.clear(); g_pcb_port.clear(); g_faults = Faults{}; + std::vector().swap(g_dns_queue); g_reentrant_api_calls = 0; g_unlocked_calls = 0; g_first_unlocked_call.clear(); @@ -772,4 +795,49 @@ const char *first_callback_return_violation() { return g_first_return_violation.c_str(); } +bool dns_pending() { + return !g_dns_queue.empty(); +} + +size_t dns_pending_count() { + return g_dns_queue.size(); +} + +std::string dns_pending_host() { + return g_dns_queue.empty() ? std::string() : g_dns_queue.front().host; +} + +// Fires the oldest outstanding lookup, as a resolver working through its queue would. +void fire_dns(uint32_t addr) { + ip_addr_t a; + memset(&a, 0, sizeof(a)); + ip_addr_set_ip4_u32_val(a, addr); + fire_dns(a); +} + +void fire_dns(const ip_addr_t &addr) { + on_lwip_thread lwip; + if (g_dns_queue.empty()) { + return; + } + ip_addr_t a = addr; + PendingDns p = g_dns_queue.front(); + g_dns_queue.erase(g_dns_queue.begin()); + if (p.cb) { + p.cb(p.host.c_str(), &a, p.arg); + } +} + +void fire_dns_failure() { + on_lwip_thread lwip; + if (g_dns_queue.empty()) { + return; + } + PendingDns p = g_dns_queue.front(); + g_dns_queue.erase(g_dns_queue.begin()); + if (p.cb) { + p.cb(p.host.c_str(), nullptr, p.arg); + } +} + } // namespace mocklwip diff --git a/test/test_native/mocks/mock_lwip.h b/test/test_native/mocks/mock_lwip.h index 429fe21e..53053d2a 100644 --- a/test/test_native/mocks/mock_lwip.h +++ b/test/test_native/mocks/mock_lwip.h @@ -53,6 +53,11 @@ size_t live_pbufs(); // Everything a pcb was handed to tcp_write(), in order, kept after tcp_close(). std::string written(const tcp_pcb *pcb); +// A pcb's remote IPv4 address. +inline uint32_t remote_ip4(const tcp_pcb *pcb) { + return ip_addr_get_ip4_u32(&pcb->remote_ip); +} + // Empties 's send buffer and resizes it, with the queue limit lwIP would derive // for that TCP_SND_BUF. Above 65535 only with LWIP_WND_SCALE. void set_send_buffer(tcp_pcb *pcb, uint32_t size); @@ -66,8 +71,11 @@ int core_lock_depth(); struct Faults { err_t write_result = 0; // ERR_OK; set ERR_MEM to fail tcp_write err_t output_result = 0; // ERR_OK - err_t dns_result = 0; // ERR_OK (immediate) or an error + err_t dns_result = 0; // ERR_OK (immediate), ERR_INPROGRESS, or an error uint32_t dns_addr = 0; // address returned for an immediate ERR_OK lookup + // With ERR_INPROGRESS: answers NULL, and lets the async task run, before returning -- + // as lwIP with no DNS server does, with the task on another core. + bool dns_fails_inside_the_call = false; }; Faults &faults(); @@ -123,6 +131,21 @@ const char *first_unlocked_call(); unsigned callback_return_violations(); const char *first_callback_return_violation(); +// --------------------------------------------------------------------------- +// DNS +// --------------------------------------------------------------------------- +// True while a dns_gethostbyname() that returned ERR_INPROGRESS is outstanding. +bool dns_pending(); +// How many are outstanding. LwIP cannot cancel a lookup, so they accumulate. +size_t dns_pending_count(); +// The hostname of the oldest outstanding lookup. +std::string dns_pending_host(); +// Fire the deferred callback with a resolved address. +void fire_dns(uint32_t addr); +void fire_dns(const ip_addr_t &addr); +// Fire the deferred callback with NULL (failure / timeout). +void fire_dns_failure(); + } // namespace mocklwip #endif diff --git a/test/test_native/test_main.cpp b/test/test_native/test_main.cpp index 86a9474d..03c3c9f9 100644 --- a/test/test_native/test_main.cpp +++ b/test/test_native/test_main.cpp @@ -45,6 +45,7 @@ void run_client_tests(void); void run_write_tests(void); void run_recv_tests(void); void run_timeout_tests(void); +void run_dns_tests(void); namespace { @@ -331,6 +332,7 @@ int main(int argc, char **argv) { run_write_tests(); run_recv_tests(); run_timeout_tests(); + run_dns_tests(); if (g_list_only) { return 0; diff --git a/test/test_native/tests/test_dns.cpp b/test/test_native/tests/test_dns.cpp new file mode 100644 index 00000000..5f3f7f17 --- /dev/null +++ b/test/test_native/tests/test_dns.cpp @@ -0,0 +1,315 @@ +// DNS resolution tests for AsyncClient::connect(const char*, uint16_t). + +#include "fixtures.h" + +using namespace mocklwip; + +// --------------------------------------------------------------------------- + +static void test_dns_immediate_hit_connects_straight_away(void) { + faults().dns_result = ERR_OK; + faults().dns_addr = kResolved; + + AsyncClient c; + int connects = 0; + c.onConnect([&](void *, AsyncClient *) { + connects++; + }); + + TEST_ASSERT_TRUE(c.connect("example.test", kPort)); + TEST_ASSERT_EQUAL_size_t(1, count("dns_gethostbyname")); + TEST_ASSERT_FALSE(dns_pending()); + TEST_ASSERT_EQUAL_size_t(1, count("tcp_connect")); + + tcp_pcb *pcb = dialled_pcb(); + TEST_ASSERT_NOT_NULL(pcb); + TEST_ASSERT_EQUAL_UINT32(kResolved, remote_ip4(pcb)); + TEST_ASSERT_EQUAL_UINT16(kPort, pcb->remote_port); + + fire_connected(pcb); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, connects); + + c.close(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_dns_deferred_hit_connects_when_the_callback_fires(void) { + faults().dns_result = ERR_INPROGRESS; + + AsyncClient c; + int connects = 0; + c.onConnect([&](void *, AsyncClient *) { + connects++; + }); + + // connect() reports success optimistically while the lookup is outstanding. + TEST_ASSERT_TRUE(c.connect("example.test", kPort)); + TEST_ASSERT_TRUE(dns_pending()); + TEST_ASSERT_EQUAL_STRING("example.test", dns_pending_host().c_str()); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_connect")); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_NULL(c.pcb()); + + fire_dns(kResolved); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(1, count("tcp_connect")); + + tcp_pcb *pcb = dialled_pcb(); + TEST_ASSERT_NOT_NULL(pcb); + TEST_ASSERT_EQUAL_UINT32(kResolved, remote_ip4(pcb)); + // the port given to connect() must reach the dial. + TEST_ASSERT_EQUAL_UINT16(kPort, pcb->remote_port); + + fire_connected(pcb); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, connects); + + c.close(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_dns_failure_reports_error_55_and_does_not_connect(void) { + faults().dns_result = ERR_INPROGRESS; + + AsyncClient c; + int order = 0, err_order = 0, disc_order = 0, connects = 0; + int8_t err_seen = 0; + c.onError([&](void *, AsyncClient *, int8_t e) { + err_seen = e; + err_order = ++order; + }); + c.onDisconnect([&](void *, AsyncClient *) { + disc_order = ++order; + }); + c.onConnect([&](void *, AsyncClient *) { + connects++; + }); + + TEST_ASSERT_TRUE(c.connect("nx.test", kPort)); + TEST_ASSERT_TRUE(dns_pending()); + + // Resolver gives up: callback with a NULL address. + fire_dns_failure(); + TEST_ASSERT_EQUAL_INT(0, err_order); + + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_INT(-55, (int)err_seen); // AsyncTCP's "DNS failed" pseudo-error + TEST_ASSERT_EQUAL_INT(1, err_order); + TEST_ASSERT_EQUAL_INT(2, disc_order); + TEST_ASSERT_EQUAL_INT(0, connects); + + // Crucially: no connection attempt, and no pcb allocated for 0.0.0.0. + TEST_ASSERT_EQUAL_size_t(0, count("tcp_connect")); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_new_ip_type")); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_NULL(c.pcb()); +} + +static void test_dns_failure_answered_inside_the_lookup_is_reported_once(void) { + // lwIP with no DNS server answers inside dns_gethostbyname(), then returns ERR_INPROGRESS; + // the async task may deliver the answer before connect() returns. + faults().dns_result = ERR_INPROGRESS; + faults().dns_fails_inside_the_call = true; + + AsyncClient c; + Recorder t; + t.attach(c); + TEST_ASSERT_TRUE(c.connect("nx.test", kPort)); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("ED", t.seq.c_str()); + TEST_ASSERT_EQUAL_INT(-55, (int)t.err); + + establish(c); + TEST_ASSERT_EQUAL_STRING("EDC", t.seq.c_str()); + c.close(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_dns_deferred_zero_address_is_treated_as_failure(void) { + faults().dns_result = ERR_INPROGRESS; + + AsyncClient c; + int8_t err_seen = 0; + c.onError([&](void *, AsyncClient *, int8_t e) { + err_seen = e; + }); + + TEST_ASSERT_TRUE(c.connect("zero.test", kPort)); + fire_dns(0); // resolver returned 0.0.0.0 + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_INT(-55, (int)err_seen); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_connect")); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_dns_synchronous_zero_address_is_treated_as_failure(void) { + // dns_gethostbyname() answers a cached name without a callback, so a second attempt at + // a sinkholed host takes this path rather than the one above. + faults().dns_result = ERR_OK; + faults().dns_addr = 0; + + AsyncClient c; + int8_t err_seen = 0; + c.onError([&](void *, AsyncClient *, int8_t e) { + err_seen = e; + }); + + TEST_ASSERT_FALSE(c.connect("zero.test", kPort)); // refused outright, so no callback is owed + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_INT(0, (int)err_seen); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_connect")); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_dns_hard_error_fails_connect_immediately(void) { + faults().dns_result = ERR_VAL; + + AsyncClient c; + int8_t err_seen = 0; + c.onError([&](void *, AsyncClient *, int8_t e) { + err_seen = e; + }); + + TEST_ASSERT_FALSE(c.connect("bad..test", kPort)); + TEST_ASSERT_FALSE(dns_pending()); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_connect")); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + + // The synchronous failure path just returns false; no callback is invoked. + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(0, (int)err_seen); +} + +// --------------------------------------------------------------------------- +// Abandoned and superseded lookups. LwIP cannot cancel a lookup, so its answer still +// arrives, and must be dropped. +// --------------------------------------------------------------------------- + +static void test_dns_close_abandons_the_lookup(void) { + faults().dns_result = ERR_INPROGRESS; + AsyncClient c; + TEST_ASSERT_TRUE(c.connect("slow.test", kPort)); + c.close(); + TEST_ASSERT_FALSE(c.connecting()); + + fire_dns(0x0A000001); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(0, count("tcp_connect")); // the answer is no longer wanted + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_dns_abort_abandons_the_lookup(void) { + faults().dns_result = ERR_INPROGRESS; + AsyncClient c; + int errors = 0; + c.onError([&](void *, AsyncClient *, int8_t) { + errors++; + }); + TEST_ASSERT_TRUE(c.connect("slow.test", kPort)); + c.abort(); + TEST_ASSERT_FALSE(c.connecting()); + + // The answer still arrives - LwIP cannot cancel - and must be dropped on the floor. + fire_dns(0x0A000001); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_connect")); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_dns_second_connect_supersedes_the_lookup(void) { + faults().dns_result = ERR_INPROGRESS; + AsyncClient c; + TEST_ASSERT_TRUE(c.connect("first.test", kPort)); + TEST_ASSERT_TRUE(c.connect("second.test", 9999)); // a different target wins + TEST_ASSERT_EQUAL_size_t(2, count("dns_gethostbyname")); + + // The first answer must not be dialled - it is the address of a host the caller has + // moved on from, and dialling it would report success for the wrong destination. + fire_dns(0x0A000001); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_connect")); + + fire_dns(0x0A000002); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(1, count("tcp_connect")); + tcp_pcb *pcb = dialled_pcb(); + TEST_ASSERT_NOT_NULL(pcb); + TEST_ASSERT_EQUAL_UINT32(0x0A000002, remote_ip4(pcb)); + TEST_ASSERT_EQUAL_INT(9999, (int)pcb->remote_port); +} + +static void test_dns_connect_after_close_supersedes_the_lookup(void) { + faults().dns_result = ERR_INPROGRESS; + AsyncClient c; + TEST_ASSERT_TRUE(c.connect("first.test", kPort)); + c.close(); // orphans the first lookup + TEST_ASSERT_TRUE(c.connect("second.test", kPort)); // a second is now outstanding + TEST_ASSERT_EQUAL_size_t(2, dns_pending_count()); // neither can be canceled + + // The stale answer arrives first. It must be recognized as superseded and dropped: + // dialling it would connect to the wrong address. + fire_dns(0x0A000001); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_connect")); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + + // The lookup we are actually waiting on still works. + fire_dns(0x0A000002); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(1, count("tcp_connect")); + TEST_ASSERT_NOT_NULL(c.pcb()); + TEST_ASSERT_EQUAL_UINT32(0x0A000002, remote_ip4(c.pcb())); +} + +#if LWIP_IPV6 +static void test_dns_ipv6_answer_connects_over_ipv6(void) { + const uint32_t kWords[4] = {0x20010db8, 0x00000000, 0x00000000, 0x00000005}; + faults().dns_result = ERR_INPROGRESS; + + AsyncClient c; + Recorder t; + t.attach(c); + TEST_ASSERT_TRUE(c.connect("v6.test", kPort)); + + ip_addr_t answer = IPADDR6_INIT(kWords[0], kWords[1], kWords[2], kWords[3]); + fire_dns(answer); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(1, count("tcp_connect")); + tcp_pcb *pcb = dialled_pcb(); + TEST_ASSERT_NOT_NULL(pcb); + TEST_ASSERT_EQUAL_UINT8(IPADDR_TYPE_V6, pcb->remote_ip.type); + TEST_ASSERT_EQUAL_UINT16(kPort, pcb->remote_port); + + fire_connected(pcb); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("C", t.seq.c_str()); + TEST_ASSERT_TRUE(c.remoteIP6() == IPv6Address(kWords)); + + c.close(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} +#endif + +void run_dns_tests(void) { + UnitySetTestFile(__FILE__); + RUN_TEST(test_dns_immediate_hit_connects_straight_away); + RUN_TEST(test_dns_deferred_hit_connects_when_the_callback_fires); + RUN_TEST(test_dns_failure_reports_error_55_and_does_not_connect); + RUN_TEST(test_dns_failure_answered_inside_the_lookup_is_reported_once); + RUN_TEST(test_dns_deferred_zero_address_is_treated_as_failure); + RUN_TEST(test_dns_synchronous_zero_address_is_treated_as_failure); + RUN_TEST(test_dns_hard_error_fails_connect_immediately); + RUN_TEST(test_dns_close_abandons_the_lookup); + RUN_TEST(test_dns_abort_abandons_the_lookup); + RUN_TEST(test_dns_second_connect_supersedes_the_lookup); + RUN_TEST(test_dns_connect_after_close_supersedes_the_lookup); +#if LWIP_IPV6 + RUN_TEST(test_dns_ipv6_answer_connects_over_ipv6); +#endif +} From 55bc53497fa843ee96703a2e52442f0129022923 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Wed, 30 Sep 2026 23:34:07 -0400 Subject: [PATCH 09/24] test: Cover starting and stopping a server Co-Authored-By: Claude Opus 5.5 --- test/test_native/README.md | 14 +- test/test_native/mocks/mock_lwip.cpp | 26 +- test/test_native/mocks/mock_lwip.h | 10 +- test/test_native/test_main.cpp | 2 + test/test_native/tests/test_server_listen.cpp | 281 ++++++++++++++++++ 5 files changed, 327 insertions(+), 6 deletions(-) create mode 100644 test/test_native/tests/test_server_listen.cpp diff --git a/test/test_native/README.md b/test/test_native/README.md index f5b4423c..999ca1a5 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -120,6 +120,7 @@ test/test_native/ test_recv.cpp the inbound path test_timeout.cpp the rx and ack timeouts, onPoll, keepalive test_dns.cpp name resolution, and lookups that are abandoned + test_server_listen.cpp begin(), end(), status(), restarts mocks/ include/ fake system headers -- on the include path FIRST, so #include "lwip/tcp.h" etc. resolve here @@ -142,8 +143,12 @@ on it: `ERR_ABRT` (except on `LISTEN` pcbs). * A fatal error (`fire_error`) frees the pcb **before** the error callback runs, so the pointer the library sees is already dangling. -* `tcp_listen_with_backlog()` allocates a **new** listen pcb and frees the one - passed in. +* `tcp_close()` frees the pcb only when it returns `ERR_OK`. +* `tcp_listen_with_backlog()` on success allocates a **new** listen pcb and + frees the one passed in; on failure it returns `NULL` and leaves the caller's + pcb alone, so the caller still owns it. (That asymmetry is what + `test_server_listen_begin_frees_the_bound_pcb_when_listen_fails` is + about.) * `tcp_write()` checks what `tcp_write_checks()` does: `ERR_CONN` outside `ESTABLISHED`, `CLOSE_WAIT`, `SYN_SENT` and `SYN_RCVD`; `ERR_MEM` for more than `tcp_sndbuf()`, or for more segments than `TCP_SND_QUEUELEN`. Segments are cut as @@ -266,6 +271,10 @@ and then calls it with `pcb->callback_arg`. `mocklwip::faults()` returns a mutable struct, reset before each test: ```cpp +faults().fail_tcp_new = 1; // next tcp_new_ip_type() returns NULL +faults().bind_result = ERR_USE; +faults().listen_returns_null = true; +faults().close_result = ERR_MEM; // tcp_close() fails, pcb stays allocated faults().write_result = ERR_MEM; faults().dns_result = ERR_INPROGRESS; // or ERR_OK / an error faults().dns_addr = 0x0A000005; // used when dns_result == ERR_OK @@ -277,6 +286,7 @@ faults().dns_fails_inside_the_call = true; // with ERR_INPROGRESS: answers NULL ```cpp live_pcbs() // outstanding pcbs -- the leak check live_pbufs() +port_is_bound(8080) is_live(pcb) count("tcp_close") // number of calls count("tcp_close", pcb) diff --git a/test/test_native/mocks/mock_lwip.cpp b/test/test_native/mocks/mock_lwip.cpp index 3a51880b..86fa6233 100644 --- a/test/test_native/mocks/mock_lwip.cpp +++ b/test/test_native/mocks/mock_lwip.cpp @@ -2,8 +2,10 @@ // // Behavior is modeled on real lwIP wherever it matters for AsyncTCP: // * tcp_abort() frees the pcb and *then* runs the error callback -// * tcp_close() frees the pcb, and what was written still reaches the peer -// * tcp_listen_with_backlog() frees the old pcb and returns a NEW one +// * tcp_close() frees the pcb only when it returns ERR_OK, and what was written still +// reaches the peer +// * tcp_listen_with_backlog() frees the old pcb and returns a NEW one on +// success, and returns NULL *without* freeing on failure // * a fatal error frees the pcb before the error callback sees it // * a callback handed a pcb returns ERR_ABRT exactly when it aborted that pcb // Those are exactly the edges AsyncTCP's lifetime handling gets wrong or right. @@ -206,6 +208,11 @@ extern "C" void pbuf_cat(struct pbuf *head, struct pbuf *tail) { // =========================================================================== extern "C" struct tcp_pcb *tcp_new_ip_type(u8_t type) { check_context("tcp_new_ip_type"); + if (g_faults.fail_tcp_new > 0) { + g_faults.fail_tcp_new--; + rec("tcp_new_ip_type", nullptr, type); + return nullptr; + } tcp_pcb *pcb = alloc_pcb(); IP_SET_TYPE_VAL(pcb->local_ip, type); IP_SET_TYPE_VAL(pcb->remote_ip, type); @@ -267,6 +274,9 @@ extern "C" err_t tcp_bind(struct tcp_pcb *pcb, const ip_addr_t *ipaddr, u16_t po if (!pcb) { return ERR_ARG; } + if (g_faults.bind_result != ERR_OK) { + return g_faults.bind_result; + } if (port == 0) { port = g_next_ephemeral++; } @@ -288,6 +298,11 @@ extern "C" struct tcp_pcb *tcp_listen_with_backlog(struct tcp_pcb *pcb, u8_t bac if (!pcb) { return nullptr; } + if (g_faults.listen_returns_null) { + // Real lwIP leaves the caller's pcb alone when it cannot allocate the + // listen pcb. Whoever called us still owns it. + return nullptr; + } // Real lwIP swaps the pcb for a smaller listen pcb and frees the original. tcp_pcb *lpcb = alloc_pcb(); lpcb->local_ip = pcb->local_ip; @@ -334,6 +349,9 @@ extern "C" err_t tcp_close(struct tcp_pcb *pcb) { if (!pcb) { return ERR_ARG; } + if (g_faults.close_result != ERR_OK) { + return g_faults.close_result; // pcb stays allocated, as on the target + } // lwIP keeps the pcb until the peer acks our FIN, so queued data goes out first. auto sent = g_written.extract(pcb); free_pcb(pcb); @@ -611,6 +629,10 @@ size_t live_pbufs() { return g_pbufs.size(); } +bool port_is_bound(uint16_t port) { + return g_ports.count(port) != 0; +} + std::string written(const tcp_pcb *pcb) { auto it = g_written.find(pcb); return it == g_written.end() ? std::string() : it->second; diff --git a/test/test_native/mocks/mock_lwip.h b/test/test_native/mocks/mock_lwip.h index 53053d2a..ace60b9f 100644 --- a/test/test_native/mocks/mock_lwip.h +++ b/test/test_native/mocks/mock_lwip.h @@ -49,6 +49,8 @@ bool is_live(const tcp_pcb *pcb); // freed, so check is_live() before dereferencing. tcp_pcb *dialled_pcb(); size_t live_pbufs(); +// Ports currently held by a bound or listening pcb. +bool port_is_bound(uint16_t port); // Everything a pcb was handed to tcp_write(), in order, kept after tcp_close(). std::string written(const tcp_pcb *pcb); @@ -69,10 +71,14 @@ int core_lock_depth(); // Failure injection. All are reset by reset(). // --------------------------------------------------------------------------- struct Faults { + int fail_tcp_new = 0; // >0: that many tcp_new_ip_type() calls return NULL + err_t bind_result = 0; // ERR_OK err_t write_result = 0; // ERR_OK; set ERR_MEM to fail tcp_write err_t output_result = 0; // ERR_OK - err_t dns_result = 0; // ERR_OK (immediate), ERR_INPROGRESS, or an error - uint32_t dns_addr = 0; // address returned for an immediate ERR_OK lookup + err_t close_result = 0; // ERR_OK; non-OK leaves the pcb allocated + bool listen_returns_null = false; + err_t dns_result = 0; // ERR_OK (immediate), ERR_INPROGRESS, or an error + uint32_t dns_addr = 0; // address returned for an immediate ERR_OK lookup // With ERR_INPROGRESS: answers NULL, and lets the async task run, before returning -- // as lwIP with no DNS server does, with the task on another core. bool dns_fails_inside_the_call = false; diff --git a/test/test_native/test_main.cpp b/test/test_native/test_main.cpp index 03c3c9f9..b82abd75 100644 --- a/test/test_native/test_main.cpp +++ b/test/test_native/test_main.cpp @@ -46,6 +46,7 @@ void run_write_tests(void); void run_recv_tests(void); void run_timeout_tests(void); void run_dns_tests(void); +void run_server_listen_tests(void); namespace { @@ -333,6 +334,7 @@ int main(int argc, char **argv) { run_recv_tests(); run_timeout_tests(); run_dns_tests(); + run_server_listen_tests(); if (g_list_only) { return 0; diff --git a/test/test_native/tests/test_server_listen.cpp b/test/test_native/tests/test_server_listen.cpp new file mode 100644 index 00000000..195f4a35 --- /dev/null +++ b/test/test_native/tests/test_server_listen.cpp @@ -0,0 +1,281 @@ +// AsyncServer's listening session: begin(), end(), status(), restarts, and what a failed +// bind or listen leaves behind. + +#include "fixtures.h" + +using namespace mocklwip; + +static void test_server_listen_begin_binds_and_listens(void) { + AsyncServer s(kServerPort); + s.begin(); + + TEST_ASSERT_EQUAL_size_t(1, count("tcp_bind")); + TEST_ASSERT_EQUAL_size_t(1, count("tcp_listen_with_backlog")); + TEST_ASSERT_TRUE(port_is_bound(kServerPort)); + + tcp_pcb *lp = listen_pcb(); + TEST_ASSERT_NOT_NULL(lp); + TEST_ASSERT_EQUAL_INT((int)LISTEN, (int)s.status()); + TEST_ASSERT_NOT_NULL(lp->accept); + // What the library passes as the argument is its own business, so we can only check + // that one is set. + TEST_ASSERT_NOT_NULL(lp->callback_arg); + + // tcp_listen_with_backlog swapped the bound pcb for a listen pcb, so exactly + // one should be alive. + TEST_ASSERT_EQUAL_size_t(1, live_pcbs()); + + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_FALSE(port_is_bound(kServerPort)); +} + +static void test_server_listen_destructor_releases_the_port(void) { + { + AsyncServer s(kServerPort); + s.begin(); + TEST_ASSERT_TRUE(port_is_bound(kServerPort)); + } + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_FALSE(port_is_bound(kServerPort)); +} + +static void test_server_listen_begin_bails_out_when_pcb_allocation_fails(void) { + faults().fail_tcp_new = 1; + + AsyncServer s(kServerPort); + s.begin(); + + TEST_ASSERT_EQUAL_INT(0, (int)s.status()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_bind")); +} + +static void test_server_listen_begin_frees_the_pcb_when_bind_fails(void) { + faults().bind_result = ERR_USE; + + AsyncServer s(kServerPort); + s.begin(); + + TEST_ASSERT_EQUAL_INT(0, (int)s.status()); + TEST_ASSERT_EQUAL_size_t(1, count("tcp_bind")); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_listen_with_backlog")); + // A failed bind must not leave the pcb behind. + TEST_ASSERT_EQUAL_size_t(1, count("tcp_close")); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_server_listen_bind_conflicts_with_an_already_bound_port(void) { + AsyncServer a(kServerPort); + a.begin(); + TEST_ASSERT_EQUAL_INT((int)LISTEN, (int)a.status()); + + AsyncServer b(kServerPort); + b.begin(); + TEST_ASSERT_EQUAL_INT(0, (int)b.status()); // ERR_USE from tcp_bind + + a.end(); + b.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +// lwIP does NOT free the caller's pcb when it cannot allocate a listen pcb, so begin() +// still owns the bound one and has to close it, or both the pcb and its port leak for +// the life of the process. +static void test_server_listen_begin_frees_the_bound_pcb_when_listen_fails(void) { + faults().listen_returns_null = true; + + { + AsyncServer s(kServerPort); + s.begin(); + TEST_ASSERT_EQUAL_INT(0, (int)s.status()); + TEST_ASSERT_EQUAL_size_t(1, count("tcp_listen_with_backlog")); + } + + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_FALSE(port_is_bound(kServerPort)); +} + +static void test_server_listen_end_is_idempotent(void) { + AsyncServer s(kServerPort); + s.begin(); + s.end(); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_INT(0, (int)s.status()); +} + +static void test_server_listen_begin_twice_does_not_double_bind(void) { + AsyncServer s(kServerPort); + s.begin(); + s.begin(); // second call must be a no-op: already listening + TEST_ASSERT_EQUAL_size_t(1, count("tcp_bind")); + TEST_ASSERT_EQUAL_size_t(1, live_pcbs()); + s.end(); +} + +// --------------------------------------------------------------------------- +// status() +// --------------------------------------------------------------------------- + +static void test_server_listen_status_is_closed_before_begin(void) { + // Not listening yet, so status() has nothing to report but CLOSED. + AsyncServer s(kServerPort); + TEST_ASSERT_EQUAL_INT((int)CLOSED, (int)s.status()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_FALSE(port_is_bound(kServerPort)); +} + +static void test_server_listen_status_follows_the_listening_session(void) { + AsyncServer s(kServerPort); + TEST_ASSERT_EQUAL_INT((int)CLOSED, (int)s.status()); + s.begin(); + TEST_ASSERT_EQUAL_INT((int)LISTEN, (int)s.status()); + s.end(); + TEST_ASSERT_EQUAL_INT((int)CLOSED, (int)s.status()); + s.begin(); + TEST_ASSERT_EQUAL_INT((int)LISTEN, (int)s.status()); + s.end(); + TEST_ASSERT_EQUAL_INT((int)CLOSED, (int)s.status()); +} + +static void test_server_listen_status_is_closed_after_end_aborts_the_listen_pcb(void) { + // tcp_close() can fail on a listen pcb; end() falls back to tcp_abort(), and either way + // the server has stopped listening and owns nothing. + faults().close_result = ERR_MEM; + + AsyncServer s(kServerPort); + s.begin(); + s.end(); + + TEST_ASSERT_EQUAL_INT((int)CLOSED, (int)s.status()); + TEST_ASSERT_EQUAL_size_t(1, count("tcp_abort")); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_FALSE(port_is_bound(kServerPort)); +} + +// --------------------------------------------------------------------------- +// Restarting, and taking a port over +// --------------------------------------------------------------------------- + +static void test_server_listen_restart_accepts_on_a_fresh_listen_pcb(void) { + AsyncServer s(kServerPort); + Accepted accepted(s); + s.begin(); + tcp_pcb *first = listen_pcb(); + s.end(); + TEST_ASSERT_FALSE(is_live(first)); + + s.begin(); + tcp_pcb *second = listen_pcb(); + TEST_ASSERT_NOT_NULL(second); + TEST_ASSERT_EQUAL_INT((int)LISTEN, (int)s.status()); + TEST_ASSERT_TRUE(port_is_bound(kServerPort)); + TEST_ASSERT_EQUAL_size_t(1, live_pcbs()); + + tcp_pcb *conn = fire_accept(second); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(1, accepted.size()); + TEST_ASSERT_EQUAL_PTR(conn, accepted[0]->pcb()); + + accepted.clear(); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_server_listen_the_port_is_reusable_once_the_holder_ends(void) { + // A failed begin() leaves nothing behind, so a server can take the port over as soon as + // the one holding it lets go, with no intervening teardown of its own. + AsyncServer a(kServerPort); + AsyncServer b(kServerPort); + a.begin(); + b.begin(); + TEST_ASSERT_EQUAL_INT((int)CLOSED, (int)b.status()); + + a.end(); + b.begin(); + TEST_ASSERT_EQUAL_INT((int)LISTEN, (int)b.status()); + TEST_ASSERT_TRUE(port_is_bound(kServerPort)); + TEST_ASSERT_EQUAL_size_t(1, live_pcbs()); + + b.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +// --------------------------------------------------------------------------- +// The address listened on +// --------------------------------------------------------------------------- + +static void test_server_listen_by_port_alone_binds_to_any_address(void) { + AsyncServer s(kServerPort); + s.begin(); + + const Call *bind = nth("tcp_bind", 0); + TEST_ASSERT_NOT_NULL(bind); + TEST_ASSERT_EQUAL_INT(kServerPort, (int)bind->a); + TEST_ASSERT_EQUAL_INT(0, (int)bind->b); + tcp_pcb *lp = listen_pcb(); + TEST_ASSERT_NOT_NULL(lp); + TEST_ASSERT_EQUAL_UINT32(0, ip_addr_get_ip4_u32(&lp->local_ip)); +#if LWIP_IPV6 + TEST_ASSERT_EQUAL_UINT8(IPADDR_TYPE_ANY, lp->local_ip.type); // both families +#endif + + s.end(); +} + +static void test_server_listen_by_ip_addr_t_binds_that_address(void) { + const uint32_t kBind = 0x0A000007; // 10.0.0.7 + ip_addr_t addr = IPADDR4_INIT(kBind); + AsyncServer s(addr, kServerPort); + s.begin(); + + TEST_ASSERT_EQUAL_INT((int)LISTEN, (int)s.status()); + tcp_pcb *lp = listen_pcb(kServerPort); + TEST_ASSERT_NOT_NULL(lp); + TEST_ASSERT_EQUAL_UINT32(kBind, ip_addr_get_ip4_u32(&lp->local_ip)); + + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +#if LWIP_IPV6 +static void test_server_listen_by_ipv6_address_binds_that_address(void) { + const uint32_t kWords[4] = {0x20010db8, 0x00000000, 0x00000000, 0x00000007}; + AsyncServer s(IPv6Address(kWords), kServerPort); + s.begin(); + + TEST_ASSERT_EQUAL_INT((int)LISTEN, (int)s.status()); + tcp_pcb *lp = listen_pcb(kServerPort); + TEST_ASSERT_NOT_NULL(lp); + TEST_ASSERT_EQUAL_UINT8(IPADDR_TYPE_V6, lp->local_ip.type); + for (int i = 0; i < 4; ++i) { + TEST_ASSERT_EQUAL_UINT32(kWords[i], lp->local_ip.u_addr.ip6.addr[i]); + } + + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} +#endif + +void run_server_listen_tests(void) { + UnitySetTestFile(__FILE__); + RUN_TEST(test_server_listen_begin_binds_and_listens); + RUN_TEST(test_server_listen_destructor_releases_the_port); + RUN_TEST(test_server_listen_begin_bails_out_when_pcb_allocation_fails); + RUN_TEST(test_server_listen_begin_frees_the_pcb_when_bind_fails); + RUN_TEST(test_server_listen_bind_conflicts_with_an_already_bound_port); + RUN_TEST(test_server_listen_begin_frees_the_bound_pcb_when_listen_fails); + RUN_TEST(test_server_listen_end_is_idempotent); + RUN_TEST(test_server_listen_begin_twice_does_not_double_bind); + RUN_TEST(test_server_listen_status_is_closed_before_begin); + RUN_TEST(test_server_listen_status_follows_the_listening_session); + RUN_TEST(test_server_listen_status_is_closed_after_end_aborts_the_listen_pcb); + RUN_TEST(test_server_listen_restart_accepts_on_a_fresh_listen_pcb); + RUN_TEST(test_server_listen_the_port_is_reusable_once_the_holder_ends); + RUN_TEST(test_server_listen_by_port_alone_binds_to_any_address); + RUN_TEST(test_server_listen_by_ip_addr_t_binds_that_address); +#if LWIP_IPV6 + RUN_TEST(test_server_listen_by_ipv6_address_binds_that_address); +#endif +} From 71ad50ef8e9bd891842fa1580c4b5a366c267f61 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Wed, 30 Sep 2026 23:35:00 -0400 Subject: [PATCH 10/24] test: Cover accepting connections Co-Authored-By: Claude Opus 5.5 --- test/test_native/README.md | 2 + test/test_native/fixtures.h | 16 + test/test_native/mocks/mock_lwip.cpp | 12 + test/test_native/mocks/mock_lwip.h | 6 +- test/test_native/test_main.cpp | 2 + test/test_native/tests/test_server_accept.cpp | 748 ++++++++++++++++++ 6 files changed, 785 insertions(+), 1 deletion(-) create mode 100644 test/test_native/tests/test_server_accept.cpp diff --git a/test/test_native/README.md b/test/test_native/README.md index 999ca1a5..ed58a970 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -121,6 +121,7 @@ test/test_native/ test_timeout.cpp the rx and ack timeouts, onPoll, keepalive test_dns.cpp name resolution, and lookups that are abandoned test_server_listen.cpp begin(), end(), status(), restarts + test_server_accept.cpp delivering accepted connections mocks/ include/ fake system headers -- on the include path FIRST, so #include "lwip/tcp.h" etc. resolve here @@ -255,6 +256,7 @@ fire_poll(pcb); // tcp_poll_fn fire_error(pcb, ERR_RST); // frees pcb, then tcp_err_fn -- pcb is dead after fire_accept(listen_pcb); // builds a new ESTABLISHED pcb, offers it fire_accept(listen_pcb, peer, 41234); // ... from the peer at that ip_addr_t and port +fire_accept_failure(listen_pcb); // ... or NULL with ERR_MEM, as when no pcb is free fire_dns(0x0A000005); // deferred dns_found_callback with an address fire_dns(addr); // ... or with any ip_addr_t, IPv6 included fire_dns_failure(); // ... or with NULL diff --git a/test/test_native/fixtures.h b/test/test_native/fixtures.h index a693b8f1..d0b16a61 100644 --- a/test/test_native/fixtures.h +++ b/test/test_native/fixtures.h @@ -23,6 +23,7 @@ const IPAddress kPeer(10, 0, 0, 1); // where clients dial const uint16_t kPort = 8080; // ... and on what port const uint16_t kServerPort = 8081; // where servers listen const uint32_t kResolved = 0x0A000005; // what a name lookup answers: 10.0.0.5 +const long kExpectedBacklog = 5; // what a server asks lwIP for; there is no public setting // Connects to kPeer and completes the handshake. Returns the client's pcb. inline tcp_pcb *establish(AsyncClient &c) { @@ -140,6 +141,21 @@ class Accepted { AsyncClient *operator[](size_t i) const { return _clients.at(i); } + // The client holding , or nullptr. + AsyncClient *holding(const tcp_pcb *pcb) const { + for (AsyncClient *c : _clients) { + if (c->pcb() == pcb) { + return c; + } + } + return nullptr; + } + // Deletes the i-th client now. + void drop(size_t i) { + AsyncClient *c = _clients.at(i); + _clients.erase(_clients.begin() + i); + delete c; + } void clear() { std::vector doomed; doomed.swap(_clients); diff --git a/test/test_native/mocks/mock_lwip.cpp b/test/test_native/mocks/mock_lwip.cpp index 86fa6233..dcbde14c 100644 --- a/test/test_native/mocks/mock_lwip.cpp +++ b/test/test_native/mocks/mock_lwip.cpp @@ -784,6 +784,9 @@ tcp_pcb *fire_accept(tcp_pcb *listen_pcb, const ip_addr_t &peer, uint16_t port, tcp_pcb *newpcb = alloc_pcb(); newpcb->state = ESTABLISHED; newpcb->local_ip = listen_pcb->local_ip; + if (listen_pcb->local_ip.type == IPADDR_TYPE_ANY) { + newpcb->local_ip.type = peer.type; // a dual-stack listener answers in the peer's family + } newpcb->local_port = listen_pcb->local_port; newpcb->remote_ip = peer; newpcb->remote_port = port; @@ -797,6 +800,15 @@ tcp_pcb *fire_accept(tcp_pcb *listen_pcb, const ip_addr_t &peer, uint16_t port, return newpcb; } +void fire_accept_failure(tcp_pcb *listen_pcb, err_t err) { + on_lwip_thread lwip; + if (!listen_pcb || !is_live(listen_pcb) || !listen_pcb->accept) { + return; + } + rec("accept_offered", nullptr, err); + listen_pcb->accept(listen_pcb->callback_arg, nullptr, err); +} + unsigned reentrant_api_calls() { return g_reentrant_api_calls; } diff --git a/test/test_native/mocks/mock_lwip.h b/test/test_native/mocks/mock_lwip.h index ace60b9f..5c783e13 100644 --- a/test/test_native/mocks/mock_lwip.h +++ b/test/test_native/mocks/mock_lwip.h @@ -116,9 +116,13 @@ err_t fire_poll(tcp_pcb *pcb); // tcp_accept_fn. Returns the new pcb (which may already have been freed by the // library, so check is_live() before dereferencing). As in lwIP, a return other than // ERR_OK or ERR_ABRT aborts the new pcb. The peer is 10.0.0.2:40000 unless given. The -// local address is the listener's. +// local address is the listener's; on a listener bound to any address, only its family +// follows the peer's. tcp_pcb *fire_accept(tcp_pcb *listen_pcb, err_t err = 0); tcp_pcb *fire_accept(tcp_pcb *listen_pcb, const ip_addr_t &peer, uint16_t port, err_t err = 0); +// A SYN lwIP could not allocate a pcb for: tcp_accept_fn with a NULL pcb and , whose +// return lwIP ignores. +void fire_accept_failure(tcp_pcb *listen_pcb, err_t err = ERR_MEM); // Convenience: build a pbuf holding bytes of . pbuf *make_pbuf(const void *data, size_t len); diff --git a/test/test_native/test_main.cpp b/test/test_native/test_main.cpp index b82abd75..86d3c6ca 100644 --- a/test/test_native/test_main.cpp +++ b/test/test_native/test_main.cpp @@ -47,6 +47,7 @@ void run_recv_tests(void); void run_timeout_tests(void); void run_dns_tests(void); void run_server_listen_tests(void); +void run_server_accept_tests(void); namespace { @@ -335,6 +336,7 @@ int main(int argc, char **argv) { run_timeout_tests(); run_dns_tests(); run_server_listen_tests(); + run_server_accept_tests(); if (g_list_only) { return 0; diff --git a/test/test_native/tests/test_server_accept.cpp b/test/test_native/tests/test_server_accept.cpp new file mode 100644 index 00000000..f300ff61 --- /dev/null +++ b/test/test_native/tests/test_server_accept.cpp @@ -0,0 +1,748 @@ +// AsyncServer's accepted connections: delivery to onClient, the ones never delivered, +// several at once, and how each outlives or dies with the server. + +#include "fixtures.h" + +#include +#include +#include + +using namespace mocklwip; + +static void test_server_accept_delivers_a_client_to_onClient(void) { + AsyncServer s(kServerPort); + Accepted accepted(s); + s.begin(); + + tcp_pcb *lp = listen_pcb(); + TEST_ASSERT_NOT_NULL(lp); + + tcp_pcb *conn = fire_accept(lp); + TEST_ASSERT_NOT_NULL(conn); + TEST_ASSERT_EQUAL_size_t(0, accepted.size()); // queued, not yet delivered + + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(1, accepted.size()); + + TEST_ASSERT_EQUAL_PTR(conn, accepted[0]->pcb()); + TEST_ASSERT_TRUE(accepted[0]->connected()); + TEST_ASSERT_EQUAL_INT(40000, (int)accepted[0]->remotePort()); + + // AsyncServer hands ownership of the client to the application. + accepted.drop(0); + TEST_ASSERT_FALSE(is_live(conn)); + + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_server_accept_without_a_handler_closes_the_connection(void) { + AsyncServer s(kServerPort); + s.begin(); // no onClient() + + tcp_pcb *lp = listen_pcb(); + tcp_pcb *conn = fire_accept(lp); + TEST_ASSERT_NOT_NULL(conn); + TEST_ASSERT_FALSE(is_live(conn)); // closed straight away + asynctcp_test_pump(); + + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_server_accept_delivered_client_receives_data(void) { + AsyncServer s(kServerPort); + std::string got; + Accepted accepted(s, [&](AsyncClient *c) { + c->onData([&](void *, AsyncClient *, void *d, size_t n) { + got.assign((const char *)d, n); + }); + }); + s.begin(); + + tcp_pcb *conn = fire_accept(listen_pcb()); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(1, accepted.size()); + + fire_recv(conn, "GET /", 5); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("GET /", got.c_str()); + TEST_ASSERT_TRUE(saw("tcp_recved", conn, 5)); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + + accepted.clear(); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +// --------------------------------------------------------------------------- +// Accepts that are never delivered +// --------------------------------------------------------------------------- + +static void test_server_accept_drops_a_client_the_peer_resets_first(void) { + // A connection reset between lwIP accepting it and the async task delivering it must + // simply cease to exist - it was never visible to the application. + AsyncServer s(kServerPort); + Accepted accepted(s); + s.begin(); + + tcp_pcb *lp = listen_pcb(); + TEST_ASSERT_NOT_NULL(lp); + tcp_pcb *conn = fire_accept(lp); + TEST_ASSERT_NOT_NULL(conn); + + fire_error(conn, ERR_RST); // before the accept is delivered + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(0, accepted.size()); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_server_accept_an_accept_lwip_could_not_allocate_delivers_nothing(void) { + // lwIP ran out of pcbs for an incoming SYN. There is no connection to deliver, and the + // server goes on listening. + AsyncServer s(kServerPort); + Accepted accepted(s); + s.begin(); + + fire_accept_failure(listen_pcb()); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(0, accepted.size()); + TEST_ASSERT_EQUAL_INT((int)LISTEN, (int)s.status()); + TEST_ASSERT_EQUAL_size_t(1, live_pcbs()); // the listen pcb alone + + tcp_pcb *conn = fire_accept(listen_pcb()); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(1, accepted.size()); + TEST_ASSERT_EQUAL_PTR(conn, accepted[0]->pcb()); + + accepted.clear(); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_server_accept_end_drops_clients_never_delivered(void) { + // Connections accepted but not yet delivered when end() is called are never + // delivered; they are closed once the async task reaches them. + AsyncServer s(kServerPort); + Accepted accepted(s); + s.begin(); + + tcp_pcb *lp = listen_pcb(); + tcp_pcb *conn = fire_accept(lp); + TEST_ASSERT_NOT_NULL(conn); + TEST_ASSERT_EQUAL_size_t(0, accepted.size()); // queued, not yet delivered + + s.end(); // orphans the queued accept + asynctcp_test_pump(); // ...which the async task then drops + + TEST_ASSERT_EQUAL_size_t(0, accepted.size()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_server_accept_destroying_the_server_drops_an_accept_in_flight(void) { + // Destroying the AsyncServer with a connection still waiting to be delivered must + // neither crash nor deliver it. The single-threaded harness can only reach the + // queued case; a destructor running concurrently with delivery needs two threads and + // is not expressible here. + int accepted = 0; + { + AsyncServer s(kServerPort); + s.onClient( + [&](void *, AsyncClient *) { + accepted++; + }, + nullptr + ); + s.begin(); + TEST_ASSERT_NOT_NULL(fire_accept(listen_pcb())); + TEST_ASSERT_EQUAL_INT(0, accepted); // queued, not yet delivered + } // server destroyed with the accept still outstanding + + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(0, accepted); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_server_accept_restart_does_not_resurrect_a_queued_accept(void) { + // end() disowns connections accepted on the session it closes. Restarting must not + // undo that: the queued connection belongs to the previous listening session, and + // listening again must not let it through. + AsyncServer s(kServerPort); + Accepted accepted(s); + s.begin(); + + TEST_ASSERT_NOT_NULL(fire_accept(listen_pcb())); + TEST_ASSERT_EQUAL_size_t(0, accepted.size()); // queued, not yet delivered + + s.end(); + s.begin(); // listening again, on a fresh pcb + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(0, accepted.size()); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +// --------------------------------------------------------------------------- +// Several connections at once +// --------------------------------------------------------------------------- + +static void test_server_accept_takes_more_connections_than_the_backlog(void) { + // The backlog bounds lwIP's queue of half-open connections, not how many established + // ones AsyncServer will hand over. Offering more than that in one go must deliver all + // of them; the library imposes no ceiling of its own. + const size_t kCount = (size_t)kExpectedBacklog + 3; + + AsyncServer s(kServerPort); + Accepted clients(s); + s.begin(); + + std::vector conns; + for (size_t i = 0; i < kCount; i++) { + tcp_pcb *conn = fire_accept(listen_pcb()); + TEST_ASSERT_NOT_NULL(conn); + conns.push_back(conn); + } + TEST_ASSERT_EQUAL_size_t(0, clients.size()); // all queued, none delivered yet + + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(kCount, clients.size()); + + // One client per connection, and no connection delivered twice. + for (tcp_pcb *conn : conns) { + TEST_ASSERT_NOT_NULL(clients.holding(conn)); + } + TEST_ASSERT_EQUAL_size_t(kCount + 1, live_pcbs()); // the clients, plus the listen pcb + + clients.clear(); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_server_accept_concurrent_clients_get_their_own_pcbs(void) { + AsyncServer s(kServerPort); + Accepted clients(s); + s.begin(); + + tcp_pcb *a = fire_accept(listen_pcb()); + asynctcp_test_pump(); + tcp_pcb *b = fire_accept(listen_pcb()); + asynctcp_test_pump(); + tcp_pcb *c = fire_accept(listen_pcb()); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(3, clients.size()); + TEST_ASSERT_TRUE(a != b && b != c && a != c); + TEST_ASSERT_EQUAL_PTR(a, clients[0]->pcb()); + TEST_ASSERT_EQUAL_PTR(b, clients[1]->pcb()); + TEST_ASSERT_EQUAL_PTR(c, clients[2]->pcb()); + TEST_ASSERT_TRUE(clients[0]->connected()); + TEST_ASSERT_TRUE(clients[1]->connected()); + TEST_ASSERT_TRUE(clients[2]->connected()); + + clients.clear(); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_server_accept_data_reaches_only_the_addressed_client(void) { + AsyncServer s(kServerPort); + std::vector got(3); + size_t slots = 0; + Accepted clients(s, [&](AsyncClient *c) { + const size_t slot = slots++; + c->onData([&got, slot](void *, AsyncClient *, void *d, size_t n) { + got[slot].append((const char *)d, n); + }); + }); + s.begin(); + + std::vector conns; + for (int i = 0; i < 3; i++) { + conns.push_back(fire_accept(listen_pcb())); + asynctcp_test_pump(); + } + TEST_ASSERT_EQUAL_size_t(3, clients.size()); + + fire_recv(conns[1], "middle", 6); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("", got[0].c_str()); + TEST_ASSERT_EQUAL_STRING("middle", got[1].c_str()); + TEST_ASSERT_EQUAL_STRING("", got[2].c_str()); + TEST_ASSERT_TRUE(saw("tcp_recved", conns[1], 6)); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_recved", conns[0])); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + + clients.clear(); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_server_accept_closing_one_client_leaves_the_others_running(void) { + AsyncServer s(kServerPort); + std::string tail; + Accepted clients(s, [&](AsyncClient *c) { + c->onData([&](void *, AsyncClient *, void *d, size_t n) { + tail.append((const char *)d, n); + }); + }); + s.begin(); + + std::vector conns; + for (int i = 0; i < 3; i++) { + conns.push_back(fire_accept(listen_pcb())); + asynctcp_test_pump(); + } + + clients.drop(0); + TEST_ASSERT_FALSE(is_live(conns[0])); + TEST_ASSERT_TRUE(is_live(conns[1])); + TEST_ASSERT_TRUE(is_live(conns[2])); + + // The survivors keep their callbacks and their pcbs. + fire_recv(conns[2], "still here", 10); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("still here", tail.c_str()); + TEST_ASSERT_TRUE(clients[1]->connected()); + + clients.clear(); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_server_accept_one_client_reset_leaves_the_others_running(void) { + // A fatal error frees one connection's pcb behind the library's back. The other + // connections share nothing with it but the server, so they must be untouched. + AsyncServer s(kServerPort); + int errors = 0; + std::string tail; + Accepted clients(s, [&](AsyncClient *c) { + c->onError([&](void *, AsyncClient *, int8_t) { + errors++; + }); + c->onData([&](void *, AsyncClient *, void *d, size_t n) { + tail.append((const char *)d, n); + }); + }); + s.begin(); + + std::vector conns; + for (int i = 0; i < 3; i++) { + conns.push_back(fire_accept(listen_pcb())); + asynctcp_test_pump(); + } + + fire_error(conns[0], ERR_RST); // conns[0] is dangling from here on + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, errors); + TEST_ASSERT_FALSE(clients[0]->connected()); + TEST_ASSERT_TRUE(clients[1]->connected()); + + fire_recv(conns[2], "ok", 2); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("ok", tail.c_str()); + + clients.clear(); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +// --------------------------------------------------------------------------- +// end(), restart and destruction order with live clients +// --------------------------------------------------------------------------- + +static void test_server_accept_end_leaves_established_clients_running(void) { + // end() closes the listen pcb only. A connection already handed to the application is + // the application's to close. + AsyncServer s(kServerPort); + std::string tail; + Accepted clients(s, [&](AsyncClient *c) { + c->onData([&](void *, AsyncClient *, void *d, size_t n) { + tail.append((const char *)d, n); + }); + }); + s.begin(); + + std::vector conns; + for (int i = 0; i < 2; i++) { + conns.push_back(fire_accept(listen_pcb())); + asynctcp_test_pump(); + } + + s.end(); + TEST_ASSERT_EQUAL_INT((int)CLOSED, (int)s.status()); + TEST_ASSERT_FALSE(port_is_bound(kServerPort)); + TEST_ASSERT_EQUAL_size_t(2, live_pcbs()); // the two connections, and nothing else + TEST_ASSERT_TRUE(clients[0]->connected()); + TEST_ASSERT_TRUE(clients[1]->connected()); + + fire_recv(conns[0], "after end", 9); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("after end", tail.c_str()); + + clients.clear(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_server_accept_restart_does_not_disturb_an_earlier_client(void) { + // Disowning the queued accepts must not touch a connection that was already delivered + // on the previous session. + AsyncServer s(kServerPort); + std::string tail; + Accepted clients(s, [&](AsyncClient *c) { + c->onData([&](void *, AsyncClient *, void *d, size_t n) { + tail.append((const char *)d, n); + }); + }); + s.begin(); + tcp_pcb *old_conn = fire_accept(listen_pcb()); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(1, clients.size()); + + s.end(); + s.begin(); + tcp_pcb *new_conn = fire_accept(listen_pcb()); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(2, clients.size()); + + TEST_ASSERT_TRUE(is_live(old_conn)); + TEST_ASSERT_TRUE(clients[0]->connected()); + fire_recv(old_conn, "old", 3); + fire_recv(new_conn, "new", 3); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("oldnew", tail.c_str()); + + clients.clear(); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_server_accept_server_destroyed_before_its_client(void) { + // ~AsyncServer() stops listening and detaches; a connection already delivered has no + // dependency on the server left and keeps working. + AsyncClient *client = nullptr; + std::string tail; + tcp_pcb *conn = nullptr; + { + AsyncServer s(kServerPort); + s.onClient( + [&](void *, AsyncClient *c) { + client = c; + c->onData([&](void *, AsyncClient *, void *d, size_t n) { + tail.append((const char *)d, n); + }); + }, + nullptr + ); + s.begin(); + conn = fire_accept(listen_pcb()); + asynctcp_test_pump(); + TEST_ASSERT_NOT_NULL(client); + } // server destroyed, connection still open + + TEST_ASSERT_FALSE(port_is_bound(kServerPort)); + TEST_ASSERT_EQUAL_size_t(1, live_pcbs()); + TEST_ASSERT_TRUE(client->connected()); + + fire_recv(conn, "orphan", 6); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("orphan", tail.c_str()); + + delete client; + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_server_accept_client_destroyed_before_its_server(void) { + // The other order: the server outlives the connection and goes on accepting. + AsyncServer s(kServerPort); + Accepted clients(s); + s.begin(); + + tcp_pcb *first = fire_accept(listen_pcb()); + asynctcp_test_pump(); + clients.drop(0); + TEST_ASSERT_FALSE(is_live(first)); + TEST_ASSERT_EQUAL_INT((int)LISTEN, (int)s.status()); + + tcp_pcb *second = fire_accept(listen_pcb()); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(1, clients.size()); + TEST_ASSERT_EQUAL_PTR(second, clients[0]->pcb()); + + clients.clear(); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +// --------------------------------------------------------------------------- +// Turning connections away +// --------------------------------------------------------------------------- + +static void test_server_accept_a_queued_accept_precedes_its_own_data(void) { + // Delivery order between two connections is not guaranteed -- but a connection must + // be delivered to onClient before anything is delivered for it. + AsyncServer s(kServerPort); + tcp_pcb *first = nullptr; + std::vector log; + Accepted clients(s, [&](AsyncClient *c) { + log.push_back(c->pcb() == first ? "accept:first" : "accept:second"); + c->onData([&](void *, AsyncClient *, void *d, size_t n) { + log.push_back(std::string("data:") + std::string((const char *)d, n)); + }); + }); + s.begin(); + + first = fire_accept(listen_pcb()); + fire_recv(first, "early", 5); + TEST_ASSERT_NOT_NULL(fire_accept(listen_pcb())); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(3, log.size()); + auto at = [&](const char *entry) { + return std::find(log.begin(), log.end(), entry) - log.begin(); + }; + TEST_ASSERT_TRUE(at("accept:second") < 3); + TEST_ASSERT_TRUE(at("accept:first") < at("data:early")); + TEST_ASSERT_TRUE(at("data:early") < 3); + + clients.clear(); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_server_accept_rejecting_a_client_from_onClient_closes_it(void) { + // How an application enforces a connection cap of its own: take delivery and destroy the + // client on the spot. The connection has to go, and the server has to stay listening. + AsyncServer s(kServerPort); + int accepted = 0; + s.onClient( + [&](void *, AsyncClient *c) { + accepted++; + delete c; + }, + nullptr + ); + s.begin(); + + tcp_pcb *rejected = fire_accept(listen_pcb()); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, accepted); + TEST_ASSERT_FALSE(is_live(rejected)); + TEST_ASSERT_EQUAL_INT((int)LISTEN, (int)s.status()); + TEST_ASSERT_EQUAL_size_t(1, live_pcbs()); // the listen pcb alone + + tcp_pcb *next = fire_accept(listen_pcb()); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(2, accepted); + TEST_ASSERT_FALSE(is_live(next)); + + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_server_accept_end_from_inside_onClient_drops_the_rest(void) { + // The other way to cap connections: close the door as the last one wanted arrives. + // end() disowns whatever else was accepted on that session -- and end() from the + // async task must not deadlock against the delivery that called it. + AsyncServer s(kServerPort); + Accepted clients(s, [&](AsyncClient *) { + s.end(); + }); + s.begin(); + + tcp_pcb *lp = listen_pcb(); + TEST_ASSERT_NOT_NULL(fire_accept(lp)); + TEST_ASSERT_NOT_NULL(fire_accept(lp)); + asynctcp_test_pump(); + + // Whichever of the two is delivered first shuts the server; the other is disowned. + TEST_ASSERT_EQUAL_size_t(1, clients.size()); + TEST_ASSERT_EQUAL_INT((int)CLOSED, (int)s.status()); + TEST_ASSERT_FALSE(port_is_bound(kServerPort)); + TEST_ASSERT_TRUE(clients[0]->connected()); + TEST_ASSERT_EQUAL_size_t(1, live_pcbs()); // only the connection that was kept + TEST_ASSERT_EQUAL_UINT(0, mockrtos::deadlocks()); + + clients.clear(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_server_accept_destroying_the_server_from_onClient_drops_the_rest(void) { + // Nothing may reach a server once it is destroyed; the accepts still queued go with it. + AsyncServer *s = new AsyncServer(kServerPort); + std::vector clients; + s->onClient( + [&](void *, AsyncClient *c) { + clients.push_back(c); + delete s; + s = nullptr; + }, + nullptr + ); + s->begin(); + + tcp_pcb *lp = listen_pcb(); + TEST_ASSERT_NOT_NULL(fire_accept(lp)); + TEST_ASSERT_NOT_NULL(fire_accept(lp)); + TEST_ASSERT_NOT_NULL(fire_accept(lp)); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(1, clients.size()); + TEST_ASSERT_FALSE(port_is_bound(kServerPort)); + TEST_ASSERT_TRUE(clients[0]->connected()); + TEST_ASSERT_EQUAL_size_t(1, live_pcbs()); // only the connection that was delivered + + delete clients[0]; + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +// --------------------------------------------------------------------------- +// Who connected, and on what address +// --------------------------------------------------------------------------- + +static void test_server_accept_client_reports_the_peer_that_connected(void) { + AsyncServer s(kServerPort); + Accepted accepted(s); + s.begin(); + + ip_addr_t peer = IPADDR4_INIT(0x0A000009); // 10.0.0.9 + tcp_pcb *conn = fire_accept(listen_pcb(), peer, 41234); + TEST_ASSERT_NOT_NULL(conn); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(1, accepted.size()); + + TEST_ASSERT_TRUE(accepted[0]->remoteIP() == IPAddress(10, 0, 0, 9)); + TEST_ASSERT_EQUAL_UINT16(41234, accepted[0]->remotePort()); + TEST_ASSERT_EQUAL_UINT16(kServerPort, accepted[0]->localPort()); + + accepted.clear(); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_server_accept_on_an_ip_addr_t_server_reports_that_address(void) { + const uint32_t kBind = 0x0A000007; // 10.0.0.7 + ip_addr_t addr = IPADDR4_INIT(kBind); + AsyncServer s(addr, kServerPort); + Accepted accepted(s); + s.begin(); + + TEST_ASSERT_NOT_NULL(fire_accept(listen_pcb())); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(1, accepted.size()); + TEST_ASSERT_TRUE(accepted[0]->localIP() == IPAddress(kBind)); + TEST_ASSERT_EQUAL_UINT32(kBind, accepted[0]->getLocalAddress4().addr); + + accepted.clear(); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +#if LWIP_IPV6 +static void test_server_accept_ipv6_connection_end_to_end(void) { + const uint32_t kBind[4] = {0x20010db8, 0x00000000, 0x00000000, 0x00000007}; + const uint32_t kPeerWords[4] = {0x20010db8, 0x00000000, 0x00000000, 0x00000009}; + AsyncServer s(IPv6Address(kBind), kServerPort); + std::string got; + Accepted accepted(s, [&](AsyncClient *c) { + c->onData([&](void *, AsyncClient *cl, void *d, size_t n) { + got.append((const char *)d, n); + cl->write("pong", 4); + }); + }); + s.begin(); + + ip_addr_t peer = IPADDR6_INIT(kPeerWords[0], kPeerWords[1], kPeerWords[2], kPeerWords[3]); + tcp_pcb *conn = fire_accept(listen_pcb(), peer, 41234); + TEST_ASSERT_NOT_NULL(conn); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(1, accepted.size()); + AsyncClient *client = accepted[0]; + + TEST_ASSERT_TRUE(client->connected()); + TEST_ASSERT_TRUE(client->remoteIP6() == IPv6Address(kPeerWords)); + TEST_ASSERT_TRUE(client->localIP6() == IPv6Address(kBind)); + ip6_addr_t local = client->getLocalAddress6(); + ip6_addr_t remote = client->getRemoteAddress6(); + for (int i = 0; i < 4; ++i) { + TEST_ASSERT_EQUAL_UINT32(kBind[i], local.addr[i]); + TEST_ASSERT_EQUAL_UINT32(kPeerWords[i], remote.addr[i]); + } + TEST_ASSERT_EQUAL_UINT16(41234, client->remotePort()); + TEST_ASSERT_EQUAL_UINT16(kServerPort, client->localPort()); + + fire_recv(conn, "ping", 4); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("ping", got.c_str()); + TEST_ASSERT_EQUAL_STRING("pong", written(conn).c_str()); + + fire_fin(conn); + asynctcp_test_pump(); + TEST_ASSERT_TRUE(client->disconnected()); + + accepted.clear(); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_server_accept_any_address_takes_both_families(void) { + const uint32_t kPeerWords[4] = {0x20010db8, 0x00000000, 0x00000000, 0x00000009}; + AsyncServer s(kServerPort); + Accepted accepted(s); + s.begin(); + + ip_addr_t peer4 = IPADDR4_INIT(0x0A000009); + ip_addr_t peer6 = IPADDR6_INIT(kPeerWords[0], kPeerWords[1], kPeerWords[2], kPeerWords[3]); + tcp_pcb *conn4 = fire_accept(listen_pcb(), peer4, 41234); + asynctcp_test_pump(); + tcp_pcb *conn6 = fire_accept(listen_pcb(), peer6, 41235); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(2, accepted.size()); + + AsyncClient *c4 = accepted.holding(conn4); + AsyncClient *c6 = accepted.holding(conn6); + TEST_ASSERT_NOT_NULL(c4); + TEST_ASSERT_NOT_NULL(c6); + TEST_ASSERT_TRUE(c4->remoteIP() == IPAddress(10, 0, 0, 9)); + TEST_ASSERT_TRUE(c6->remoteIP6() == IPv6Address(kPeerWords)); + + accepted.clear(); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} +#endif // LWIP_IPV6 + +void run_server_accept_tests(void) { + UnitySetTestFile(__FILE__); + RUN_TEST(test_server_accept_delivers_a_client_to_onClient); + RUN_TEST(test_server_accept_without_a_handler_closes_the_connection); + RUN_TEST(test_server_accept_delivered_client_receives_data); + RUN_TEST(test_server_accept_drops_a_client_the_peer_resets_first); + RUN_TEST(test_server_accept_an_accept_lwip_could_not_allocate_delivers_nothing); + RUN_TEST(test_server_accept_end_drops_clients_never_delivered); + RUN_TEST(test_server_accept_destroying_the_server_drops_an_accept_in_flight); + RUN_TEST(test_server_accept_restart_does_not_resurrect_a_queued_accept); + RUN_TEST(test_server_accept_takes_more_connections_than_the_backlog); + RUN_TEST(test_server_accept_concurrent_clients_get_their_own_pcbs); + RUN_TEST(test_server_accept_data_reaches_only_the_addressed_client); + RUN_TEST(test_server_accept_closing_one_client_leaves_the_others_running); + RUN_TEST(test_server_accept_one_client_reset_leaves_the_others_running); + RUN_TEST(test_server_accept_end_leaves_established_clients_running); + RUN_TEST(test_server_accept_restart_does_not_disturb_an_earlier_client); + RUN_TEST(test_server_accept_server_destroyed_before_its_client); + RUN_TEST(test_server_accept_client_destroyed_before_its_server); + RUN_TEST(test_server_accept_a_queued_accept_precedes_its_own_data); + RUN_TEST(test_server_accept_rejecting_a_client_from_onClient_closes_it); + RUN_TEST(test_server_accept_end_from_inside_onClient_drops_the_rest); + RUN_TEST(test_server_accept_destroying_the_server_from_onClient_drops_the_rest); + RUN_TEST(test_server_accept_client_reports_the_peer_that_connected); + RUN_TEST(test_server_accept_on_an_ip_addr_t_server_reports_that_address); +#if LWIP_IPV6 + RUN_TEST(test_server_accept_ipv6_connection_end_to_end); + RUN_TEST(test_server_accept_any_address_takes_both_families); +#endif +} From 787fa9fd4a28c569114f0bfd8fc4a45dd7780029 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Wed, 30 Sep 2026 23:35:22 -0400 Subject: [PATCH 11/24] test: Cover the server's configuration Co-Authored-By: Claude Opus 5.5 --- test/test_native/README.md | 1 + test/test_native/test_main.cpp | 2 + test/test_native/tests/test_server_config.cpp | 151 ++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 test/test_native/tests/test_server_config.cpp diff --git a/test/test_native/README.md b/test/test_native/README.md index ed58a970..09a9b3f2 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -122,6 +122,7 @@ test/test_native/ test_dns.cpp name resolution, and lookups that are abandoned test_server_listen.cpp begin(), end(), status(), restarts test_server_accept.cpp delivering accepted connections + test_server_config.cpp setNoDelay() mocks/ include/ fake system headers -- on the include path FIRST, so #include "lwip/tcp.h" etc. resolve here diff --git a/test/test_native/test_main.cpp b/test/test_native/test_main.cpp index 86d3c6ca..cbba4259 100644 --- a/test/test_native/test_main.cpp +++ b/test/test_native/test_main.cpp @@ -48,6 +48,7 @@ void run_timeout_tests(void); void run_dns_tests(void); void run_server_listen_tests(void); void run_server_accept_tests(void); +void run_server_config_tests(void); namespace { @@ -337,6 +338,7 @@ int main(int argc, char **argv) { run_dns_tests(); run_server_listen_tests(); run_server_accept_tests(); + run_server_config_tests(); if (g_list_only) { return 0; diff --git a/test/test_native/tests/test_server_config.cpp b/test/test_native/tests/test_server_config.cpp new file mode 100644 index 00000000..3cbc7fc2 --- /dev/null +++ b/test/test_native/tests/test_server_config.cpp @@ -0,0 +1,151 @@ +// AsyncServer's configuration: setNoDelay()/getNoDelay() and how it reaches the +// connections the server accepts. + +#include "fixtures.h" + +using namespace mocklwip; + +namespace { + +const uint16_t kOtherPort = 8082; + +} // namespace + +static void test_server_config_getNoDelay_defaults_to_false(void) { + AsyncServer s(kServerPort); + TEST_ASSERT_FALSE(s.getNoDelay()); +} + +static void test_server_config_getNoDelay_reports_what_was_set(void) { + // Pure state on the server, readable with no pcb in existence either side of begin(). + AsyncServer s(kServerPort); + s.setNoDelay(true); + TEST_ASSERT_TRUE(s.getNoDelay()); + s.setNoDelay(false); + TEST_ASSERT_FALSE(s.getNoDelay()); + + s.begin(); + s.setNoDelay(true); + TEST_ASSERT_TRUE(s.getNoDelay()); + s.end(); + TEST_ASSERT_TRUE(s.getNoDelay()); // survives the listening session +} + +static void test_server_config_default_accepts_leave_nagle_enabled(void) { + AsyncServer s(kServerPort); + Accepted accepted(s); + s.begin(); + + tcp_pcb *conn = fire_accept(listen_pcb()); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(1, accepted.size()); + TEST_ASSERT_FALSE(tcp_nagle_disabled(conn)); + TEST_ASSERT_FALSE(accepted[0]->getNoDelay()); + + accepted.clear(); + s.end(); +} + +static void test_server_config_setNoDelay_propagates_to_accepted_clients(void) { + AsyncServer s(kServerPort); + s.setNoDelay(true); + TEST_ASSERT_TRUE(s.getNoDelay()); + + Accepted accepted(s); + s.begin(); + + tcp_pcb *conn = fire_accept(listen_pcb()); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(1, accepted.size()); + TEST_ASSERT_TRUE(accepted[0]->getNoDelay()); + TEST_ASSERT_TRUE(tcp_nagle_disabled(conn)); + + accepted.clear(); + s.end(); +} + +static void test_server_config_setNoDelay_after_begin_reaches_later_accepts(void) { + // A change made while already listening has to apply to everything accepted from then + // on. + AsyncServer s(kServerPort); + Accepted clients(s); + s.begin(); + + tcp_pcb *before = fire_accept(listen_pcb()); + asynctcp_test_pump(); + + s.setNoDelay(true); + tcp_pcb *after = fire_accept(listen_pcb()); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(2, clients.size()); + TEST_ASSERT_TRUE(tcp_nagle_disabled(after)); + TEST_ASSERT_TRUE(clients[1]->getNoDelay()); + // The earlier connection belongs to the application now; the server must not reach back + // into a client it has already handed over. + TEST_ASSERT_FALSE(tcp_nagle_disabled(before)); + + clients.clear(); + s.end(); +} + +static void test_server_config_setNoDelay_applies_across_a_restart(void) { + // end() drops the listening session, not the server's configuration. + AsyncServer s(kServerPort); + Accepted accepted(s); + s.setNoDelay(true); + s.begin(); + s.end(); + s.begin(); + + tcp_pcb *conn = fire_accept(listen_pcb()); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(1, accepted.size()); + TEST_ASSERT_TRUE(s.getNoDelay()); + TEST_ASSERT_TRUE(tcp_nagle_disabled(conn)); + + accepted.clear(); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_server_config_noDelay_is_per_server(void) { + // Two servers listening at once must not share the setting. + AsyncServer a(kServerPort); + AsyncServer b(kOtherPort); + Accepted from_a(a); + Accepted from_b(b); + a.setNoDelay(true); + a.begin(); + b.begin(); + + tcp_pcb *pa = fire_accept(listen_pcb(kServerPort)); + tcp_pcb *pb = fire_accept(listen_pcb(kOtherPort)); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(1, from_a.size()); + TEST_ASSERT_EQUAL_size_t(1, from_b.size()); + TEST_ASSERT_TRUE(tcp_nagle_disabled(pa)); + TEST_ASSERT_FALSE(tcp_nagle_disabled(pb)); + TEST_ASSERT_FALSE(b.getNoDelay()); + + from_a.clear(); + from_b.clear(); + a.end(); + b.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +void run_server_config_tests(void) { + UnitySetTestFile(__FILE__); + RUN_TEST(test_server_config_getNoDelay_defaults_to_false); + RUN_TEST(test_server_config_getNoDelay_reports_what_was_set); + RUN_TEST(test_server_config_default_accepts_leave_nagle_enabled); + RUN_TEST(test_server_config_setNoDelay_propagates_to_accepted_clients); + RUN_TEST(test_server_config_setNoDelay_after_begin_reaches_later_accepts); + RUN_TEST(test_server_config_setNoDelay_applies_across_a_restart); + RUN_TEST(test_server_config_noDelay_is_per_server); +} From bb2c16efb023966aac62408a32574bc89d9aa2d3 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Wed, 30 Sep 2026 23:35:46 -0400 Subject: [PATCH 12/24] test: Cover the connection state machine and the accessors Co-Authored-By: Claude Opus 5.5 --- test/test_native/README.md | 1 + test/test_native/test_main.cpp | 2 + test/test_native/tests/test_state.cpp | 817 ++++++++++++++++++++++++++ 3 files changed, 820 insertions(+) create mode 100644 test/test_native/tests/test_state.cpp diff --git a/test/test_native/README.md b/test/test_native/README.md index 09a9b3f2..8f06e002 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -116,6 +116,7 @@ test/test_native/ listen_pcb(), Recorder, Accepted tests/ one file per topic; add files here, they are picked up test_client.cpp end-to-end smoke tests, one per main path + test_state.cpp the connection state machine and the accessors test_write.cpp the outbound path test_recv.cpp the inbound path test_timeout.cpp the rx and ack timeouts, onPoll, keepalive diff --git a/test/test_native/test_main.cpp b/test/test_native/test_main.cpp index cbba4259..8e97c7d0 100644 --- a/test/test_native/test_main.cpp +++ b/test/test_native/test_main.cpp @@ -49,6 +49,7 @@ void run_dns_tests(void); void run_server_listen_tests(void); void run_server_accept_tests(void); void run_server_config_tests(void); +void run_state_tests(void); namespace { @@ -339,6 +340,7 @@ int main(int argc, char **argv) { run_server_listen_tests(); run_server_accept_tests(); run_server_config_tests(); + run_state_tests(); if (g_list_only) { return 0; diff --git a/test/test_native/tests/test_state.cpp b/test/test_native/tests/test_state.cpp new file mode 100644 index 00000000..14a8ea83 --- /dev/null +++ b/test/test_native/tests/test_state.cpp @@ -0,0 +1,817 @@ +// AsyncClient's connection state machine and its accessors. +// +// Two things are checked here: that the five state predicates agree with each other at +// every point in a connection's life, and that the address/port accessors report what +// was actually dialled -- and report zero, rather than whatever is left in a freed pcb, +// when there is no connection at all. + +#include "fixtures.h" + +using namespace mocklwip; + +namespace { + +// connecting/connected/disconnecting/disconnected are four names for "where in its life +// is this connection". They partition it: exactly one must hold at any moment. +void assert_one_phase(const AsyncClient &c, const char *where) { + const int n = (int)c.connecting() + (int)c.connected() + (int)c.disconnecting() + (int)c.disconnected(); + TEST_ASSERT_EQUAL_INT_MESSAGE(1, n, where); +} + +// freeable() is documented as "disconnected or disconnecting", and free() asks the same +// question under a different name. +void assert_freeable_agrees(AsyncClient &c, const char *where) { + TEST_ASSERT_EQUAL_INT_MESSAGE((int)(c.disconnected() || c.disconnecting()), (int)c.freeable(), where); + TEST_ASSERT_EQUAL_INT_MESSAGE((int)c.freeable(), (int)c.free(), where); +} + +} // namespace + +// --------------------------------------------------------------------------- +// The state machine +// --------------------------------------------------------------------------- + +static void test_state_fresh_client_is_closed_and_disconnected(void) { + AsyncClient c; + TEST_ASSERT_NULL(c.pcb()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_UINT8(CLOSED, c.state()); + TEST_ASSERT_EQUAL_STRING("Closed", c.stateToString()); + TEST_ASSERT_FALSE(c.connecting()); + TEST_ASSERT_FALSE(c.connected()); + TEST_ASSERT_FALSE(c.disconnecting()); + TEST_ASSERT_TRUE(c.disconnected()); + TEST_ASSERT_TRUE(c.freeable()); + TEST_ASSERT_TRUE(c.free()); +} + +static void test_state_connect_dials_the_peer_on_one_pcb(void) { + AsyncClient c; + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + + TEST_ASSERT_EQUAL_size_t(1, live_pcbs()); + tcp_pcb *pcb = dialled_pcb(); + TEST_ASSERT_NOT_NULL(pcb); + TEST_ASSERT_EQUAL_INT((int)SYN_SENT, (int)pcb->state); + TEST_ASSERT_EQUAL_UINT16(kPort, pcb->remote_port); + TEST_ASSERT_EQUAL_UINT32((uint32_t)kPeer, remote_ip4(pcb)); + + // Every callback should be wired up. What the library passes as the argument is its + // own business, so we can only check that one is set. + TEST_ASSERT_NOT_NULL(pcb->callback_arg); + TEST_ASSERT_NOT_NULL(pcb->recv); + TEST_ASSERT_NOT_NULL(pcb->sent); + TEST_ASSERT_NOT_NULL(pcb->errf); + TEST_ASSERT_NOT_NULL(pcb->poll); + + TEST_ASSERT_EQUAL_size_t(1, count("tcp_connect")); + TEST_ASSERT_TRUE(asynctcp_test_task_started()); +} + +static void test_state_connect_reports_syn_sent_until_the_handshake_lands(void) { + AsyncClient c; + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + + TEST_ASSERT_EQUAL_UINT8(SYN_SENT, c.state()); + TEST_ASSERT_EQUAL_STRING("SYN Sent", c.stateToString()); + TEST_ASSERT_TRUE(c.connecting()); + TEST_ASSERT_FALSE(c.connected()); + TEST_ASSERT_FALSE(c.disconnecting()); + TEST_ASSERT_FALSE(c.disconnected()); + // A half-open connection is still lwIP's; nothing to reclaim yet. + TEST_ASSERT_FALSE(c.freeable()); + + c.close(); +} + +static void test_state_established_once_the_connected_event_is_delivered(void) { + AsyncClient c; + int connects = 0; + AsyncClient *seen = nullptr; + c.onConnect([&](void *, AsyncClient *cl) { + connects++; + seen = cl; + }); + + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + tcp_pcb *pcb = dialled_pcb(); + TEST_ASSERT_NOT_NULL(pcb); + + // lwIP completes the handshake, but nothing reaches the application until the async + // task runs. + TEST_ASSERT_EQUAL_INT((int)ERR_OK, (int)fire_connected(pcb)); + TEST_ASSERT_EQUAL_INT(0, connects); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, connects); + TEST_ASSERT_EQUAL_PTR(&c, seen); + + TEST_ASSERT_EQUAL_UINT8(ESTABLISHED, c.state()); + TEST_ASSERT_EQUAL_STRING("Established", c.stateToString()); + TEST_ASSERT_FALSE(c.connecting()); + TEST_ASSERT_TRUE(c.connected()); + TEST_ASSERT_FALSE(c.disconnecting()); + TEST_ASSERT_FALSE(c.disconnected()); + TEST_ASSERT_FALSE(c.freeable()); + + c.close(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_state_close_returns_the_client_to_closed(void) { + AsyncClient c; + establish(c); + + c.close(); + TEST_ASSERT_NULL(c.pcb()); + TEST_ASSERT_EQUAL_UINT8(CLOSED, c.state()); + TEST_ASSERT_EQUAL_STRING("Closed", c.stateToString()); + TEST_ASSERT_FALSE(c.connecting()); + TEST_ASSERT_FALSE(c.connected()); + TEST_ASSERT_FALSE(c.disconnecting()); + TEST_ASSERT_TRUE(c.disconnected()); + TEST_ASSERT_TRUE(c.freeable()); +} + +static void test_state_predicates_partition_the_whole_connection_life(void) { + AsyncClient c; + assert_one_phase(c, "fresh"); + + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + assert_one_phase(c, "connecting"); + + tcp_pcb *pcb = dialled_pcb(); + TEST_ASSERT_NOT_NULL(pcb); + fire_connected(pcb); + assert_one_phase(c, "established, undelivered"); + asynctcp_test_pump(); + assert_one_phase(c, "established"); + + c.close(); + assert_one_phase(c, "closed"); +} + +static void test_state_connected_and_disconnected_are_never_both_true(void) { + AsyncClient c; + TEST_ASSERT_FALSE(c.connected() && c.disconnected()); + + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + TEST_ASSERT_FALSE(c.connected() && c.disconnected()); + + tcp_pcb *pcb = dialled_pcb(); + TEST_ASSERT_NOT_NULL(pcb); + fire_connected(pcb); + asynctcp_test_pump(); + TEST_ASSERT_FALSE(c.connected() && c.disconnected()); + + // The peer hangs up: the transition runs on the lwIP thread, so check both sides of + // the queue, not just the settled result. + fire_fin(pcb); + TEST_ASSERT_FALSE(c.connected() && c.disconnected()); + asynctcp_test_pump(); + TEST_ASSERT_FALSE(c.connected() && c.disconnected()); + TEST_ASSERT_TRUE(c.disconnected()); +} + +static void test_state_freeable_means_disconnected_or_disconnecting(void) { + AsyncClient c; + assert_freeable_agrees(c, "fresh"); + + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + assert_freeable_agrees(c, "connecting"); + + tcp_pcb *pcb = dialled_pcb(); + TEST_ASSERT_NOT_NULL(pcb); + fire_connected(pcb); + asynctcp_test_pump(); + assert_freeable_agrees(c, "established"); + + c.close(); + assert_freeable_agrees(c, "closed"); +} + +static void test_state_disconnecting_while_a_fin_is_outstanding(void) { + // lwIP moves the pcb to FIN_WAIT_1 when our FIN goes out; the library never leaves a + // pcb attached in that state itself, so drive it directly. + AsyncClient c; + tcp_pcb *pcb = establish(c); + + pcb->state = FIN_WAIT_1; + TEST_ASSERT_EQUAL_STRING("FIN Wait 1", c.stateToString()); + TEST_ASSERT_FALSE(c.connecting()); + TEST_ASSERT_FALSE(c.connected()); + TEST_ASSERT_TRUE(c.disconnecting()); + TEST_ASSERT_FALSE(c.disconnected()); + TEST_ASSERT_TRUE(c.freeable()); + assert_one_phase(c, "FIN_WAIT_1"); + + pcb->state = ESTABLISHED; + c.close(); +} + +static void test_state_time_wait_is_disconnected_not_disconnecting(void) { + // TIME_WAIT is past the close, not part of it: disconnecting() stops at TIME_WAIT and + // disconnected() picks it up. + AsyncClient c; + tcp_pcb *pcb = establish(c); + + pcb->state = TIME_WAIT; + TEST_ASSERT_EQUAL_STRING("Time Wait", c.stateToString()); + TEST_ASSERT_FALSE(c.disconnecting()); + TEST_ASSERT_TRUE(c.disconnected()); + TEST_ASSERT_TRUE(c.freeable()); + assert_one_phase(c, "TIME_WAIT"); + + pcb->state = ESTABLISHED; + c.close(); +} + +static void test_state_stateToString_names_every_lwip_state(void) { + static const char *const kNames[] = {"Closed", "Listen", "SYN Sent", "SYN Received", "Established", "FIN Wait 1", + "FIN Wait 2", "Close Wait", "Closing", "Last ACK", "Time Wait"}; + + AsyncClient c; + tcp_pcb *pcb = establish(c); + + for (uint8_t s = 0; s <= (uint8_t)TIME_WAIT; ++s) { + pcb->state = (tcp_state)s; + TEST_ASSERT_EQUAL_UINT8(s, c.state()); + TEST_ASSERT_EQUAL_STRING(kNames[s], c.stateToString()); + } + + pcb->state = (tcp_state)(TIME_WAIT + 1); // nothing lwIP defines + TEST_ASSERT_EQUAL_STRING("UNKNOWN", c.stateToString()); + + pcb->state = ESTABLISHED; + c.close(); +} + +static void test_state_connect_is_refused_while_already_connecting(void) { + AsyncClient c; + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + tcp_pcb *first = dialled_pcb(); + + TEST_ASSERT_FALSE(c.connect(kPeer, kPort)); + // Refused means untouched, not torn down and retried. + TEST_ASSERT_EQUAL_PTR(first, c.pcb()); + TEST_ASSERT_EQUAL_UINT8(SYN_SENT, c.state()); + TEST_ASSERT_EQUAL_size_t(1, count("tcp_connect")); + TEST_ASSERT_EQUAL_size_t(1, live_pcbs()); + + c.close(); +} + +static void test_state_connect_is_refused_while_connected(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + + TEST_ASSERT_FALSE(c.connect(kPeer, kPort)); + TEST_ASSERT_EQUAL_PTR(pcb, c.pcb()); + TEST_ASSERT_TRUE(c.connected()); + TEST_ASSERT_EQUAL_size_t(1, live_pcbs()); + + c.close(); +} + +static void test_state_close_twice_reports_one_disconnect(void) { + AsyncClient c; + int disconnects = 0; + c.onDisconnect([&](void *, AsyncClient *) { + disconnects++; + }); + establish(c); + + c.close(); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, disconnects); + TEST_ASSERT_TRUE(c.disconnected()); + + c.close(); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, disconnects); + TEST_ASSERT_TRUE(c.disconnected()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_state_abort_twice_reports_one_error(void) { + AsyncClient c; + int errors = 0; + c.onError([&](void *, AsyncClient *, int8_t) { + errors++; + }); + establish(c); + + TEST_ASSERT_EQUAL_INT8(ERR_ABRT, c.abort()); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, errors); + TEST_ASSERT_TRUE(c.disconnected()); + + // Nothing left to abort and nothing left to report. + TEST_ASSERT_EQUAL_INT8(ERR_CONN, c.abort()); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, errors); + TEST_ASSERT_TRUE(c.freeable()); +} + +static void test_state_stop_closes_like_close(void) { + AsyncClient c; + int disconnects = 0; + c.onDisconnect([&](void *, AsyncClient *) { + disconnects++; + }); + establish(c); + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + c.stop(); // deprecated alias for close() +#pragma GCC diagnostic pop + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_INT(1, disconnects); + TEST_ASSERT_NULL(c.pcb()); + TEST_ASSERT_TRUE(c.disconnected()); + TEST_ASSERT_TRUE(c.freeable()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_state_close_bool_closes_like_close(void) { + AsyncClient c; + int disconnects = 0; + c.onDisconnect([&](void *, AsyncClient *) { + disconnects++; + }); + establish(c); + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + c.close(true); // deprecated; the argument is ignored +#pragma GCC diagnostic pop + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_INT(1, disconnects); + TEST_ASSERT_NULL(c.pcb()); + TEST_ASSERT_TRUE(c.disconnected()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_state_connect_by_ip_addr_t_dials_that_address(void) { + AsyncClient c; + ip_addr_t addr = IPADDR4_INIT((uint32_t)kPeer); + TEST_ASSERT_TRUE(c.connect(addr, kPort)); + + tcp_pcb *pcb = dialled_pcb(); + TEST_ASSERT_NOT_NULL(pcb); + TEST_ASSERT_EQUAL_UINT32((uint32_t)kPeer, remote_ip4(pcb)); + TEST_ASSERT_EQUAL_UINT16(kPort, pcb->remote_port); + fire_connected(pcb); + asynctcp_test_pump(); + + TEST_ASSERT_TRUE(c.connected()); + TEST_ASSERT_TRUE(c.remoteIP() == kPeer); + c.close(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_state_a_client_equals_itself(void) { + AsyncClient c; + establish(c); + TEST_ASSERT_TRUE(c == c); + TEST_ASSERT_FALSE(c != c); + c.close(); +} + +static void test_state_clients_on_different_connections_are_unequal(void) { + AsyncClient a; + AsyncClient b; + establish(a); + establish(b); + TEST_ASSERT_FALSE(a == b); + TEST_ASSERT_TRUE(a != b); + a.close(); + b.close(); +} + +static void test_state_peer_fin_leaves_the_client_closed_and_freeable(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + + fire_fin(pcb); + asynctcp_test_pump(); + + TEST_ASSERT_NULL(c.pcb()); + TEST_ASSERT_EQUAL_UINT8(CLOSED, c.state()); + TEST_ASSERT_TRUE(c.disconnected()); + TEST_ASSERT_TRUE(c.freeable()); + TEST_ASSERT_TRUE(c.free()); + assert_one_phase(c, "after FIN"); +} + +static void test_state_reset_leaves_the_client_closed_and_freeable(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + + // lwIP frees the pcb before the error callback, so the library must have forgotten it + // by the time any of this can be asked. + fire_error(pcb, ERR_RST); + TEST_ASSERT_NULL(c.pcb()); + assert_one_phase(c, "reset, undelivered"); + TEST_ASSERT_TRUE(c.disconnected()); + + asynctcp_test_pump(); + TEST_ASSERT_TRUE(c.disconnected()); + TEST_ASSERT_TRUE(c.freeable()); + assert_one_phase(c, "reset, delivered"); +} + +// A client with a lookup in flight is not idle: it will dial when the answer arrives, +// so it must not report itself freeable(). +static void test_state_freeable_is_false_while_a_hostname_lookup_is_in_flight(void) { + faults().dns_result = ERR_INPROGRESS; + + AsyncClient c; + TEST_ASSERT_TRUE(c.connect("example.test", kPort)); + TEST_ASSERT_TRUE(dns_pending()); + + TEST_ASSERT_FALSE(c.freeable()); + TEST_ASSERT_FALSE(c.free()); + + c.close(); + fire_dns_failure(); // LwIP always answers, eventually + asynctcp_test_pump(); +} + +static void test_state_accepted_client_is_established_when_delivered(void) { + AsyncServer s(kServerPort); + Accepted accepted(s); + s.begin(); + + tcp_pcb *lp = listen_pcb(); + TEST_ASSERT_NOT_NULL(lp); + TEST_ASSERT_NOT_NULL(fire_accept(lp)); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(1, accepted.size()); + AsyncClient *client = accepted[0]; + + TEST_ASSERT_EQUAL_UINT8(ESTABLISHED, client->state()); + TEST_ASSERT_EQUAL_STRING("Established", client->stateToString()); + TEST_ASSERT_FALSE(client->connecting()); + TEST_ASSERT_TRUE(client->connected()); + TEST_ASSERT_FALSE(client->disconnecting()); + TEST_ASSERT_FALSE(client->disconnected()); + TEST_ASSERT_FALSE(client->freeable()); + assert_one_phase(*client, "accepted"); + + accepted.clear(); + s.end(); +} + +static void test_state_accepted_client_walks_to_closed_on_close(void) { + AsyncServer s(kServerPort); + int disconnects = 0; + Accepted accepted(s, [&](AsyncClient *c) { + c->onDisconnect([&](void *, AsyncClient *) { + disconnects++; + }); + }); + s.begin(); + TEST_ASSERT_NOT_NULL(fire_accept(listen_pcb())); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(1, accepted.size()); + AsyncClient *client = accepted[0]; + + assert_freeable_agrees(*client, "accepted"); + client->close(); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, disconnects); + TEST_ASSERT_NULL(client->pcb()); + TEST_ASSERT_EQUAL_UINT8(CLOSED, client->state()); + TEST_ASSERT_TRUE(client->disconnected()); + assert_one_phase(*client, "accepted, closed"); + assert_freeable_agrees(*client, "accepted, closed"); + + accepted.clear(); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_state_accepted_client_reset_leaves_it_closed(void) { + AsyncServer s(kServerPort); + Accepted accepted(s); + s.begin(); + tcp_pcb *conn = fire_accept(listen_pcb()); + TEST_ASSERT_NOT_NULL(conn); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(1, accepted.size()); + AsyncClient *client = accepted[0]; + + fire_error(conn, ERR_RST); + TEST_ASSERT_NULL(client->pcb()); + TEST_ASSERT_TRUE(client->disconnected()); + asynctcp_test_pump(); + assert_one_phase(*client, "accepted, reset"); + assert_freeable_agrees(*client, "accepted, reset"); + + accepted.clear(); + s.end(); +} + +// --------------------------------------------------------------------------- +// The accessors +// --------------------------------------------------------------------------- + +static void test_state_accessors_report_zero_before_connecting(void) { + AsyncClient c; + TEST_ASSERT_EQUAL_UINT32(0, c.getRemoteAddress()); + TEST_ASSERT_EQUAL_UINT16(0, c.getRemotePort()); + TEST_ASSERT_EQUAL_UINT16(0, c.remotePort()); + TEST_ASSERT_EQUAL_UINT32(0, c.getLocalAddress()); + TEST_ASSERT_EQUAL_UINT16(0, c.getLocalPort()); + TEST_ASSERT_EQUAL_UINT16(0, c.localPort()); + TEST_ASSERT_TRUE(c.remoteIP() == IPAddress()); + TEST_ASSERT_TRUE(c.localIP() == IPAddress()); + TEST_ASSERT_EQUAL_UINT32(0, c.getRemoteAddress4().addr); + TEST_ASSERT_EQUAL_UINT32(0, c.getLocalAddress4().addr); +} + +static void test_state_remote_accessors_match_the_dialled_peer(void) { + AsyncClient c; + establish(c); + + TEST_ASSERT_EQUAL_UINT32((uint32_t)kPeer, c.getRemoteAddress()); + TEST_ASSERT_EQUAL_UINT16(kPort, c.getRemotePort()); + TEST_ASSERT_EQUAL_UINT16(kPort, c.remotePort()); + TEST_ASSERT_TRUE(c.remoteIP() == kPeer); + TEST_ASSERT_EQUAL_UINT32((uint32_t)kPeer, c.getRemoteAddress4().addr); + + c.close(); +} + +static void test_state_connect_assigns_a_local_port(void) { + AsyncClient c; + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + tcp_pcb *pcb = dialled_pcb(); + TEST_ASSERT_NOT_NULL(pcb); + fire_connected(pcb); + asynctcp_test_pump(); + + // lwIP picks an ephemeral port for an unbound pcb as part of connecting. + TEST_ASSERT_NOT_EQUAL(0, c.getLocalPort()); + TEST_ASSERT_EQUAL_UINT16(pcb->local_port, c.getLocalPort()); + TEST_ASSERT_EQUAL_UINT16(c.getLocalPort(), c.localPort()); + + c.close(); +} + +static void test_state_ip_wrappers_agree_with_the_raw_accessors(void) { + AsyncClient c; + establish(c); + + TEST_ASSERT_EQUAL_UINT32(c.getRemoteAddress(), (uint32_t)c.remoteIP()); + TEST_ASSERT_EQUAL_UINT32(c.getLocalAddress(), (uint32_t)c.localIP()); + TEST_ASSERT_EQUAL_UINT32(c.getRemoteAddress(), c.getRemoteAddress4().addr); + + c.close(); +} + +static void test_state_accessors_report_zero_after_close(void) { + AsyncClient c; + establish(c); + c.close(); + + // The pcb is gone; none of these may read it. + TEST_ASSERT_EQUAL_UINT32(0, c.getRemoteAddress()); + TEST_ASSERT_EQUAL_UINT16(0, c.getRemotePort()); + TEST_ASSERT_EQUAL_UINT32(0, c.getLocalAddress()); + TEST_ASSERT_EQUAL_UINT16(0, c.getLocalPort()); + TEST_ASSERT_TRUE(c.remoteIP() == IPAddress()); + TEST_ASSERT_TRUE(c.localIP() == IPAddress()); + TEST_ASSERT_EQUAL_UINT32(0, c.getRemoteAddress4().addr); + TEST_ASSERT_EQUAL_UINT32(0, c.getLocalAddress4().addr); + TEST_ASSERT_EQUAL_UINT16(0, c.getMss()); +} + +static void test_state_accessors_report_zero_after_a_reset(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + + // The pcb was freed by lwIP before the callback ran, so anything still reading it is a + // use-after-free -- which the sanitized build catches outright. + fire_error(pcb, ERR_RST); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_UINT32(0, c.getRemoteAddress()); + TEST_ASSERT_EQUAL_UINT16(0, c.getRemotePort()); + TEST_ASSERT_EQUAL_UINT32(0, c.getLocalAddress()); + TEST_ASSERT_EQUAL_UINT16(0, c.getLocalPort()); + TEST_ASSERT_EQUAL_UINT16(0, c.getMss()); + TEST_ASSERT_FALSE(c.getNoDelay()); +} + +static void test_state_accepted_client_reports_the_peer_that_connected(void) { + const IPAddress kBind(10, 0, 0, 7); + AsyncServer s(kBind, kServerPort); + Accepted accepted(s); + s.begin(); + + tcp_pcb *conn = fire_accept(listen_pcb()); + TEST_ASSERT_NOT_NULL(conn); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(1, accepted.size()); + AsyncClient *client = accepted[0]; + + // The accepted side reports the peer as remote and the listening socket as local. + TEST_ASSERT_EQUAL_UINT32(remote_ip4(conn), client->getRemoteAddress()); + TEST_ASSERT_EQUAL_UINT16(conn->remote_port, client->getRemotePort()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)kBind, client->getLocalAddress()); + TEST_ASSERT_EQUAL_UINT16(kServerPort, client->getLocalPort()); + TEST_ASSERT_TRUE(client->localIP() == kBind); + TEST_ASSERT_TRUE(client->remoteIP() == IPAddress(remote_ip4(conn))); + + client->close(); + TEST_ASSERT_EQUAL_UINT32(0, client->getRemoteAddress()); + TEST_ASSERT_EQUAL_UINT16(0, client->getLocalPort()); + + accepted.clear(); + s.end(); +} + +static void test_state_getMss_reports_the_connection_mss(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + + TEST_ASSERT_EQUAL_UINT16(TCP_MSS, c.getMss()); + pcb->mss = 536; // the peer advertised something smaller + TEST_ASSERT_EQUAL_UINT16(536, c.getMss()); + + c.close(); +} + +static void test_state_getMss_is_zero_without_a_connection(void) { + AsyncClient c; + TEST_ASSERT_EQUAL_UINT16(0, c.getMss()); +} + +static void test_state_setNoDelay_round_trips_while_connected(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + + TEST_ASSERT_FALSE(c.getNoDelay()); + c.setNoDelay(true); + TEST_ASSERT_TRUE(c.getNoDelay()); + TEST_ASSERT_TRUE(tcp_nagle_disabled(pcb)); + c.setNoDelay(false); + TEST_ASSERT_FALSE(c.getNoDelay()); + TEST_ASSERT_FALSE(tcp_nagle_disabled(pcb)); + + c.close(); +} + +static void test_state_getNoDelay_is_false_without_a_connection(void) { + AsyncClient c; + c.setNoDelay(true); // no pcb to set it on + TEST_ASSERT_FALSE(c.getNoDelay()); +} + +// --------------------------------------------------------------------------- +// IPv6. IPv6Address.h is on the mock include path, so the pre-IDF5 IPv6Address +// flavor of these accessors is what the native build compiles when LWIP_IPV6 is 1. +// --------------------------------------------------------------------------- +#if LWIP_IPV6 + +static void test_state_ipv6_accessors_are_zero_without_a_connection(void) { + AsyncClient c; + ip6_addr_t remote = c.getRemoteAddress6(); + ip6_addr_t local = c.getLocalAddress6(); + for (int i = 0; i < 4; ++i) { + TEST_ASSERT_EQUAL_UINT32(0, remote.addr[i]); + TEST_ASSERT_EQUAL_UINT32(0, local.addr[i]); + } + TEST_ASSERT_TRUE(c.remoteIP6() == IPv6Address()); + TEST_ASSERT_TRUE(c.localIP6() == IPv6Address()); +} + +static void test_state_ipv6_connect_reports_the_v6_peer(void) { + const uint32_t kWords[4] = {0x20010db8, 0x00000000, 0x00000000, 0x00000001}; + const IPv6Address kPeer6(kWords); + + AsyncClient c; + TEST_ASSERT_TRUE(c.connect(kPeer6, kPort)); + tcp_pcb *pcb = dialled_pcb(); + TEST_ASSERT_NOT_NULL(pcb); + TEST_ASSERT_EQUAL_UINT8(IPADDR_TYPE_V6, pcb->remote_ip.type); + fire_connected(pcb); + asynctcp_test_pump(); + + ip6_addr_t remote = c.getRemoteAddress6(); + for (int i = 0; i < 4; ++i) { + TEST_ASSERT_EQUAL_UINT32(kWords[i], remote.addr[i]); + } + TEST_ASSERT_TRUE(c.remoteIP6() == kPeer6); + TEST_ASSERT_EQUAL_UINT16(kPort, c.getRemotePort()); + + c.close(); +} + +static void test_state_ipv6_connect_by_ip_addr_t_dials_that_address(void) { + const uint32_t kWords[4] = {0x20010db8, 0x00000000, 0x00000000, 0x00000002}; + + AsyncClient c; + ip_addr_t addr = IPADDR6_INIT(kWords[0], kWords[1], kWords[2], kWords[3]); + TEST_ASSERT_TRUE(c.connect(addr, kPort)); + tcp_pcb *pcb = dialled_pcb(); + TEST_ASSERT_NOT_NULL(pcb); + TEST_ASSERT_EQUAL_UINT8(IPADDR_TYPE_V6, pcb->remote_ip.type); + fire_connected(pcb); + asynctcp_test_pump(); + + TEST_ASSERT_TRUE(c.connected()); + TEST_ASSERT_TRUE(c.remoteIP6() == IPv6Address(kWords)); + c.close(); +} + +static void test_state_ipv6_accessors_are_zero_for_an_ipv4_peer(void) { + AsyncClient c; + establish(c); + + // The v6 accessors check the address family before reading the union. + ip6_addr_t remote = c.getRemoteAddress6(); + for (int i = 0; i < 4; ++i) { + TEST_ASSERT_EQUAL_UINT32(0, remote.addr[i]); + } + TEST_ASSERT_TRUE(c.remoteIP6() == IPv6Address()); + + c.close(); +} + +// getRemoteAddress4()/getLocalAddress4() return the null address when the pcb is not an +// IPv4 one; getRemoteAddress()/getLocalAddress() read the union unconditionally, so on a +// v6 connection they hand back the first 32 bits of the IPv6 address as if it were an +// IPv4 one -- and remoteIP()/localIP() pass that straight to the application. +static void test_state_ipv4_accessors_are_zero_for_an_ipv6_peer(void) { + const uint32_t kWords[4] = {0x20010db8, 0x00000000, 0x00000000, 0x00000001}; + const IPv6Address kPeer6(kWords); + + AsyncClient c; + TEST_ASSERT_TRUE(c.connect(kPeer6, kPort)); + tcp_pcb *pcb = dialled_pcb(); + TEST_ASSERT_NOT_NULL(pcb); + fire_connected(pcb); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_UINT32(0, c.getRemoteAddress4().addr); // guarded on the address family + TEST_ASSERT_EQUAL_UINT32(0, c.getRemoteAddress()); // not guarded + TEST_ASSERT_TRUE(c.remoteIP() == IPAddress()); + + c.close(); +} +#endif // LWIP_IPV6 + +void run_state_tests(void) { + UnitySetTestFile(__FILE__); + + RUN_TEST(test_state_fresh_client_is_closed_and_disconnected); + RUN_TEST(test_state_connect_dials_the_peer_on_one_pcb); + RUN_TEST(test_state_connect_reports_syn_sent_until_the_handshake_lands); + RUN_TEST(test_state_established_once_the_connected_event_is_delivered); + RUN_TEST(test_state_close_returns_the_client_to_closed); + RUN_TEST(test_state_predicates_partition_the_whole_connection_life); + RUN_TEST(test_state_connected_and_disconnected_are_never_both_true); + RUN_TEST(test_state_freeable_means_disconnected_or_disconnecting); + RUN_TEST(test_state_disconnecting_while_a_fin_is_outstanding); + RUN_TEST(test_state_time_wait_is_disconnected_not_disconnecting); + RUN_TEST(test_state_stateToString_names_every_lwip_state); + RUN_TEST(test_state_connect_is_refused_while_already_connecting); + RUN_TEST(test_state_connect_is_refused_while_connected); + RUN_TEST(test_state_close_twice_reports_one_disconnect); + RUN_TEST(test_state_abort_twice_reports_one_error); + RUN_TEST(test_state_stop_closes_like_close); + RUN_TEST(test_state_close_bool_closes_like_close); + RUN_TEST(test_state_connect_by_ip_addr_t_dials_that_address); + RUN_TEST(test_state_a_client_equals_itself); + RUN_TEST(test_state_clients_on_different_connections_are_unequal); + RUN_TEST(test_state_peer_fin_leaves_the_client_closed_and_freeable); + RUN_TEST(test_state_reset_leaves_the_client_closed_and_freeable); + RUN_TEST(test_state_freeable_is_false_while_a_hostname_lookup_is_in_flight); + RUN_TEST(test_state_accepted_client_is_established_when_delivered); + RUN_TEST(test_state_accepted_client_walks_to_closed_on_close); + RUN_TEST(test_state_accepted_client_reset_leaves_it_closed); + + RUN_TEST(test_state_accessors_report_zero_before_connecting); + RUN_TEST(test_state_remote_accessors_match_the_dialled_peer); + RUN_TEST(test_state_connect_assigns_a_local_port); + RUN_TEST(test_state_ip_wrappers_agree_with_the_raw_accessors); + RUN_TEST(test_state_accessors_report_zero_after_close); + RUN_TEST(test_state_accessors_report_zero_after_a_reset); + RUN_TEST(test_state_accepted_client_reports_the_peer_that_connected); + RUN_TEST(test_state_getMss_reports_the_connection_mss); + RUN_TEST(test_state_getMss_is_zero_without_a_connection); + RUN_TEST(test_state_setNoDelay_round_trips_while_connected); + RUN_TEST(test_state_getNoDelay_is_false_without_a_connection); + +#if LWIP_IPV6 + RUN_TEST(test_state_ipv6_accessors_are_zero_without_a_connection); + RUN_TEST(test_state_ipv6_connect_reports_the_v6_peer); + RUN_TEST(test_state_ipv6_connect_by_ip_addr_t_dials_that_address); + RUN_TEST(test_state_ipv6_accessors_are_zero_for_an_ipv4_peer); + RUN_TEST(test_state_ipv4_accessors_are_zero_for_an_ipv6_peer); +#endif +} From 567abbd87c8bc795d6bfb5f967286b5e063608f2 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Wed, 30 Sep 2026 23:36:23 -0400 Subject: [PATCH 13/24] test: Cover how failures are reported Co-Authored-By: Claude Opus 5.5 --- test/test_native/README.md | 3 + test/test_native/mocks/mock_lwip.cpp | 4 + test/test_native/mocks/mock_lwip.h | 11 +- test/test_native/test_main.cpp | 2 + test/test_native/tests/test_error.cpp | 485 ++++++++++++++++++++++++++ 5 files changed, 500 insertions(+), 5 deletions(-) create mode 100644 test/test_native/tests/test_error.cpp diff --git a/test/test_native/README.md b/test/test_native/README.md index 8f06e002..ff8d0dd9 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -120,6 +120,7 @@ test/test_native/ test_write.cpp the outbound path test_recv.cpp the inbound path test_timeout.cpp the rx and ack timeouts, onPoll, keepalive + test_error.cpp how failures are reported test_dns.cpp name resolution, and lookups that are abandoned test_server_listen.cpp begin(), end(), status(), restarts test_server_accept.cpp delivering accepted connections @@ -152,6 +153,7 @@ on it: pcb alone, so the caller still owns it. (That asymmetry is what `test_server_listen_begin_frees_the_bound_pcb_when_listen_fails` is about.) +* `tcp_connect()` failure does **not** free the pcb. * `tcp_write()` checks what `tcp_write_checks()` does: `ERR_CONN` outside `ESTABLISHED`, `CLOSE_WAIT`, `SYN_SENT` and `SYN_RCVD`; `ERR_MEM` for more than `tcp_sndbuf()`, or for more segments than `TCP_SND_QUEUELEN`. Segments are cut as @@ -276,6 +278,7 @@ and then calls it with `pcb->callback_arg`. ```cpp faults().fail_tcp_new = 1; // next tcp_new_ip_type() returns NULL +faults().connect_result = ERR_RTE; // tcp_connect() fails faults().bind_result = ERR_USE; faults().listen_returns_null = true; faults().close_result = ERR_MEM; // tcp_close() fails, pcb stays allocated diff --git a/test/test_native/mocks/mock_lwip.cpp b/test/test_native/mocks/mock_lwip.cpp index dcbde14c..9d7cdbea 100644 --- a/test/test_native/mocks/mock_lwip.cpp +++ b/test/test_native/mocks/mock_lwip.cpp @@ -328,6 +328,10 @@ extern "C" err_t tcp_connect(struct tcp_pcb *pcb, const ip_addr_t *ipaddr, u16_t if (!pcb) { return ERR_ARG; } + if (g_faults.connect_result != ERR_OK) { + // lwIP does NOT free the pcb on a failed connect; the caller still owns it. + return g_faults.connect_result; + } if (ipaddr) { pcb->remote_ip = *ipaddr; } diff --git a/test/test_native/mocks/mock_lwip.h b/test/test_native/mocks/mock_lwip.h index 5c783e13..dc0e3252 100644 --- a/test/test_native/mocks/mock_lwip.h +++ b/test/test_native/mocks/mock_lwip.h @@ -71,11 +71,12 @@ int core_lock_depth(); // Failure injection. All are reset by reset(). // --------------------------------------------------------------------------- struct Faults { - int fail_tcp_new = 0; // >0: that many tcp_new_ip_type() calls return NULL - err_t bind_result = 0; // ERR_OK - err_t write_result = 0; // ERR_OK; set ERR_MEM to fail tcp_write - err_t output_result = 0; // ERR_OK - err_t close_result = 0; // ERR_OK; non-OK leaves the pcb allocated + int fail_tcp_new = 0; // >0: that many tcp_new_ip_type() calls return NULL + err_t connect_result = 0; // ERR_OK; set e.g. ERR_RTE to fail tcp_connect + err_t bind_result = 0; // ERR_OK + err_t write_result = 0; // ERR_OK; set ERR_MEM to fail tcp_write + err_t output_result = 0; // ERR_OK + err_t close_result = 0; // ERR_OK; non-OK leaves the pcb allocated bool listen_returns_null = false; err_t dns_result = 0; // ERR_OK (immediate), ERR_INPROGRESS, or an error uint32_t dns_addr = 0; // address returned for an immediate ERR_OK lookup diff --git a/test/test_native/test_main.cpp b/test/test_native/test_main.cpp index 8e97c7d0..a002ce6a 100644 --- a/test/test_native/test_main.cpp +++ b/test/test_native/test_main.cpp @@ -50,6 +50,7 @@ void run_server_listen_tests(void); void run_server_accept_tests(void); void run_server_config_tests(void); void run_state_tests(void); +void run_error_tests(void); namespace { @@ -341,6 +342,7 @@ int main(int argc, char **argv) { run_server_accept_tests(); run_server_config_tests(); run_state_tests(); + run_error_tests(); if (g_list_only) { return 0; diff --git a/test/test_native/tests/test_error.cpp b/test/test_native/tests/test_error.cpp new file mode 100644 index 00000000..5fa55912 --- /dev/null +++ b/test/test_native/tests/test_error.cpp @@ -0,0 +1,485 @@ +// How a failure is reported to the application. +// +// Two things are checked here. errorToString() has to name every code the library can +// hand to onError, because the string is all the application gets. And every way a +// connection can end has to report it exactly once: onError when there was an error, +// onDisconnect when the connection had been established, onError first when both. +// +// The tests below record the callbacks with a Recorder, so order, count and exactly-once +// are one assertion rather than three. +// +// stateToString() is not here: test_state.cpp walks the whole table already. + +#include "fixtures.h" + +#include +#include + +using namespace mocklwip; + +// --------------------------------------------------------------------------- +// errorToString +// --------------------------------------------------------------------------- + +static void test_error_errorToString_names_every_lwip_code(void) { + // Every code LwIP defines, plus -55, which the library raises for a failed lookup. + TEST_ASSERT_EQUAL_STRING("OK", AsyncClient::errorToString(ERR_OK)); + TEST_ASSERT_EQUAL_STRING("Out of memory error", AsyncClient::errorToString(ERR_MEM)); + TEST_ASSERT_EQUAL_STRING("Buffer error", AsyncClient::errorToString(ERR_BUF)); + TEST_ASSERT_EQUAL_STRING("Timeout", AsyncClient::errorToString(ERR_TIMEOUT)); + TEST_ASSERT_EQUAL_STRING("Routing problem", AsyncClient::errorToString(ERR_RTE)); + TEST_ASSERT_EQUAL_STRING("Operation in progress", AsyncClient::errorToString(ERR_INPROGRESS)); + TEST_ASSERT_EQUAL_STRING("Illegal value", AsyncClient::errorToString(ERR_VAL)); + TEST_ASSERT_EQUAL_STRING("Operation would block", AsyncClient::errorToString(ERR_WOULDBLOCK)); + TEST_ASSERT_EQUAL_STRING("Address in use", AsyncClient::errorToString(ERR_USE)); + TEST_ASSERT_EQUAL_STRING("Already connecting", AsyncClient::errorToString(ERR_ALREADY)); + TEST_ASSERT_EQUAL_STRING("Already connected", AsyncClient::errorToString(ERR_ISCONN)); + TEST_ASSERT_EQUAL_STRING("Not connected", AsyncClient::errorToString(ERR_CONN)); + TEST_ASSERT_EQUAL_STRING("Low-level netif error", AsyncClient::errorToString(ERR_IF)); + TEST_ASSERT_EQUAL_STRING("Connection aborted", AsyncClient::errorToString(ERR_ABRT)); + TEST_ASSERT_EQUAL_STRING("Connection reset", AsyncClient::errorToString(ERR_RST)); + TEST_ASSERT_EQUAL_STRING("Connection closed", AsyncClient::errorToString(ERR_CLSD)); + TEST_ASSERT_EQUAL_STRING("Illegal argument", AsyncClient::errorToString(ERR_ARG)); + TEST_ASSERT_EQUAL_STRING("DNS failed", AsyncClient::errorToString(-55)); +} + +static void test_error_errorToString_says_unknown_for_codes_it_has_no_name_for(void) { + TEST_ASSERT_EQUAL_STRING("UNKNOWN", AsyncClient::errorToString(1)); + TEST_ASSERT_EQUAL_STRING("UNKNOWN", AsyncClient::errorToString(127)); + TEST_ASSERT_EQUAL_STRING("UNKNOWN", AsyncClient::errorToString(ERR_ARG - 1)); + TEST_ASSERT_EQUAL_STRING("UNKNOWN", AsyncClient::errorToString(-54)); + TEST_ASSERT_EQUAL_STRING("UNKNOWN", AsyncClient::errorToString(-56)); + TEST_ASSERT_EQUAL_STRING("UNKNOWN", AsyncClient::errorToString(-128)); +} + +// --------------------------------------------------------------------------- +// Which callbacks a failure runs, in what order, how many times +// --------------------------------------------------------------------------- + +static void test_error_reset_is_reported_once_however_often_the_queue_is_pumped(void) { + AsyncClient c; + Recorder t; + t.attach(c); + tcp_pcb *pcb = establish(c); + t.seq.clear(); // drop the 'C' + + fire_error(pcb, ERR_RST); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_STRING("", t.seq.c_str()); // not delivered yet + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("ED", t.seq.c_str()); + TEST_ASSERT_EQUAL_INT8(ERR_RST, t.err); + + // Nothing is left owed: neither a further pump nor a close can produce a second report. + asynctcp_test_pump(); + c.close(); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("ED", t.seq.c_str()); +} + +static void test_error_refused_connect_reports_the_error_and_never_onConnect(void) { + // A refused SYN arrives as tcp_err on a pcb still in SYN_SENT, so there is a connection + // attempt to report against but no connection. + AsyncClient c; + Recorder t; + t.attach(c); + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + tcp_pcb *pcb = dialled_pcb(); + TEST_ASSERT_NOT_NULL(pcb); + TEST_ASSERT_EQUAL_UINT8(SYN_SENT, pcb->state); + + fire_error(pcb, ERR_ABRT); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("ED", t.seq.c_str()); + TEST_ASSERT_EQUAL_INT8(ERR_ABRT, t.err); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_error_reset_overtaking_an_undelivered_connect_reports_only_the_error(void) { + // The handshake landed but the async task had not run yet. Reporting both would tell + // the application it was connected to something already gone. + AsyncClient c; + Recorder t; + t.attach(c); + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + tcp_pcb *pcb = dialled_pcb(); + TEST_ASSERT_NOT_NULL(pcb); + + fire_connected(pcb); + fire_error(pcb, ERR_RST); // both events queued, neither delivered + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("ED", t.seq.c_str()); +} + +static void test_error_pcb_allocation_failure_reports_nothing(void) { + // connect() returns false before anything is owed, so reporting a failure the caller + // already knows about would be a callback against a connection that never existed. + faults().fail_tcp_new = 1; + + AsyncClient c; + Recorder t; + t.attach(c); + TEST_ASSERT_FALSE(c.connect(kPeer, kPort)); + TEST_ASSERT_NULL(c.pcb()); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_connect")); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("", t.seq.c_str()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_error_dns_failure_is_reported_once_and_leaves_nothing_owed(void) { + faults().dns_result = ERR_INPROGRESS; + + AsyncClient c; + Recorder t; + t.attach(c); + TEST_ASSERT_TRUE(c.connect("nx.test", kPort)); + + fire_dns_failure(); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("ED", t.seq.c_str()); + TEST_ASSERT_EQUAL_INT8(-55, t.err); + + // The client never held a pcb, so the close that follows has nothing to report either. + c.close(); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("ED", t.seq.c_str()); +} + +static void test_error_dial_failing_after_a_lookup_reports_error_before_disconnect(void) { + // The dial happens inside the resolver callback, where the failure is nobody's return + // value; it has to come back as the same ordered pair as any other terminal error. + faults().dns_result = ERR_INPROGRESS; + + AsyncClient c; + Recorder t; + t.attach(c); + TEST_ASSERT_TRUE(c.connect("slow.test", kPort)); + + faults().connect_result = ERR_RTE; + fire_dns(kResolved); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("ED", t.seq.c_str()); + TEST_ASSERT_EQUAL_INT8(ERR_CONN, t.err); // resolved, so not the -55 "DNS failed" code + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_error_abort_reports_abrt_then_disconnect(void) { + AsyncClient c; + Recorder t; + t.attach(c); + establish(c); + t.seq.clear(); + + TEST_ASSERT_EQUAL_INT8(ERR_ABRT, c.abort()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_NULL(c.pcb()); + asynctcp_test_pump(); + + // abort() raises the error itself -- LwIP does not call tcp_err for a pcb we aborted -- + // so that a connection the application tore down still gets its one report. + TEST_ASSERT_EQUAL_STRING("ED", t.seq.c_str()); + TEST_ASSERT_EQUAL_INT8(ERR_ABRT, t.err); +} + +static void test_error_peer_fin_disconnects_without_an_error(void) { + AsyncClient c; + Recorder t; + t.attach(c); + tcp_pcb *pcb = establish(c); + t.seq.clear(); + + fire_fin(pcb); + asynctcp_test_pump(); + + // An orderly close is not a failure, so onError must stay out of it. + TEST_ASSERT_EQUAL_STRING("D", t.seq.c_str()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_error_close_disconnects_without_an_error(void) { + AsyncClient c; + Recorder t; + t.attach(c); + establish(c); + t.seq.clear(); + + c.close(); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("D", t.seq.c_str()); +} + +static void test_error_rx_timeout_disconnects_without_an_error(void) { + AsyncClient c; + Recorder t; + t.attach(c); + tcp_pcb *pcb = establish(c); + t.seq.clear(); + + c.setRxTimeout(2); + mockclock::advance(2500); + fire_poll(pcb); + asynctcp_test_pump(); + + // The library closes the connection itself, which is a disconnect and not an error. + TEST_ASSERT_EQUAL_STRING("D", t.seq.c_str()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_error_ack_timeout_reports_onTimeout_and_nothing_else(void) { + // An unacked send is reported to onTimeout, and that is all. Tearing the connection + // down is the application's call, so neither onError nor onDisconnect may fire on its + // own. + AsyncClient c; + Recorder t; + t.attach(c, "CEDT"); + tcp_pcb *pcb = establish(c); + t.seq.clear(); + + c.setAckTimeout(100); + c.write("x", 1); + mockclock::advance(250); + fire_poll(pcb); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("T", t.seq.c_str()); + TEST_ASSERT_TRUE(c.connected()); + + c.close(); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("TD", t.seq.c_str()); +} + +static void test_error_accepted_client_reset_reports_error_then_disconnect(void) { + AsyncServer s(kServerPort); + Recorder t; + Accepted accepted(s, [&](AsyncClient *c) { + t.attach(*c); + }); + s.begin(); + + tcp_pcb *conn = fire_accept(listen_pcb()); + TEST_ASSERT_NOT_NULL(conn); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(1, accepted.size()); + + fire_error(conn, ERR_RST); + asynctcp_test_pump(); + + // An accepted client was never connect()ed, but it holds a pcb, so it is owed the same + // pair as any other established connection. + TEST_ASSERT_EQUAL_STRING("ED", t.seq.c_str()); + TEST_ASSERT_EQUAL_INT8(ERR_RST, t.err); + + accepted.clear(); + s.end(); +} + +// --------------------------------------------------------------------------- +// What the error callback is told +// --------------------------------------------------------------------------- + +static void test_error_callback_is_handed_a_usable_client(void) { + // LwIP freed the pcb before tcp_err ran, so every accessor here is reading state the + // library must already have detached; under the sanitized build a miss is a crash. + AsyncClient c; + bool checked = false; + c.onError([&](void *, AsyncClient *cl, int8_t e) { + checked = true; + TEST_ASSERT_EQUAL_PTR(&c, cl); + TEST_ASSERT_NULL(cl->pcb()); + TEST_ASSERT_EQUAL_UINT8(CLOSED, cl->state()); + TEST_ASSERT_EQUAL_STRING("Closed", cl->stateToString()); + TEST_ASSERT_FALSE(cl->connected()); + TEST_ASSERT_TRUE(cl->disconnected()); + TEST_ASSERT_TRUE(cl->freeable()); + TEST_ASSERT_EQUAL_STRING("Connection reset", AsyncClient::errorToString(e)); + TEST_ASSERT_EQUAL_UINT32(0, cl->getRemoteAddress()); + TEST_ASSERT_EQUAL_UINT16(0, cl->getRemotePort()); + TEST_ASSERT_EQUAL_size_t(0, cl->space()); + TEST_ASSERT_FALSE(cl->canSend()); + TEST_ASSERT_EQUAL_size_t(0, cl->write("late", 4)); + }); + + tcp_pcb *pcb = establish(c); + fire_error(pcb, ERR_RST); + asynctcp_test_pump(); + TEST_ASSERT_TRUE(checked); +} + +static void test_error_closing_from_inside_on_error_still_disconnects_once(void) { + // The obvious thing for an application to do, and the one that could double-report: + // close() reports a dispose of its own when one is owed. + AsyncClient c; + Recorder t; + t.attach(c); + c.onError([&](void *, AsyncClient *cl, int8_t) { + t.seq += 'E'; + cl->close(); + }); + + tcp_pcb *pcb = establish(c); + t.seq.clear(); + + fire_error(pcb, ERR_RST); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("ED", t.seq.c_str()); +} + +// --------------------------------------------------------------------------- +// Missing, shared and late-registered handlers +// --------------------------------------------------------------------------- + +static void test_error_reset_with_no_handlers_does_not_crash_or_leak(void) { + AsyncClient c; + tcp_pcb *pcb = establish(c); + + fire_error(pcb, ERR_RST); + asynctcp_test_pump(); + + TEST_ASSERT_TRUE(c.disconnected()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_error_abort_with_no_handlers_does_not_crash_or_leak(void) { + AsyncClient c; + establish(c); + + TEST_ASSERT_EQUAL_INT8(ERR_ABRT, c.abort()); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_error_dns_failure_with_no_handlers_does_not_crash_or_leak(void) { + // The failure is owed to a client with no error handler at all; it still has to be + // settled, so the client can connect again. + faults().dns_result = ERR_INPROGRESS; + + AsyncClient c; + TEST_ASSERT_TRUE(c.connect("nx.test", kPort)); + fire_dns_failure(); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); // not wedged: the failure was reported + c.close(); +} + +static void test_error_only_onDisconnect_registered_still_hears_about_a_reset(void) { + AsyncClient c; + Recorder t; + t.attach(c, "D"); + + tcp_pcb *pcb = establish(c); + fire_error(pcb, ERR_RST); + asynctcp_test_pump(); + + // The error has nowhere to go, but the connection still ended and that is still owed. + TEST_ASSERT_EQUAL_STRING("D", t.seq.c_str()); +} + +static void test_error_only_onError_registered_hears_just_the_error(void) { + AsyncClient c; + Recorder t; + t.attach(c, "E"); + + tcp_pcb *pcb = establish(c); + fire_error(pcb, ERR_RST); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("E", t.seq.c_str()); +} + +static void test_error_one_handler_registered_for_both_is_called_twice(void) { + // Sharing a handler is common in application code. It must be entered once per + // callback, error first -- not coalesced, and not entered re-entrantly. + AsyncClient c; + std::string seq; + int depth = 0, max_depth = 0; + auto handler = [&](void *, AsyncClient *, auto &&...rest) { + max_depth = std::max(max_depth, ++depth); + seq += (sizeof...(rest) == 1) ? 'E' : 'D'; // only onError carries the code + --depth; + }; + c.onError(handler); + c.onDisconnect(handler); + + tcp_pcb *pcb = establish(c); + fire_error(pcb, ERR_RST); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("ED", seq.c_str()); + TEST_ASSERT_EQUAL_INT(1, max_depth); +} + +static void test_error_handlers_registered_after_the_failure_is_queued_still_fire(void) { + // The error happens on the LwIP thread but is delivered by the async task, so + // handlers set in between still count. + AsyncClient c; + Recorder t; + tcp_pcb *pcb = establish(c); + + fire_error(pcb, ERR_RST); + t.attach(c); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("ED", t.seq.c_str()); + TEST_ASSERT_EQUAL_INT8(ERR_RST, t.err); +} + +static void test_error_replacing_a_handler_after_the_failure_is_queued_uses_the_new_one(void) { + AsyncClient c; + std::string seq; + c.onError([&](void *, AsyncClient *, int8_t) { + seq += 'x'; + }); + + tcp_pcb *pcb = establish(c); + fire_error(pcb, ERR_RST); + + c.onError([&](void *, AsyncClient *, int8_t) { + seq += 'E'; + }); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("E", seq.c_str()); +} + +void run_error_tests(void) { + UnitySetTestFile(__FILE__); + + RUN_TEST(test_error_errorToString_names_every_lwip_code); + RUN_TEST(test_error_errorToString_says_unknown_for_codes_it_has_no_name_for); + + RUN_TEST(test_error_reset_is_reported_once_however_often_the_queue_is_pumped); + RUN_TEST(test_error_refused_connect_reports_the_error_and_never_onConnect); + RUN_TEST(test_error_reset_overtaking_an_undelivered_connect_reports_only_the_error); + RUN_TEST(test_error_pcb_allocation_failure_reports_nothing); + RUN_TEST(test_error_dns_failure_is_reported_once_and_leaves_nothing_owed); + RUN_TEST(test_error_dial_failing_after_a_lookup_reports_error_before_disconnect); + RUN_TEST(test_error_abort_reports_abrt_then_disconnect); + RUN_TEST(test_error_peer_fin_disconnects_without_an_error); + RUN_TEST(test_error_close_disconnects_without_an_error); + RUN_TEST(test_error_rx_timeout_disconnects_without_an_error); + RUN_TEST(test_error_ack_timeout_reports_onTimeout_and_nothing_else); + RUN_TEST(test_error_accepted_client_reset_reports_error_then_disconnect); + + RUN_TEST(test_error_callback_is_handed_a_usable_client); + RUN_TEST(test_error_closing_from_inside_on_error_still_disconnects_once); + + RUN_TEST(test_error_reset_with_no_handlers_does_not_crash_or_leak); + RUN_TEST(test_error_abort_with_no_handlers_does_not_crash_or_leak); + RUN_TEST(test_error_dns_failure_with_no_handlers_does_not_crash_or_leak); + RUN_TEST(test_error_only_onDisconnect_registered_still_hears_about_a_reset); + RUN_TEST(test_error_only_onError_registered_hears_just_the_error); + RUN_TEST(test_error_one_handler_registered_for_both_is_called_twice); + RUN_TEST(test_error_handlers_registered_after_the_failure_is_queued_still_fire); + RUN_TEST(test_error_replacing_a_handler_after_the_failure_is_queued_uses_the_new_one); +} From ccd9c82bf81d7c9863cf20c6459a00066a8e3eef Mon Sep 17 00:00:00 2001 From: Will Miles Date: Wed, 30 Sep 2026 23:36:49 -0400 Subject: [PATCH 14/24] test: Cover one connection, one dispose Co-Authored-By: Claude Opus 5.5 --- test/test_native/README.md | 1 + test/test_native/mocks/mock_lwip.cpp | 4 + test/test_native/mocks/mock_lwip.h | 1 + test/test_native/test_main.cpp | 2 + test/test_native/tests/test_dispose.cpp | 329 ++++++++++++++++++++++++ 5 files changed, 337 insertions(+) create mode 100644 test/test_native/tests/test_dispose.cpp diff --git a/test/test_native/README.md b/test/test_native/README.md index ff8d0dd9..88055b4c 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -121,6 +121,7 @@ test/test_native/ test_recv.cpp the inbound path test_timeout.cpp the rx and ack timeouts, onPoll, keepalive test_error.cpp how failures are reported + test_dispose.cpp one connection, one terminal notification test_dns.cpp name resolution, and lookups that are abandoned test_server_listen.cpp begin(), end(), status(), restarts test_server_accept.cpp delivering accepted connections diff --git a/test/test_native/mocks/mock_lwip.cpp b/test/test_native/mocks/mock_lwip.cpp index 9d7cdbea..edf32f9d 100644 --- a/test/test_native/mocks/mock_lwip.cpp +++ b/test/test_native/mocks/mock_lwip.cpp @@ -633,6 +633,10 @@ size_t live_pbufs() { return g_pbufs.size(); } +std::vector bound_ports() { + return std::vector(g_ports.begin(), g_ports.end()); +} + bool port_is_bound(uint16_t port) { return g_ports.count(port) != 0; } diff --git a/test/test_native/mocks/mock_lwip.h b/test/test_native/mocks/mock_lwip.h index dc0e3252..cc22d1e4 100644 --- a/test/test_native/mocks/mock_lwip.h +++ b/test/test_native/mocks/mock_lwip.h @@ -50,6 +50,7 @@ bool is_live(const tcp_pcb *pcb); tcp_pcb *dialled_pcb(); size_t live_pbufs(); // Ports currently held by a bound or listening pcb. +std::vector bound_ports(); bool port_is_bound(uint16_t port); // Everything a pcb was handed to tcp_write(), in order, kept after tcp_close(). diff --git a/test/test_native/test_main.cpp b/test/test_native/test_main.cpp index a002ce6a..48ec8fcb 100644 --- a/test/test_native/test_main.cpp +++ b/test/test_native/test_main.cpp @@ -51,6 +51,7 @@ void run_server_accept_tests(void); void run_server_config_tests(void); void run_state_tests(void); void run_error_tests(void); +void run_dispose_tests(void); namespace { @@ -343,6 +344,7 @@ int main(int argc, char **argv) { run_server_config_tests(); run_state_tests(); run_error_tests(); + run_dispose_tests(); if (g_list_only) { return 0; diff --git a/test/test_native/tests/test_dispose.cpp b/test/test_native/tests/test_dispose.cpp new file mode 100644 index 00000000..cac95a20 --- /dev/null +++ b/test/test_native/tests/test_dispose.cpp @@ -0,0 +1,329 @@ +// "One connection, one dispose": the application gets exactly one terminal notification +// per connection, and a client that is owed one cannot start another until it arrives. +// +// The awkward cases all come from the same place. connect() promises a callback before +// there is anything to hang it on, tcp_error() fires after LwIP has already freed the +// pcb, and the notification can therefore be owed across a window in which the +// application may close, abort, reconnect, or destroy the client. + +#include "fixtures.h" + +using namespace mocklwip; + +static void test_dispose_a_failed_tcp_connect_owes_nothing_and_does_not_wedge(void) { + AsyncClient c; + Recorder t; + t.attach(c); + + // No route - the WiFi dropped, say. connect() reports failure, so it promised nothing. + faults().connect_result = ERR_RTE; + TEST_ASSERT_FALSE(c.connect(kPeer, kPort)); + TEST_ASSERT_EQUAL_size_t(1, count("tcp_connect")); + TEST_ASSERT_NULL(c.pcb()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); // disposed of, not left attached + TEST_ASSERT_EQUAL_size_t(0, bound_ports().size()); + TEST_ASSERT_TRUE(count("tcp_close") + count("tcp_abort") > 0); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(0, t.count('E')); + TEST_ASSERT_EQUAL_INT(0, t.count('D')); // nothing was owed, so nothing is reported + + // The retry every application writes must still work. + faults().connect_result = ERR_OK; + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + c.close(); +} + +static void test_dispose_superseding_a_lookup_with_a_cached_answer_still_connects(void) { + // The displaced lookup will never report, so the report it owed must not outlive it: + // a connect() answered from cache would otherwise be refused as if one were still + // outstanding. + faults().dns_result = ERR_INPROGRESS; + AsyncClient c; + Recorder t; + t.attach(c); + TEST_ASSERT_TRUE(c.connect("slow.test", kPort)); + + faults().dns_result = ERR_OK; // answered from cache this time + faults().dns_addr = kResolved; + TEST_ASSERT_TRUE(c.connect("fast.test", kPort)); + TEST_ASSERT_EQUAL_size_t(1, count("tcp_connect")); + + c.close(); + fire_dns(kResolved); // the displaced lookup answers late and is dropped + asynctcp_test_pump(); +} + +static void test_dispose_superseding_a_lookup_that_fails_leaves_the_client_usable(void) { + faults().dns_result = ERR_INPROGRESS; + AsyncClient c; + Recorder t; + t.attach(c); + TEST_ASSERT_TRUE(c.connect("slow.test", kPort)); + + faults().dns_result = ERR_VAL; // the new name is rejected outright + TEST_ASSERT_FALSE(c.connect("bad..test", kPort)); + + faults().dns_result = ERR_OK; // and the client is not wedged by that + faults().dns_addr = kResolved; + TEST_ASSERT_TRUE(c.connect("retry.test", kPort)); + c.close(); + fire_dns(kResolved); + asynctcp_test_pump(); +} + +static void test_dispose_abort_during_a_lookup_reports_and_leaves_the_client_usable(void) { + faults().dns_result = ERR_INPROGRESS; + AsyncClient c; + Recorder t; + t.attach(c); + + TEST_ASSERT_TRUE(c.connect("slow.test", kPort)); + c.abort(); // abandons the lookup + asynctcp_test_pump(); + + // connect() returned true, so a callback was promised. The pair fires, as it does for + // any other terminal error. + TEST_ASSERT_EQUAL_INT(1, t.count('E')); + TEST_ASSERT_EQUAL_INT(1, t.count('D')); + TEST_ASSERT_EQUAL_INT(0, t.count('C')); + + faults().dns_result = ERR_OK; + faults().dns_addr = kResolved; + TEST_ASSERT_TRUE(c.connect("other.test", kPort)); + c.close(); + fire_dns(kResolved); + asynctcp_test_pump(); +} + +static void test_dispose_a_lookup_that_resolves_but_cannot_dial_still_reports(void) { + // connect() returned true on the strength of the lookup, so the attempt owes exactly one + // of onConnect or onError - and the dispose that pairs with it. The dial happens inside + // the resolver callback, where a failure is nobody's return value to report. + faults().dns_result = ERR_INPROGRESS; + AsyncClient c; + Recorder t; + t.attach(c); + + TEST_ASSERT_TRUE(c.connect("slow.test", kPort)); + faults().connect_result = ERR_RTE; // resolved fine, but there is no route + fire_dns(kResolved); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_INT(0, t.count('C')); + TEST_ASSERT_EQUAL_INT(1, t.count('E')); + TEST_ASSERT_EQUAL_INT(1, t.count('D')); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + + // And the client is not wedged by the failed attempt. + faults().connect_result = ERR_OK; + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + c.close(); +} + +static void test_dispose_abort_on_an_idle_client_reports_nothing(void) { + AsyncClient c; + Recorder t; + t.attach(c); + c.abort(); // never attempted anything, so nothing is owed + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(0, t.count('E')); + TEST_ASSERT_EQUAL_INT(0, t.count('D')); +} + +static void test_dispose_reconnecting_inside_on_error_behaves_the_same_by_name_or_address(void) { + // A callback that starts a new attempt owes a report for that attempt, so an + // onDisconnect for the old one would be about the wrong connection. A reconnect by + // address dials at once; one by name waits for the resolver, and the two must not + // diverge. + for (int by_name = 0; by_name < 2; ++by_name) { + AsyncClient c; + Recorder t; + bool reconnected = false, connected_at_dispose = false; + c.onDisconnect([&](void *, AsyncClient *cl) { + t.seq += 'D'; + connected_at_dispose = cl->connected(); + }); + c.onError([&](void *, AsyncClient *cl, int8_t) { + t.seq += 'E'; + if (by_name) { + faults().dns_result = ERR_INPROGRESS; + reconnected = cl->connect("again.test", kPort); + } else { + reconnected = cl->connect(kPeer, kPort); + } + }); + + faults().dns_result = ERR_OK; + tcp_pcb *first = establish(c); + + fire_error(first, ERR_RST); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_INT(1, t.count('E')); + TEST_ASSERT_TRUE(reconnected); + if (!by_name) { + TEST_ASSERT_EQUAL_size_t(2, count("tcp_connect")); + } + TEST_ASSERT_EQUAL_INT(0, t.count('D')); // onError covered it; the new attempt owns the next one + TEST_ASSERT_FALSE(connected_at_dispose); + + c.close(); + fire_dns(kResolved); + asynctcp_test_pump(); + } +} + +static void test_dispose_destroying_a_live_client_reports_one_disconnect(void) { + AsyncClient *c = new AsyncClient(); + int disconnects = 0; + c->onDisconnect([&](void *, AsyncClient *) { + disconnects++; + }); + establish(*c); + TEST_ASSERT_EQUAL_size_t(1, live_pcbs()); + + delete c; + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, bound_ports().size()); + // Nothing may reach a client after it is destroyed, so the destructor is the only + // place left to deliver it. + TEST_ASSERT_EQUAL_INT(1, disconnects); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, disconnects); +} + +static void test_dispose_connect_is_refused_while_a_terminal_event_is_undelivered(void) { + AsyncClient c; + int errors = 0, disconnects = 0; + bool connected_at_dispose = false; + c.onError([&](void *, AsyncClient *, int8_t) { + errors++; + }); + c.onDisconnect([&](void *, AsyncClient *cl) { + disconnects++; + connected_at_dispose = cl->connected(); + }); + + tcp_pcb *first = establish(c); + + // Reset: the terminal event is queued, but the application has not been told yet. + fire_error(first, ERR_RST); + + // Connecting now would have that event delivered against the new connection. + TEST_ASSERT_FALSE(c.connect(kPeer, kPort)); + TEST_ASSERT_NULL(c.pcb()); + + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, errors); + TEST_ASSERT_EQUAL_INT(1, disconnects); + TEST_ASSERT_FALSE(connected_at_dispose); // never told "disconnected" about a live connection + + // Reported, so the client is idle and connectable again. + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + c.close(); +} + +static void test_dispose_reconnect_from_inside_on_disconnect_is_allowed(void) { + AsyncClient c; + bool allowed = false; + int disconnects = 0; + c.onDisconnect([&](void *, AsyncClient *cl) { + if (++disconnects == 1) { + // By the time onDisconnect runs, the client is already idle. + allowed = cl->connect(kPeer, kPort); + } + }); + tcp_pcb *first = establish(c); + + fire_fin(first); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, disconnects); + TEST_ASSERT_TRUE(allowed); + c.close(); +} + +static void test_dispose_is_delivered_once_for_an_explicit_close(void) { + AsyncClient *c = new AsyncClient(); + int disconnects = 0; + c->onDisconnect([&](void *, AsyncClient *) { + disconnects++; + }); + tcp_pcb *pcb = establish(*c); + + // Data arrives and queues, but is not delivered yet. + fire_recv(pcb, "hello", 5); + + // The application closes: one notification. The data still waiting to be delivered + // goes with the connection. + c->close(); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, disconnects); + + // The destructor closes again unconditionally. Whatever that second pass finds, the + // application must not hear about the same connection twice. + delete c; + TEST_ASSERT_EQUAL_INT(1, disconnects); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, disconnects); +} + +static void test_dispose_is_delivered_exactly_once_when_the_peer_closes_first(void) { + AsyncClient *c = new AsyncClient(); + int disconnects = 0; + c->onDisconnect([&](void *, AsyncClient *) { + disconnects++; + }); + tcp_pcb *pcb = establish(*c); + + // The peer hangs up: the library closes, and that is the one notification. + fire_fin(pcb); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, disconnects); + + // Destroying afterwards must not report the same connection a second time, even though + // the destructor closes unconditionally. + delete c; + TEST_ASSERT_EQUAL_INT(1, disconnects); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, disconnects); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_dispose_is_still_delivered_when_a_reset_beats_the_destructor(void) { + AsyncClient *c = new AsyncClient(); + int disconnects = 0; + c->onDisconnect([&](void *, AsyncClient *) { + disconnects++; + }); + tcp_pcb *pcb = establish(*c); + + // Reset arrives and queues a terminal event, but the application destroys the client + // before the async task delivers it. The notification is still owed, and nothing may + // reach a client after it is destroyed, so destruction is the last chance to make it. + fire_error(pcb, ERR_RST); + TEST_ASSERT_EQUAL_INT(0, disconnects); + + delete c; + TEST_ASSERT_EQUAL_INT(1, disconnects); + + // Draining the queue afterwards must not produce a second one. + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(1, disconnects); +} + +void run_dispose_tests(void) { + UnitySetTestFile(__FILE__); + RUN_TEST(test_dispose_a_failed_tcp_connect_owes_nothing_and_does_not_wedge); + RUN_TEST(test_dispose_superseding_a_lookup_with_a_cached_answer_still_connects); + RUN_TEST(test_dispose_superseding_a_lookup_that_fails_leaves_the_client_usable); + RUN_TEST(test_dispose_abort_during_a_lookup_reports_and_leaves_the_client_usable); + RUN_TEST(test_dispose_a_lookup_that_resolves_but_cannot_dial_still_reports); + RUN_TEST(test_dispose_abort_on_an_idle_client_reports_nothing); + RUN_TEST(test_dispose_reconnecting_inside_on_error_behaves_the_same_by_name_or_address); + RUN_TEST(test_dispose_destroying_a_live_client_reports_one_disconnect); + RUN_TEST(test_dispose_connect_is_refused_while_a_terminal_event_is_undelivered); + RUN_TEST(test_dispose_reconnect_from_inside_on_disconnect_is_allowed); + RUN_TEST(test_dispose_is_delivered_once_for_an_explicit_close); + RUN_TEST(test_dispose_is_delivered_exactly_once_when_the_peer_closes_first); + RUN_TEST(test_dispose_is_still_delivered_when_a_reset_beats_the_destructor); +} From 68361f69e52cd4e7fa4f878f443a88b9cc1ddeda Mon Sep 17 00:00:00 2001 From: Will Miles Date: Wed, 30 Sep 2026 23:37:14 -0400 Subject: [PATCH 15/24] test: Cover object lifetimes Co-Authored-By: Claude Opus 5.5 --- test/test_native/README.md | 1 + test/test_native/test_main.cpp | 2 + test/test_native/tests/test_lifetime.cpp | 497 +++++++++++++++++++++++ 3 files changed, 500 insertions(+) create mode 100644 test/test_native/tests/test_lifetime.cpp diff --git a/test/test_native/README.md b/test/test_native/README.md index 88055b4c..7ed3925b 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -123,6 +123,7 @@ test/test_native/ test_error.cpp how failures are reported test_dispose.cpp one connection, one terminal notification test_dns.cpp name resolution, and lookups that are abandoned + test_lifetime.cpp destroying a client at awkward moments test_server_listen.cpp begin(), end(), status(), restarts test_server_accept.cpp delivering accepted connections test_server_config.cpp setNoDelay() diff --git a/test/test_native/test_main.cpp b/test/test_native/test_main.cpp index 48ec8fcb..754b4a79 100644 --- a/test/test_native/test_main.cpp +++ b/test/test_native/test_main.cpp @@ -52,6 +52,7 @@ void run_server_config_tests(void); void run_state_tests(void); void run_error_tests(void); void run_dispose_tests(void); +void run_lifetime_tests(void); namespace { @@ -345,6 +346,7 @@ int main(int argc, char **argv) { run_state_tests(); run_error_tests(); run_dispose_tests(); + run_lifetime_tests(); if (g_list_only) { return 0; diff --git a/test/test_native/tests/test_lifetime.cpp b/test/test_native/tests/test_lifetime.cpp new file mode 100644 index 00000000..3941cf43 --- /dev/null +++ b/test/test_native/tests/test_lifetime.cpp @@ -0,0 +1,497 @@ +// Object-lifetime tests. +// +// These cover the awkward lifetimes: destroying a client from inside its own callbacks, +// or while it still has queued events or a name lookup outstanding. LiveProbe +// below is the leak check. + +#include "fixtures.h" + +#include +#include + +using namespace mocklwip; + +namespace { + +/* + Answers "has this client let go of everything it was given yet?" without the library + carrying a counter for us. A client holds the callbacks it was given until it is done + with them, so a shared_ptr captured by one of them is released at exactly that moment - + and a weak_ptr to it then expires. It reports per client rather than as a global count, + which is what these tests actually want to assert. + + Every handler a test registers goes through wrap(), so the probe stays alive while the + client holds any of them and replacing one cannot release it early. attach() is for a + client with no handlers of its own: it registers a wrapped no-op. +*/ +class LiveProbe { +public: + template auto wrap(F f) { + std::shared_ptr tag = _weak.lock(); + if (!tag) { + tag = std::make_shared(0); + _weak = tag; + } + return [tag, f](auto &&...args) { + return f(std::forward(args)...); + }; + } + void attach(AsyncClient &c) { + c.onTimeout(wrap([](void *, AsyncClient *, uint32_t) {})); + } + bool alive() const { + return !_weak.expired(); + } + +private: + std::weak_ptr _weak; +}; + +} // namespace + +// --------------------------------------------------------------------------- + +static void test_lifetime_client_releases_its_callbacks(void) { + LiveProbe probe; + { + AsyncClient c; + probe.attach(c); + TEST_ASSERT_TRUE(probe.alive()); + } + TEST_ASSERT_FALSE(probe.alive()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_lifetime_connected_client_releases_on_close(void) { + LiveProbe probe; + { + AsyncClient c; + probe.attach(c); + establish(c); + c.close(); + } + asynctcp_test_pump(); + TEST_ASSERT_FALSE(probe.alive()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_lifetime_destroy_inside_onData_is_safe(void) { + LiveProbe probe; + bool ran = false; + AsyncClient *c = new AsyncClient(); + c->onData(probe.wrap([&](void *, AsyncClient *client, void *, size_t) { + ran = true; + delete client; // destroys the std::function we are executing inside + })); + + tcp_pcb *pcb = establish(*c); + + fire_recv(pcb, "hello", 5); + asynctcp_test_pump(); + + TEST_ASSERT_TRUE(ran); + TEST_ASSERT_FALSE(probe.alive()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_lifetime_destroy_inside_onError_is_safe(void) { + LiveProbe probe; + bool ran = false; + AsyncClient *c = new AsyncClient(); + c->onError(probe.wrap([&](void *, AsyncClient *client, int8_t) { + ran = true; + delete client; + })); + + tcp_pcb *pcb = establish(*c); + + fire_error(pcb, ERR_RST); + asynctcp_test_pump(); + + TEST_ASSERT_TRUE(ran); + TEST_ASSERT_FALSE(probe.alive()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_lifetime_destroy_inside_onConnect_is_safe(void) { + LiveProbe probe; + bool ran = false; + AsyncClient *c = new AsyncClient(); + c->onConnect(probe.wrap([&](void *, AsyncClient *client) { + ran = true; + delete client; + })); + + TEST_ASSERT_TRUE(c->connect(kPeer, kPort)); + fire_connected(dialled_pcb()); // c is destroyed inside onConnect + asynctcp_test_pump(); + + TEST_ASSERT_TRUE(ran); + TEST_ASSERT_FALSE(probe.alive()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_lifetime_destroy_with_a_queued_event_delivers_nothing(void) { + LiveProbe probe; + bool ran = false; + { + AsyncClient c; + c.onData(probe.wrap([&](void *, AsyncClient *, void *, size_t) { + ran = true; + })); + tcp_pcb *pcb = establish(c); + + // Queue an event, then destroy the client before the async task runs + fire_recv(pcb, "hello", 5); + } + + asynctcp_test_pump(); // the event is still queued, and must simply drain + TEST_ASSERT_FALSE(ran); + TEST_ASSERT_FALSE(probe.alive()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_lifetime_close_inside_onData_acks_the_packet(void) { + LiveProbe probe; + // Without the ack the peer gets an RST for data the application did process. + { + AsyncClient c; + c.onData(probe.wrap([](void *, AsyncClient *client, void *, size_t) { + client->close(); + })); + tcp_pcb *pcb = establish(c); + + fire_recv(pcb, "hello", 5); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + TEST_ASSERT_TRUE(saw("tcp_recved", pcb, 5)); // the packet onData was handed + TEST_ASSERT_EQUAL_size_t(1, count("tcp_close")); + } + TEST_ASSERT_FALSE(probe.alive()); +} + +static void test_lifetime_destroy_during_a_lookup_releases_the_callbacks(void) { + LiveProbe probe; + faults().dns_result = ERR_INPROGRESS; + { + AsyncClient c; + probe.attach(c); + TEST_ASSERT_TRUE(c.connect("slow.test", kPort)); + TEST_ASSERT_TRUE(dns_pending()); + } + // An answer nobody wants must not keep the client's resources alive until the + // resolver gives up. + TEST_ASSERT_FALSE(probe.alive()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + + // The callback still arrives - LwIP cannot cancel one - and must find nothing to do. + fire_dns(0x0A000001); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(0, count("tcp_connect")); // the client is gone; do not dial out + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_lifetime_self_owned_client_deletes_itself_in_onDisconnect(void) { + // The ESPAsyncWebServer shape: the client owns itself and the handler is its undertaker. + LiveProbe probe; + int disconnects = 0; + AsyncClient *c = new AsyncClient(); + c->onDisconnect(probe.wrap([&](void *, AsyncClient *self) { + disconnects++; + delete self; + })); + tcp_pcb *pcb = establish(*c); + + fire_fin(pcb); // peer hangs up; onDisconnect runs and destroys the client + asynctcp_test_pump(); + + // Exactly once, although destruction closes again - and without re-entering the + // handler that is destroying the client. + TEST_ASSERT_EQUAL_INT(1, disconnects); + TEST_ASSERT_FALSE(probe.alive()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_lifetime_destroy_inside_onError_raised_by_abort(void) { + // abort() reports the error synchronously, on the calling task rather than the async + // task, and the callback destroys the client while abort() is still on the stack. + LiveProbe probe; + int errors = 0, disconnects = 0; + AsyncClient *c = new AsyncClient(); + c->onError(probe.wrap([&](void *, AsyncClient *self, int8_t) { + errors++; + delete self; + })); + c->onDisconnect(probe.wrap([&](void *, AsyncClient *) { + disconnects++; + })); + + establish(*c); + + c->abort(); // c is destroyed inside the error callback; do not touch it after this + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_INT(1, errors); + TEST_ASSERT_EQUAL_INT(0, disconnects); // the client is gone, so it must not be handed to onDisconnect + TEST_ASSERT_FALSE(probe.alive()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_lifetime_reconnect_after_close_starts_clean(void) { + LiveProbe probe; + { + AsyncClient c; + probe.attach(c); + tcp_pcb *first = establish(c); + + // Withhold an ack, then close: the debt must not survive into the next connection + c.ackLater(); + fire_recv(first, "hello", 5); + asynctcp_test_pump(); + c.close(); + asynctcp_test_pump(); + + const size_t recved_before = count("tcp_recved"); + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + tcp_pcb *second = dialled_pcb(); + TEST_ASSERT_NOT_NULL(second); + fire_connected(second); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(0, c.ack(5)); // nothing outstanding on the new pcb + TEST_ASSERT_EQUAL_size_t(recved_before, count("tcp_recved")); // and nothing acked against it + + c.close(); + asynctcp_test_pump(); + } + TEST_ASSERT_FALSE(probe.alive()); +} + +// --------------------------------------------------------------------------- +// Tearing down one client from inside another's callback +// --------------------------------------------------------------------------- + +static void test_lifetime_closing_another_client_from_onData_drops_its_queued_events(void) { + AsyncClient a; + AsyncClient b; + Recorder ta; + Recorder tb; + ta.attach(a, "CEDR"); + tb.attach(b, "CEDR"); + a.onData([&](void *, AsyncClient *cl, void *d, size_t n) { + ta.note('R', cl); + ta.data.append((const char *)d, n); + if (ta.count('R') == 1) { + b.close(); + } + }); + tcp_pcb *pa = establish(a); + tcp_pcb *pb = establish(b); + + fire_recv(pa, "a1", 2); + fire_recv(pb, "b1", 2); + fire_fin(pb); + fire_recv(pa, "a2", 2); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("a1a2", ta.data.c_str()); + TEST_ASSERT_EQUAL_STRING("CD", tb.seq.c_str()); + TEST_ASSERT_TRUE(a.connected()); + TEST_ASSERT_FALSE(is_live(pb)); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + + a.close(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_lifetime_destroying_another_client_from_onData_drops_its_queued_events(void) { + AsyncClient a; + AsyncClient *b = new AsyncClient(); + Recorder ta; + Recorder tb; + ta.attach(a, "CED"); + tb.attach(*b, "CEDR"); + a.onData([&](void *, AsyncClient *cl, void *d, size_t n) { + ta.note('R', cl); + ta.data.append((const char *)d, n); + if (b) { + delete b; + b = nullptr; + } + }); + tcp_pcb *pa = establish(a); + tcp_pcb *pb = establish(*b); + + fire_recv(pa, "a1", 2); + fire_recv(pb, "b1", 2); + fire_fin(pb); + fire_recv(pa, "a2", 2); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("a1a2", ta.data.c_str()); + TEST_ASSERT_EQUAL_STRING("CD", tb.seq.c_str()); // the connection was up, so onDisconnect is owed + TEST_ASSERT_TRUE(a.connected()); + TEST_ASSERT_FALSE(is_live(pb)); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + + a.close(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_lifetime_aborting_another_client_from_onDisconnect_reports_it_once(void) { + // b was reset before a's callback aborts it: b is owed one onError and one onDisconnect, + // whichever of the two reports them. + AsyncClient a; + AsyncClient b; + Recorder ta; + Recorder tb; + ta.attach(a, "CE"); + tb.attach(b, "CEDR"); + a.onDisconnect([&](void *, AsyncClient *cl) { + ta.note('D', cl); + b.abort(); + }); + tcp_pcb *pa = establish(a); + tcp_pcb *pb = establish(b); + + fire_fin(pa); + fire_recv(pb, "b1", 2); + fire_error(pb, ERR_RST); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("CD", ta.seq.c_str()); + TEST_ASSERT_EQUAL_STRING("CED", tb.seq.c_str()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_lifetime_destroying_another_client_from_onError_drops_its_queued_events(void) { + AsyncClient a; + AsyncClient *b = new AsyncClient(); + Recorder ta; + Recorder tb; + ta.attach(a, "CD"); + tb.attach(*b, "CEDR"); + a.onError([&](void *, AsyncClient *cl, int8_t e) { + ta.note('E', cl, e); + delete b; + b = nullptr; + }); + tcp_pcb *pa = establish(a); + tcp_pcb *pb = establish(*b); + + fire_error(pa, ERR_RST); + fire_recv(pb, "b1", 2); + fire_fin(pb); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("CED", ta.seq.c_str()); + TEST_ASSERT_EQUAL_STRING("CD", tb.seq.c_str()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_lifetime_destroying_another_client_with_a_queued_error_reports_its_disconnect(void) { + // b's reset is queued when a's callback destroys it. Nothing may reach b once it is + // destroyed, so the onDisconnect it is owed comes from its destructor. + AsyncClient a; + AsyncClient *b = new AsyncClient(); + Recorder ta; + Recorder tb; + ta.attach(a, "CED"); + tb.attach(*b); + a.onData([&](void *, AsyncClient *cl, void *, size_t) { + ta.note('R', cl); + if (b) { + delete b; + b = nullptr; + } + }); + tcp_pcb *pa = establish(a); + tcp_pcb *pb = establish(*b); + + fire_recv(pa, "a1", 2); + fire_error(pb, ERR_RST); + fire_recv(pa, "a2", 2); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("CRR", ta.seq.c_str()); + TEST_ASSERT_EQUAL_STRING("CD", tb.seq.c_str()); + TEST_ASSERT_TRUE(a.connected()); + + a.close(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_lifetime_destroy_closes_a_connection_made_from_onDisconnect(void) { + // ~AsyncClient() runs onDisconnect, which dials again. + AsyncClient *c = new AsyncClient(); + Recorder t; + t.attach(*c, "CER"); + c->onDisconnect([&](void *, AsyncClient *cl) { + t.note('D', cl); + cl->connect(kPeer, kPort); + }); + establish(*c); + delete c; + + // A connection left behind would hear from the peer. + tcp_pcb *again = dialled_pcb(); + if (is_live(again)) { + fire_connected(again); + fire_recv(again, "late", 4); + asynctcp_test_pump(); + } + TEST_ASSERT_EQUAL_STRING("CD", t.seq.c_str()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_lifetime_destroy_abandons_a_lookup_started_from_onDisconnect(void) { + AsyncClient *c = new AsyncClient(); + Recorder t; + t.attach(*c, "CER"); + c->onDisconnect([&](void *, AsyncClient *cl) { + t.note('D', cl); + cl->connect("late.test", kPort); + }); + establish(*c); + faults().dns_result = ERR_INPROGRESS; + delete c; + + fire_dns(kResolved); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("CD", t.seq.c_str()); + TEST_ASSERT_EQUAL_size_t(1, count("tcp_connect")); // only the first dial + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +void run_lifetime_tests(void) { + UnitySetTestFile(__FILE__); + RUN_TEST(test_lifetime_client_releases_its_callbacks); + RUN_TEST(test_lifetime_connected_client_releases_on_close); + RUN_TEST(test_lifetime_destroy_inside_onData_is_safe); + RUN_TEST(test_lifetime_destroy_inside_onError_is_safe); + RUN_TEST(test_lifetime_destroy_inside_onConnect_is_safe); + RUN_TEST(test_lifetime_destroy_with_a_queued_event_delivers_nothing); + RUN_TEST(test_lifetime_close_inside_onData_acks_the_packet); + RUN_TEST(test_lifetime_destroy_during_a_lookup_releases_the_callbacks); + RUN_TEST(test_lifetime_self_owned_client_deletes_itself_in_onDisconnect); + RUN_TEST(test_lifetime_destroy_inside_onError_raised_by_abort); + RUN_TEST(test_lifetime_reconnect_after_close_starts_clean); + RUN_TEST(test_lifetime_closing_another_client_from_onData_drops_its_queued_events); + RUN_TEST(test_lifetime_destroying_another_client_from_onData_drops_its_queued_events); + RUN_TEST(test_lifetime_aborting_another_client_from_onDisconnect_reports_it_once); + RUN_TEST(test_lifetime_destroying_another_client_from_onError_drops_its_queued_events); + RUN_TEST(test_lifetime_destroying_another_client_with_a_queued_error_reports_its_disconnect); + RUN_TEST(test_lifetime_destroy_closes_a_connection_made_from_onDisconnect); + RUN_TEST(test_lifetime_destroy_abandons_a_lookup_started_from_onDisconnect); +} From fca4b44d9dd288e6d3d80971b530f8474eddfa54 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Wed, 30 Sep 2026 23:37:39 -0400 Subject: [PATCH 16/24] test: Cover what each handler is handed Co-Authored-By: Claude Opus 5.5 --- test/test_native/README.md | 1 + test/test_native/test_main.cpp | 2 + test/test_native/tests/test_callback.cpp | 160 +++++++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 test/test_native/tests/test_callback.cpp diff --git a/test/test_native/README.md b/test/test_native/README.md index 7ed3925b..04a80794 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -124,6 +124,7 @@ test/test_native/ test_dispose.cpp one connection, one terminal notification test_dns.cpp name resolution, and lookups that are abandoned test_lifetime.cpp destroying a client at awkward moments + test_callback.cpp what each handler is handed test_server_listen.cpp begin(), end(), status(), restarts test_server_accept.cpp delivering accepted connections test_server_config.cpp setNoDelay() diff --git a/test/test_native/test_main.cpp b/test/test_native/test_main.cpp index 754b4a79..75412e05 100644 --- a/test/test_native/test_main.cpp +++ b/test/test_native/test_main.cpp @@ -53,6 +53,7 @@ void run_state_tests(void); void run_error_tests(void); void run_dispose_tests(void); void run_lifetime_tests(void); +void run_callback_tests(void); namespace { @@ -347,6 +348,7 @@ int main(int argc, char **argv) { run_error_tests(); run_dispose_tests(); run_lifetime_tests(); + run_callback_tests(); if (g_list_only) { return 0; diff --git a/test/test_native/tests/test_callback.cpp b/test/test_native/tests/test_callback.cpp new file mode 100644 index 00000000..0ac10b7e --- /dev/null +++ b/test/test_native/tests/test_callback.cpp @@ -0,0 +1,160 @@ +// What each handler is handed: the argument it was registered with. + +#include "fixtures.h" + +#include + +using namespace mocklwip; + +namespace { + +// The nine handlers, by Recorder letter; K is onPacket and S is onClient. +const char kHandlers[] = "CDAERKTPS"; + +// One distinct argument per handler, and what each handler was given. +struct Args { + int marker[sizeof(kHandlers) - 1] = {}; + std::map seen; + + void *arg(char e) { + return &marker[strchr(kHandlers, e) - kHandlers]; + } + void record(char e, void *a) { + seen[e] = a; + } + void check(char e) { + char msg[] = "handler ? was not given its argument"; + msg[8] = e; + TEST_ASSERT_TRUE_MESSAGE(seen.count(e) != 0, msg); + TEST_ASSERT_EQUAL_PTR_MESSAGE(arg(e), seen[e], msg); + } +}; + +} // namespace + +static void test_callback_each_client_handler_is_given_its_own_argument(void) { + AsyncClient c; + Args a; + c.onConnect( + [&](void *arg, AsyncClient *) { + a.record('C', arg); + }, + a.arg('C') + ); + c.onDisconnect( + [&](void *arg, AsyncClient *) { + a.record('D', arg); + }, + a.arg('D') + ); + c.onAck( + [&](void *arg, AsyncClient *, size_t, uint32_t) { + a.record('A', arg); + }, + a.arg('A') + ); + c.onError( + [&](void *arg, AsyncClient *, int8_t) { + a.record('E', arg); + }, + a.arg('E') + ); + c.onData( + [&](void *arg, AsyncClient *, void *, size_t) { + a.record('R', arg); + }, + a.arg('R') + ); + c.onTimeout( + [&](void *arg, AsyncClient *, uint32_t) { + a.record('T', arg); + }, + a.arg('T') + ); + c.onPoll( + [&](void *arg, AsyncClient *) { + a.record('P', arg); + }, + a.arg('P') + ); + + tcp_pcb *pcb = establish(c); + c.setAckTimeout(100); + + fire_recv(pcb, "data", 4); // onData + asynctcp_test_pump(); + fire_poll(pcb); // onPoll: nothing outstanding + asynctcp_test_pump(); + c.write("x", 1); + fire_sent(pcb, 1); // onAck + asynctcp_test_pump(); + mockclock::advance(10); + c.write("y", 1); + mockclock::advance(150); + fire_poll(pcb); // onTimeout + asynctcp_test_pump(); + + // onPacket takes over from onData once registered. + c.onPacket( + [&](void *arg, AsyncClient *cl, pbuf *pb) { + a.record('K', arg); + cl->ackPacket(pb); + }, + a.arg('K') + ); + fire_recv(pcb, "pkt", 3); + asynctcp_test_pump(); + + fire_error(pcb, ERR_RST); // onError, onDisconnect + asynctcp_test_pump(); + + for (char e : std::string("CRPATKED")) { + a.check(e); + } + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_callback_onDisconnect_after_close_is_given_its_argument(void) { + AsyncClient c; + Args a; + c.onDisconnect( + [&](void *arg, AsyncClient *) { + a.record('D', arg); + }, + a.arg('D') + ); + establish(c); + + c.close(); + asynctcp_test_pump(); + a.check('D'); +} + +static void test_callback_onClient_is_given_its_argument(void) { + AsyncServer s(kServerPort); + Args a; + AsyncClient *client = nullptr; + s.onClient( + [&](void *arg, AsyncClient *c) { + a.record('S', arg); + client = c; + }, + a.arg('S') + ); + s.begin(); + + TEST_ASSERT_NOT_NULL(fire_accept(listen_pcb())); + asynctcp_test_pump(); + a.check('S'); + + delete client; + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +void run_callback_tests(void) { + UnitySetTestFile(__FILE__); + RUN_TEST(test_callback_each_client_handler_is_given_its_own_argument); + RUN_TEST(test_callback_onDisconnect_after_close_is_given_its_argument); + RUN_TEST(test_callback_onClient_is_given_its_argument); +} From c78073cd64235a94e141a9f0ffd2ea64efe80400 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Wed, 30 Sep 2026 23:38:16 -0400 Subject: [PATCH 17/24] test: Cover where callbacks run Co-Authored-By: Claude Opus 5.5 --- test/test_native/README.md | 6 +- test/test_native/fixtures.h | 83 +++- test/test_native/test_main.cpp | 2 + test/test_native/tests/test_context.cpp | 547 ++++++++++++++++++++++++ 4 files changed, 635 insertions(+), 3 deletions(-) create mode 100644 test/test_native/tests/test_context.cpp diff --git a/test/test_native/README.md b/test/test_native/README.md index 04a80794..432274ba 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -113,7 +113,7 @@ test/test_native/ test_main.cpp the runner: per-test reset, forking, Unity reporting asynctcp.cpp the library under test, compiled into the test program fixtures.h what most tests share: the peer and ports, establish(), - listen_pcb(), Recorder, Accepted + listen_pcb(), AppCall, Recorder, Accepted tests/ one file per topic; add files here, they are picked up test_client.cpp end-to-end smoke tests, one per main path test_state.cpp the connection state machine and the accessors @@ -125,6 +125,7 @@ test/test_native/ test_dns.cpp name resolution, and lookups that are abandoned test_lifetime.cpp destroying a client at awkward moments test_callback.cpp what each handler is handed + test_context.cpp callbacks run on the async task, not inside calls test_server_listen.cpp begin(), end(), status(), restarts test_server_accept.cpp delivering accepted connections test_server_config.cpp setNoDelay() @@ -230,6 +231,9 @@ is what redirects `RUN_TEST` into the forking runner. A new file's * `listen_pcb(port)`: the listening pcb, on `port` or on any port; * `Recorder`: records a client's callbacks as a string, one letter each, so order, count and exactly-once are one assertion; +* `AppCall`, `app()`, `app_close()`, `app_abort()`, `app_delete()`: mark a call + the application makes into the library, so a `Recorder` notes in + `violations` any callback run inside it that the call may not run; * `Accepted`: takes delivery of a server's clients and deletes whatever is left of them at scope exit. diff --git a/test/test_native/fixtures.h b/test/test_native/fixtures.h index d0b16a61..df251335 100644 --- a/test/test_native/fixtures.h +++ b/test/test_native/fixtures.h @@ -46,22 +46,83 @@ inline tcp_pcb *listen_pcb(uint16_t port = 0) { return nullptr; } +// The application calling into the library, from a test body or from inside a callback. +// Open one around each call. Callbacks run on the async task, never inside a call, except +// that close() and ~AsyncClient() may run the client's own onDisconnect, and abort() its +// own onError(ERR_ABRT) then onDisconnect. +class AppCall { +public: + // is what is called, on if anything; is the letters it may run + // for that client (see Recorder). + explicit AppCall(const char *name, const AsyncClient *client = nullptr, const char *may_run = "") + : _name(name), _client(client), _may_run(may_run), _outer(_current) { + _current = this; + } + ~AppCall() { + _current = _outer; + } + AppCall(const AppCall &) = delete; + AppCall &operator=(const AppCall &) = delete; + + // The innermost call open, or nullptr while the library runs on its own. + static const AppCall *current() { + return _current; + } + const char *name() const { + return _name; + } + bool allows(char event, const AsyncClient *c, int8_t err) const { + if (c != _client || !strchr(_may_run, event)) { + return false; + } + return event != 'E' || err == ERR_ABRT; + } + +private: + const char *_name; + const AsyncClient *_client; + const char *_may_run; + const AppCall *_outer; + static inline const AppCall *_current = nullptr; +}; + +// Runs as the application call , which may run no callbacks. +template auto app(const char *name, F &&f) { + AppCall call(name); + return f(); +} +inline void app_close(AsyncClient &c) { + AppCall call("close", &c, "D"); + c.close(); +} +inline int8_t app_abort(AsyncClient &c) { + AppCall call("abort", &c, "ED"); + return c.abort(); +} +inline void app_delete(AsyncClient *c) { + AppCall call("~AsyncClient", c, "D"); + delete c; +} + // Records a client's callbacks as one letter each, in the order they run, so that order, // count and exactly-once are one assertion: "ED" is onError then onDisconnect, "" is // silence. // // C onConnect E onError D onDisconnect R onData -// A onAck T onTimeout +// A onAck P onPoll T onTimeout K onPacket, which acks the pbuf // // attach() registers only the callbacks it is given -- by default the three that open and // end a connection -- because registering a handler can change what the library does. +// +// A callback run inside an AppCall that does not allow it is added to . class Recorder { public: std::string seq; int8_t err = 0; // what the last onError was given - std::string data; // everything onData was given, concatenated + std::string data; // everything onData or onPacket was given, concatenated size_t acked = 0; // what the last onAck was given uint32_t timed_out = 0; // what the last onTimeout was given + std::string violations; // "R in write(); ..." Recorder() = default; Recorder(const Recorder &) = delete; @@ -96,17 +157,35 @@ class Recorder { acked = n; }); } + if (strchr(events, 'P')) { + c.onPoll([this](void *, AsyncClient *cl) { + note('P', cl); + }); + } if (strchr(events, 'T')) { c.onTimeout([this](void *, AsyncClient *cl, uint32_t t) { note('T', cl); timed_out = t; }); } + if (strchr(events, 'K')) { + c.onPacket([this](void *, AsyncClient *cl, pbuf *pb) { + note('K', cl); + data.append((const char *)pb->payload, pb->len); + AppCall call("ackPacket"); + cl->ackPacket(pb); + }); + } } // Records for client , as the handlers attach() registers do. void note(char event, const AsyncClient *c, int8_t e = 0) { seq += event; + const AppCall *call = AppCall::current(); + if (call && !call->allows(event, c, e)) { + violations += event; + violations += std::string(" in ") + call->name() + "(); "; + } } int count(char event) const { diff --git a/test/test_native/test_main.cpp b/test/test_native/test_main.cpp index 75412e05..bfd69231 100644 --- a/test/test_native/test_main.cpp +++ b/test/test_native/test_main.cpp @@ -54,6 +54,7 @@ void run_error_tests(void); void run_dispose_tests(void); void run_lifetime_tests(void); void run_callback_tests(void); +void run_context_tests(void); namespace { @@ -349,6 +350,7 @@ int main(int argc, char **argv) { run_dispose_tests(); run_lifetime_tests(); run_callback_tests(); + run_context_tests(); if (g_list_only) { return 0; diff --git a/test/test_native/tests/test_context.cpp b/test/test_native/tests/test_context.cpp new file mode 100644 index 00000000..032f2317 --- /dev/null +++ b/test/test_native/tests/test_context.cpp @@ -0,0 +1,547 @@ +// Where callbacks run: on the async task, never inside a call the application makes into +// the library -- except that close() and ~AsyncClient() may run the client's own +// onDisconnect, and abort() its own onError(ERR_ABRT) then onDisconnect. +// +// Every call below is made inside an AppCall, and the Recorder notes any callback that +// runs inside one that does not allow it. Events are queued before the calls, so a call +// that delivered them would be caught doing it. + +#include "fixtures.h" + +#include +#include +#include + +using namespace mocklwip; + +namespace { + +// Every call that neither ends the connection nor starts one, each as its own AppCall. +void poke(AsyncClient &c, Recorder &t, const char *events) { + app("add", [&] { + return c.add("a", 1); + }); + app("send", [&] { + return c.send(); + }); + app("write", [&] { + return c.write("w", 1); + }); + app("write", [&] { + return c.write("s"); + }); + app("ack", [&] { + return c.ack(1); + }); + app("ackLater", [&] { + c.ackLater(); + }); + app("setRxTimeout", [&] { + c.setRxTimeout(c.getRxTimeout()); + }); + app("setAckTimeout", [&] { + c.setAckTimeout(c.getAckTimeout()); + }); + app("setNoDelay", [&] { + c.setNoDelay(c.getNoDelay()); + }); + if (c.pcb()) { + app("setKeepAlive", [&] { + c.setKeepAlive(0, 0); + }); + } + app("handler setters", [&] { + t.attach(c, events); + }); + app("getters", [&] { + return c.state() + c.connecting() + c.connected() + c.disconnecting() + c.disconnected() + c.freeable() + c.free() + c.canSend() + c.space() + c.getMss() + + c.getRemoteAddress() + c.getRemotePort() + c.getLocalAddress() + c.getLocalPort() + (uint32_t)c.remoteIP() + (uint32_t)c.localIP() + + c.getRemoteAddress4().addr + c.getLocalAddress4().addr + strlen(c.stateToString()); + }); +#if LWIP_IPV6 + app("getters6", [&] { + return c.getRemoteAddress6().addr[0] + c.getLocalAddress6().addr[0] + ((const uint32_t *)c.remoteIP6())[0] + ((const uint32_t *)c.localIP6())[0]; + }); +#endif +} + +} // namespace + +// --------------------------------------------------------------------------- +// Calls made from the application's own task +// --------------------------------------------------------------------------- + +static void test_context_calls_do_not_deliver_a_queued_connect(void) { + AsyncClient c; + Recorder t; + t.attach(c, "CEDRAPT"); + TEST_ASSERT_TRUE(app("connect", [&] { + return c.connect(kPeer, kPort); + })); + fire_connected(dialled_pcb()); + + poke(c, t, "CEDRAPT"); + app("connect", [&] { + return c.connect(kPeer, kPort); + }); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("C", t.seq.c_str()); + TEST_ASSERT_EQUAL_STRING("", t.violations.c_str()); + app_close(c); +} + +static void test_context_calls_do_not_deliver_queued_traffic(void) { + AsyncClient c; + Recorder t; + t.attach(c, "CEDRAPT"); + tcp_pcb *pcb = establish(c); + c.setAckTimeout(0); // polls report onPoll only + + app("write", [&] { + return c.write("x", 1); + }); + fire_recv(pcb, "in", 2); + fire_poll(pcb); + fire_sent(pcb, 1); + fire_recv(pcb, "more", 4); + + poke(c, t, "CEDRAPT"); + asynctcp_test_pump(); + + // All of it, on the task; the kinds may interleave in any order. + TEST_ASSERT_EQUAL_CHAR('C', t.seq[0]); + std::string kinds = t.seq.substr(1); + std::sort(kinds.begin(), kinds.end()); + TEST_ASSERT_EQUAL_STRING("APRR", kinds.c_str()); + TEST_ASSERT_EQUAL_STRING("inmore", t.data.c_str()); + TEST_ASSERT_EQUAL_STRING("", t.violations.c_str()); + app_close(c); + TEST_ASSERT_EQUAL_STRING("", t.violations.c_str()); +} + +static void test_context_calls_do_not_deliver_queued_packets(void) { + AsyncClient c; + Recorder t; + t.attach(c, "CEDK"); + tcp_pcb *pcb = establish(c); + + fire_recv(pcb, "in", 2); + fire_recv(pcb, "more", 4); + + poke(c, t, "CEDK"); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("CKK", t.seq.c_str()); + TEST_ASSERT_EQUAL_STRING("", t.violations.c_str()); + app_close(c); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_context_calls_do_not_deliver_a_queued_timeout(void) { + AsyncClient c; + Recorder t; + t.attach(c, "CEDT"); + tcp_pcb *pcb = establish(c); + c.setAckTimeout(100); + app("write", [&] { + return c.write("x", 1); + }); + mockclock::advance(150); + fire_poll(pcb); + + poke(c, t, "CEDT"); + mockclock::advance(150); // poke() sent too + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("CT", t.seq.c_str()); + TEST_ASSERT_EQUAL_STRING("", t.violations.c_str()); + app_close(c); +} + +static void test_context_calls_do_not_deliver_a_queued_fin(void) { + AsyncClient c; + Recorder t; + t.attach(c, "CEDR"); + tcp_pcb *pcb = establish(c); + fire_recv(pcb, "last", 4); + fire_fin(pcb); + + poke(c, t, "CEDR"); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("", t.violations.c_str()); + TEST_ASSERT_EQUAL_INT(1, t.count('D')); + TEST_ASSERT_EQUAL_INT(0, t.count('E')); +} + +static void test_context_calls_do_not_deliver_a_queued_error(void) { + AsyncClient c; + Recorder t; + t.attach(c, "CEDR"); + tcp_pcb *pcb = establish(c); + fire_error(pcb, ERR_RST); + + poke(c, t, "CEDR"); + app("connect", [&] { + return c.connect(kPeer, kPort); + }); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("", t.violations.c_str()); + TEST_ASSERT_EQUAL_INT(1, t.count('E')); + app_close(c); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("", t.violations.c_str()); +} + +static void test_context_close_with_a_queued_error_runs_no_onError(void) { + // close() may settle the connection with onDisconnect; the reset's onError is not its + // own to run. + AsyncClient c; + Recorder t; + t.attach(c); + tcp_pcb *pcb = establish(c); + fire_error(pcb, ERR_RST); + + app_close(c); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("", t.violations.c_str()); +} + +static void test_context_abort_with_a_queued_error_runs_no_other_error(void) { + AsyncClient c; + Recorder t; + t.attach(c); + tcp_pcb *pcb = establish(c); + fire_error(pcb, ERR_RST); + + app_abort(c); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("", t.violations.c_str()); +} + +static void test_context_close_runs_nothing_but_onDisconnect(void) { + AsyncClient c; + Recorder t; + t.attach(c, "CEDRAPT"); + tcp_pcb *pcb = establish(c); + app("write", [&] { + return c.write("x", 1); + }); + fire_recv(pcb, "in", 2); + fire_poll(pcb); + fire_sent(pcb, 1); + + app_close(c); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("", t.violations.c_str()); + TEST_ASSERT_EQUAL_INT(1, t.count('D')); +} + +static void test_context_abort_runs_nothing_but_its_own_error(void) { + AsyncClient c; + Recorder t; + t.attach(c, "CEDRAPT"); + tcp_pcb *pcb = establish(c); + app("write", [&] { + return c.write("x", 1); + }); + fire_recv(pcb, "in", 2); + fire_poll(pcb); + fire_sent(pcb, 1); + + app_abort(c); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("", t.violations.c_str()); + TEST_ASSERT_EQUAL_INT(1, t.count('E')); + TEST_ASSERT_EQUAL_INT8(ERR_ABRT, t.err); +} + +static void test_context_destructor_runs_nothing_but_onDisconnect(void) { + AsyncClient *c = new AsyncClient(); + Recorder t; + t.attach(*c, "CEDRAPT"); + tcp_pcb *pcb = establish(*c); + app("write", [&] { + return c->write("x", 1); + }); + fire_recv(pcb, "in", 2); + fire_poll(pcb); + fire_sent(pcb, 1); + + app_delete(c); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("", t.violations.c_str()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_context_connecting_runs_no_callbacks(void) { + // Every way to start a connection, with each outcome the call itself can see. + AsyncClient c; + Recorder t; + t.attach(c); + + auto settle = [&] { + app_close(c); + fire_dns(kResolved); // any lookup still outstanding answers, and is dropped + asynctcp_test_pump(); + }; + + app("connect", [&] { + return c.connect(kPeer, kPort); + }); + settle(); + + ip_addr_t addr = IPADDR4_INIT((uint32_t)kPeer); + app("connect", [&] { + return c.connect(addr, kPort); + }); + settle(); + +#if LWIP_IPV6 + const uint32_t kWords[4] = {0x20010db8, 0, 0, 1}; + app("connect", [&] { + return c.connect(IPv6Address(kWords), kPort); + }); + settle(); +#endif + + faults().dns_result = ERR_OK; + faults().dns_addr = kResolved; + app("connect", [&] { + return c.connect("cached.test", kPort); + }); + settle(); + + faults().dns_result = ERR_INPROGRESS; + app("connect", [&] { + return c.connect("slow.test", kPort); + }); + app("connect", [&] { + return c.connect("slower.test", kPort); // supersedes the first + }); + settle(); + + faults().dns_result = ERR_VAL; + app("connect", [&] { + return c.connect("bad..test", kPort); + }); + settle(); + + faults().connect_result = ERR_RTE; + app("connect", [&] { + return c.connect(kPeer, kPort); + }); + settle(); + + faults().fail_tcp_new = 1; + faults().connect_result = ERR_OK; + app("connect", [&] { + return c.connect(kPeer, kPort); + }); + settle(); + + TEST_ASSERT_EQUAL_STRING("", t.violations.c_str()); +} + +// --------------------------------------------------------------------------- +// Calls made from inside callbacks +// --------------------------------------------------------------------------- + +static void test_context_calls_from_inside_callbacks_run_no_callbacks(void) { + // Each handler answers the way an application would. Nothing it calls may run another + // callback -- not even the one it is inside, as write() from onAck would. + AsyncClient c; + Recorder t; + t.attach(c, "ED"); + int acks = 0; + c.onConnect([&](void *, AsyncClient *cl) { + t.note('C', cl); + app("write", [&] { + return cl->write("hello", 5); + }); + }); + c.onData([&](void *, AsyncClient *cl, void *, size_t n) { + t.note('R', cl); + app("ackLater", [&] { + cl->ackLater(); + }); + app("ack", [&] { + return cl->ack(n); + }); + app("write", [&] { + return cl->write("reply", 5); + }); + }); + c.onAck([&](void *, AsyncClient *cl, size_t, uint32_t) { + t.note('A', cl); + if (++acks == 1) { + app("write", [&] { + return cl->write("more", 4); + }); + app("send", [&] { + return cl->send(); + }); + } + }); + c.onPoll([&](void *, AsyncClient *cl) { + t.note('P', cl); + app("write", [&] { + return cl->write("tick", 4); + }); + }); + c.onTimeout([&](void *, AsyncClient *cl, uint32_t) { + t.note('T', cl); + app_close(*cl); + }); + + TEST_ASSERT_TRUE(app("connect", [&] { + return c.connect(kPeer, kPort); + })); + tcp_pcb *pcb = dialled_pcb(); + fire_connected(pcb); + asynctcp_test_pump(); + + fire_sent(pcb, 5); + asynctcp_test_pump(); + fire_sent(pcb, 4); + asynctcp_test_pump(); + mockclock::advance(10); + fire_recv(pcb, "request", 7); + asynctcp_test_pump(); + app("setAckTimeout", [&] { + c.setAckTimeout(100); + }); + fire_poll(pcb); + asynctcp_test_pump(); + + mockclock::advance(150); + fire_poll(pcb); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("CAARPTD", t.seq.c_str()); + TEST_ASSERT_EQUAL_STRING("", t.violations.c_str()); +} + +static void test_context_reconnecting_from_inside_onError_runs_no_callbacks(void) { + AsyncClient c; + Recorder t; + t.attach(c, "CD"); + int errors = 0; + c.onError([&](void *, AsyncClient *cl, int8_t e) { + t.note('E', cl, e); + if (++errors == 1) { + app("connect", [&] { + return cl->connect(kPeer, kPort); + }); + } + }); + tcp_pcb *pcb = establish(c); + + fire_error(pcb, ERR_RST); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("", t.violations.c_str()); + app_close(c); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("", t.violations.c_str()); +} + +// --------------------------------------------------------------------------- +// AsyncServer +// --------------------------------------------------------------------------- + +static void test_context_server_calls_do_not_deliver_a_queued_accept(void) { + AsyncServer s(kServerPort); + Recorder t; + std::vector clients; + auto on_client = [&](void *, AsyncClient *c) { + t.note('S', nullptr); + clients.push_back(c); + }; + app("onClient", [&] { + s.onClient(on_client, nullptr); + }); + app("begin", [&] { + s.begin(); + }); + + fire_accept(listen_pcb()); + fire_accept(listen_pcb()); + app("begin", [&] { + s.begin(); // already listening + }); + app("setNoDelay", [&] { + s.setNoDelay(!s.getNoDelay()); + }); + app("status", [&] { + return s.status(); + }); + app("onClient", [&] { + s.onClient(on_client, nullptr); + }); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("SS", t.seq.c_str()); + TEST_ASSERT_EQUAL_STRING("", t.violations.c_str()); + for (AsyncClient *c : clients) { + app_delete(c); + } + app("end", [&] { + s.end(); + }); +} + +static void test_context_server_teardown_runs_no_onClient(void) { + // What becomes of the accepts left queued is test_server_accept.cpp's business. + Recorder t; + AsyncServer *s = new AsyncServer(kServerPort); + s->onClient( + [&](void *, AsyncClient *) { + t.note('S', nullptr); + }, + nullptr + ); + s->begin(); + + fire_accept(listen_pcb()); + app("end", [&] { + s->end(); + }); + app("begin", [&] { + s->begin(); + }); + fire_accept(listen_pcb()); + app("~AsyncServer", [&] { + delete s; + }); + + TEST_ASSERT_EQUAL_STRING("", t.violations.c_str()); +} + +void run_context_tests(void) { + UnitySetTestFile(__FILE__); + RUN_TEST(test_context_calls_do_not_deliver_a_queued_connect); + RUN_TEST(test_context_calls_do_not_deliver_queued_traffic); + RUN_TEST(test_context_calls_do_not_deliver_queued_packets); + RUN_TEST(test_context_calls_do_not_deliver_a_queued_timeout); + RUN_TEST(test_context_calls_do_not_deliver_a_queued_fin); + RUN_TEST(test_context_calls_do_not_deliver_a_queued_error); + RUN_TEST(test_context_close_with_a_queued_error_runs_no_onError); + RUN_TEST(test_context_abort_with_a_queued_error_runs_no_other_error); + RUN_TEST(test_context_close_runs_nothing_but_onDisconnect); + RUN_TEST(test_context_abort_runs_nothing_but_its_own_error); + RUN_TEST(test_context_destructor_runs_nothing_but_onDisconnect); + RUN_TEST(test_context_connecting_runs_no_callbacks); + RUN_TEST(test_context_calls_from_inside_callbacks_run_no_callbacks); + RUN_TEST(test_context_reconnecting_from_inside_onError_runs_no_callbacks); + RUN_TEST(test_context_server_calls_do_not_deliver_a_queued_accept); + RUN_TEST(test_context_server_teardown_runs_no_onClient); +} From 02c2eeafd4d570db2f7f78dfab974254301af1ef Mon Sep 17 00:00:00 2001 From: Will Miles Date: Wed, 30 Sep 2026 23:42:40 -0400 Subject: [PATCH 18/24] test: Fail the library's allocations in each lwIP callback Co-Authored-By: Claude Opus 5.5 --- test/test_native/README.md | 23 +- test/test_native/fixtures.h | 1 + test/test_native/mocks/include/lwip/tcp.h | 2 + test/test_native/mocks/mock_alloc.cpp | 74 +++ test/test_native/mocks/mock_alloc.h | 28 ++ test/test_native/mocks/mock_lwip.cpp | 49 ++ test/test_native/mocks/mock_lwip.h | 11 +- test/test_native/test_main.cpp | 4 + test/test_native/tests/test_alloc.cpp | 556 ++++++++++++++++++++++ 9 files changed, 744 insertions(+), 4 deletions(-) create mode 100644 test/test_native/mocks/mock_alloc.cpp create mode 100644 test/test_native/mocks/mock_alloc.h create mode 100644 test/test_native/tests/test_alloc.cpp diff --git a/test/test_native/README.md b/test/test_native/README.md index 432274ba..2f7e0f6c 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -129,6 +129,7 @@ test/test_native/ test_server_listen.cpp begin(), end(), status(), restarts test_server_accept.cpp delivering accepted connections test_server_config.cpp setNoDelay() + test_alloc.cpp each lwIP callback when the library cannot allocate mocks/ include/ fake system headers -- on the include path FIRST, so #include "lwip/tcp.h" etc. resolve here @@ -138,9 +139,10 @@ test/test_native/ lwip/priv/tcpip_priv.h mock_lwip.h/.cpp mock lwIP stack + its test-facing control API mock_rtos.h/.cpp FreeRTOS, mock clock, logging, the pump + mock_alloc.h/.cpp nothrow operator new, which tests can make fail ``` -`mock_lwip.h` and `mock_rtos.h` are for tests only; the library never sees them. +`mock_lwip.h`, `mock_rtos.h` and `mock_alloc.h` are for tests only; the library never sees them. ## What the mock lwIP models @@ -166,6 +168,10 @@ on it: * lwIP is built with window scaling, so the send buffer is 32 bits wide and `tcp_sndbuf()` saturates at 65535, as it does in lwIP. `set_send_buffer(pcb, n)` resizes a pcb's. +* Data the recv callback refuses (anything but `ERR_OK` or `ERR_ABRT`) stays on the + pcb and is offered again by `retry_refused()`, or before the next segment; until it + is taken, later segments are dropped. A FIN is reported once, whatever the callback + returns. * A callback handed a pcb must return `ERR_ABRT` if and only if it aborted that pcb, as `tcp_abort()` in lwIP's `tcp.c` requires. An accept callback returning any other error has the new pcb aborted, as `tcp_process()` does. @@ -241,8 +247,8 @@ is what redirects `RUN_TEST` into the forking runner. A new file's never registered, or is not named for its file: tests in `test_topic.cpp` are `test_topic_*`. -`test_main.cpp` calls `mocklwip::reset()`, `mockrtos::reset()` and -`mockclock::reset()` before every test, and `mocklwip::reset()` again after, so +`test_main.cpp` calls `mocklwip::reset()`, `mockrtos::reset()`, `mockclock::reset()` +and `mockalloc::reset()` before every test, and `mocklwip::reset()` again after, so each test starts from zero pcbs, zero pbufs, no faults and `millis() == 1000`. A test about `millis() == 0` sets it with `mockclock::set_millis(0)`. @@ -262,6 +268,7 @@ fire_connected(pcb, ERR_OK); // tcp_connected_fn; moves pcb to ESTABLISHED fire_recv(pcb, "data", 4); // tcp_recv_fn with a pbuf the mock allocates fire_recv_pbuf(pcb, chain); // ... or with a chain you built via make_pbuf() fire_fin(pcb); // tcp_recv_fn with a NULL pbuf +retry_refused(pcb); // offers refused data again fire_sent(pcb, 4); // tcp_sent_fn fire_poll(pcb); // tcp_poll_fn fire_error(pcb, ERR_RST); // frees pcb, then tcp_err_fn -- pcb is dead after @@ -295,6 +302,16 @@ faults().dns_addr = 0x0A000005; // used when dns_result == ERR_OK faults().dns_fails_inside_the_call = true; // with ERR_INPROGRESS: answers NULL, runs the task, then returns ``` +The library allocates with `new (std::nothrow)`; nothing else in the program does, so +`mockalloc` fails only the library's allocations: + +```cpp +mockalloc::fail_next(); // the next one returns nullptr +mockalloc::fail_nth(2); // ... or the one after +mockalloc::fail_nth(3, 2); // ... or the third and fourth +mockalloc::failures() // how many have failed +``` + ### Asserting ```cpp diff --git a/test/test_native/fixtures.h b/test/test_native/fixtures.h index df251335..cd0f047c 100644 --- a/test/test_native/fixtures.h +++ b/test/test_native/fixtures.h @@ -12,6 +12,7 @@ #include #include "AsyncTCP.h" +#include "mocks/mock_alloc.h" #include "mocks/mock_lwip.h" #include "mocks/mock_rtos.h" diff --git a/test/test_native/mocks/include/lwip/tcp.h b/test/test_native/mocks/include/lwip/tcp.h index 5f38df29..8e283ddf 100644 --- a/test/test_native/mocks/include/lwip/tcp.h +++ b/test/test_native/mocks/include/lwip/tcp.h @@ -79,6 +79,8 @@ struct tcp_pcb { tcp_poll_fn poll; tcp_err_fn errf; tcp_accept_fn accept; + + struct pbuf *refused_data; // what the recv callback last refused }; struct tcp_pcb *tcp_new_ip_type(u8_t type); diff --git a/test/test_native/mocks/mock_alloc.cpp b/test/test_native/mocks/mock_alloc.cpp new file mode 100644 index 00000000..e19fb094 --- /dev/null +++ b/test/test_native/mocks/mock_alloc.cpp @@ -0,0 +1,74 @@ +// Replaces the nothrow operator new so tests can make it fail. +// +// A successful allocation goes through the ordinary operator new, so the sanitizers see +// it as one and a later delete matches it. + +#include "mock_alloc.h" + +#include + +namespace { +unsigned g_attempts = 0; +unsigned g_failures = 0; +unsigned g_fail_from = 0; // attempt number of the first failure; 0 when disarmed +unsigned g_fail_count = 0; + +bool should_fail() { + const unsigned n = ++g_attempts; + if (g_fail_from && n >= g_fail_from && n < g_fail_from + g_fail_count) { + g_failures++; + return true; + } + return false; +} + +void *allocate(std::size_t size) noexcept { + if (should_fail()) { + return nullptr; + } + try { + return ::operator new(size); + } catch (...) { + return nullptr; + } +} + +void *allocate_array(std::size_t size) noexcept { + if (should_fail()) { + return nullptr; + } + try { + return ::operator new[](size); + } catch (...) { + return nullptr; + } +} +} // namespace + +void *operator new(std::size_t size, const std::nothrow_t &) noexcept { + return allocate(size); +} + +void *operator new[](std::size_t size, const std::nothrow_t &) noexcept { + return allocate_array(size); +} + +namespace mockalloc { + +void reset() { + g_attempts = 0; + g_failures = 0; + g_fail_from = 0; + g_fail_count = 0; +} + +void fail_nth(unsigned nth, unsigned count) { + g_fail_from = nth ? g_attempts + nth : 0; + g_fail_count = count; +} + +unsigned failures() { + return g_failures; +} + +} // namespace mockalloc diff --git a/test/test_native/mocks/mock_alloc.h b/test/test_native/mocks/mock_alloc.h new file mode 100644 index 00000000..405be398 --- /dev/null +++ b/test/test_native/mocks/mock_alloc.h @@ -0,0 +1,28 @@ +// Test-facing control surface for failing nothrow allocations. +// +// The library allocates with new (std::nothrow) and copes with nullptr; the harness and +// the mocks allocate with plain new. So only the library's allocations are counted and +// failed. +// +// This header is for tests only -- the library never sees it. +#ifndef ASYNCTCP_MOCK_ALLOC_H +#define ASYNCTCP_MOCK_ALLOC_H + +namespace mockalloc { + +// Forgets any armed failure and zeroes the counts. Call between tests. +void reset(); + +// Fails the nothrow allocation from now (1 is the next one), and the - 1 +// after it. +void fail_nth(unsigned nth, unsigned count = 1); +inline void fail_next(unsigned count = 1) { + fail_nth(1, count); +} + +// Nothrow allocations failed since reset(). +unsigned failures(); + +} // namespace mockalloc + +#endif diff --git a/test/test_native/mocks/mock_lwip.cpp b/test/test_native/mocks/mock_lwip.cpp index edf32f9d..9da6369b 100644 --- a/test/test_native/mocks/mock_lwip.cpp +++ b/test/test_native/mocks/mock_lwip.cpp @@ -7,6 +7,7 @@ // * tcp_listen_with_backlog() frees the old pcb and returns a NEW one on // success, and returns NULL *without* freeing on failure // * a fatal error frees the pcb before the error callback sees it +// * data the recv callback refuses stays with lwIP and is offered again // * a callback handed a pcb returns ERR_ABRT exactly when it aborted that pcb // Those are exactly the edges AsyncTCP's lifetime handling gets wrong or right. @@ -109,6 +110,10 @@ void free_pcb(tcp_pcb *pcb) { if (!pcb) { return; } + // tcp_pcb_purge() + if (pcb->refused_data) { + pbuf_free(pcb->refused_data); + } release_port(pcb); g_written.erase(pcb); g_sendq.erase(pcb); @@ -696,6 +701,28 @@ void fire_error(tcp_pcb *pcb, err_t err) { } } +// tcp_process_refused_data() +err_t retry_refused(tcp_pcb *pcb) { + on_lwip_thread lwip; + if (!pcb || !is_live(pcb) || !pcb->refused_data) { + return ERR_OK; + } + pbuf *p = pcb->refused_data; + pcb->refused_data = nullptr; + if (!pcb->recv) { + pbuf_free(p); + return ERR_OK; + } + const size_t mark = g_aborted.size(); + err_t r = pcb->recv(pcb->callback_arg, pcb, p, ERR_OK); + check_return("recv", pcb, mark, r); + if (r != ERR_OK && r != ERR_ABRT && is_live(pcb)) { + pcb->refused_data = p; + return ERR_INPROGRESS; + } + return r; +} + err_t fire_recv_pbuf(tcp_pcb *pcb, pbuf *p, err_t err) { on_lwip_thread lwip; if (!pcb || !is_live(pcb)) { @@ -704,6 +731,17 @@ err_t fire_recv_pbuf(tcp_pcb *pcb, pbuf *p, err_t err) { } return ERR_ARG; } + // tcp_input(): refused data goes first, and while it is still refused a new segment is + // dropped for the peer to send again. + if (pcb->refused_data) { + err_t r = retry_refused(pcb); + if (r != ERR_OK) { + if (p) { + pbuf_free(p); + } + return r; + } + } if (!pcb->recv) { if (p) { pbuf_free(p); @@ -713,6 +751,10 @@ err_t fire_recv_pbuf(tcp_pcb *pcb, pbuf *p, err_t err) { const size_t mark = g_aborted.size(); err_t r = pcb->recv(pcb->callback_arg, pcb, p, err); check_return("recv", pcb, mark, r); + // tcp_input(): anything but ERR_OK or ERR_ABRT leaves the pbuf with lwIP. + if (r != ERR_OK && r != ERR_ABRT && is_live(pcb)) { + pcb->refused_data = p; + } return r; } @@ -725,6 +767,13 @@ err_t fire_fin(tcp_pcb *pcb, err_t err) { if (!pcb || !is_live(pcb)) { return ERR_ARG; } + // As for data, a FIN behind refused data is dropped until that data is taken. + if (pcb->refused_data) { + err_t r = retry_refused(pcb); + if (r != ERR_OK) { + return r; + } + } if (pcb->state == ESTABLISHED) { pcb->state = CLOSE_WAIT; } diff --git a/test/test_native/mocks/mock_lwip.h b/test/test_native/mocks/mock_lwip.h index cc22d1e4..064679d1 100644 --- a/test/test_native/mocks/mock_lwip.h +++ b/test/test_native/mocks/mock_lwip.h @@ -105,11 +105,20 @@ err_t fire_connected(tcp_pcb *pcb, err_t err = 0); void fire_error(tcp_pcb *pcb, err_t err); // Delivers bytes to tcp_recv_fn. Allocates the pbuf for you. +// +// As in lwIP, if the callback returns anything but ERR_OK or ERR_ABRT, the pbuf stays +// with lwIP as the pcb's refused data, to be offered again by retry_refused() or before +// the next segment. Until it is taken, later segments are dropped: the peer sends them +// again. err_t fire_recv(tcp_pcb *pcb, const void *data, size_t len, err_t err = 0); // Delivers an already-built pbuf chain (ownership passes to the library). err_t fire_recv_pbuf(tcp_pcb *pcb, pbuf *p, err_t err = 0); -// Remote FIN: tcp_recv_fn with a NULL pbuf. +// Remote FIN: tcp_recv_fn with a NULL pbuf. lwIP reports it once, whatever the +// callback returns. err_t fire_fin(tcp_pcb *pcb, err_t err = 0); +// Offers refused data again, as lwIP's fast timer does. ERR_OK once it is taken (or if +// there was none), ERR_INPROGRESS if it is refused again. +err_t retry_refused(tcp_pcb *pcb); err_t fire_sent(tcp_pcb *pcb, uint16_t len); err_t fire_poll(tcp_pcb *pcb); diff --git a/test/test_native/test_main.cpp b/test/test_native/test_main.cpp index bfd69231..566edcaa 100644 --- a/test/test_native/test_main.cpp +++ b/test/test_native/test_main.cpp @@ -22,6 +22,7 @@ #include #endif +#include "mocks/mock_alloc.h" #include "mocks/mock_lwip.h" #include "mocks/mock_rtos.h" @@ -55,6 +56,7 @@ void run_dispose_tests(void); void run_lifetime_tests(void); void run_callback_tests(void); void run_context_tests(void); +void run_alloc_tests(void); namespace { @@ -82,6 +84,7 @@ void reset_mocks(void) { mocklwip::reset(); mockrtos::reset(); mockclock::reset(); + mockalloc::reset(); // As on a target that has been up a while. A test about millis() == 0 sets it itself. mockclock::set_millis(1000); } @@ -351,6 +354,7 @@ int main(int argc, char **argv) { run_lifetime_tests(); run_callback_tests(); run_context_tests(); + run_alloc_tests(); if (g_list_only) { return 0; diff --git a/test/test_native/tests/test_alloc.cpp b/test/test_native/tests/test_alloc.cpp new file mode 100644 index 00000000..53cdf598 --- /dev/null +++ b/test/test_native/tests/test_alloc.cpp @@ -0,0 +1,556 @@ +// Running out of memory: each lwIP callback, with the allocation it makes failing. + +#include "fixtures.h" + +#include +#include +#include + +#if defined(__SANITIZE_ADDRESS__) +#include +#endif + +using namespace mocklwip; + +namespace { + +// More allocations than either exchange below makes. +const unsigned kMaxAllocations = 64; + +// Ends one run of an exchange. Under ASan, fails if anything is unreachable. The +// mock's call log holds the pointers the library gave lwIP, which would hide a leaked +// client, so the mock is reset first -- after the checks the runner would otherwise make +// of it at the end of the test. +void assert_nothing_leaked(const char *msg) { + TEST_ASSERT_EQUAL_UINT_MESSAGE(0, mocklwip::unlocked_calls(), mocklwip::first_unlocked_call()); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0, mocklwip::reentrant_api_calls(), msg); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, mocklwip::core_lock_depth(), msg); + TEST_ASSERT_EQUAL_UINT_MESSAGE(0, mocklwip::callback_return_violations(), mocklwip::first_callback_return_violation()); + mocklwip::reset(); +#if defined(__SANITIZE_ADDRESS__) + TEST_ASSERT_FALSE_MESSAGE(__lsan_do_recoverable_leak_check(), msg); +#else + (void)msg; +#endif +} + +// A client's side of one exchange: it answers the request with a reply. +void answer(AsyncClient &c, Recorder &t) { + t.attach(c, "CEDA"); + c.onData([&t](void *, AsyncClient *cl, void *d, size_t n) { + t.seq += 'R'; + t.data.append((const char *)d, n); + cl->write("reply", 5); + }); +} + +// The peer's side: a request, an ack for the reply, then it hangs up. +void exchange(tcp_pcb *pcb) { + fire_recv(pcb, "request", 7); + asynctcp_test_pump(); + retry_refused(pcb); + asynctcp_test_pump(); + fire_sent(pcb, 5); + asynctcp_test_pump(); + fire_fin(pcb); + asynctcp_test_pump(); + fire_poll(pcb); + asynctcp_test_pump(); +} + +// Fails unless a connect or lookup the application was told is under way has ended: +// onConnect, or one error report. +void assert_connect_concluded(const Recorder &t) { + const std::string msg = "callbacks were \"" + t.seq + "\""; + TEST_ASSERT_TRUE_MESSAGE(t.seq == "C" || t.seq == "ED", msg.c_str()); +} + +} // namespace + +// --------------------------------------------------------------------------- +// Connection events +// --------------------------------------------------------------------------- + +static void test_alloc_connected_still_concludes_the_connect(void) { + // lwIP ignores what the connected callback returns unless it is ERR_ABRT + // (tcp_process), so it will not say again that the connection is up. It still polls + // the connection. + AsyncClient *c = new AsyncClient(); + Recorder t; + t.attach(*c); + TEST_ASSERT_TRUE(c->connect(kPeer, kPort)); + tcp_pcb *pcb = dialled_pcb(); + + mockalloc::fail_next(); + fire_connected(pcb); + asynctcp_test_pump(); + fire_poll(pcb); + asynctcp_test_pump(); + + assert_connect_concluded(t); + + delete c; + TEST_ASSERT_EQUAL_INT(1, t.count('D')); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_alloc_recv_is_delivered_once_lwip_offers_it_again(void) { + AsyncClient c; + Recorder t; + t.attach(c, "CEDR"); + tcp_pcb *pcb = establish(c); + + mockalloc::fail_next(); + fire_recv(pcb, "hello", 5); + asynctcp_test_pump(); + retry_refused(pcb); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("CR", t.seq.c_str()); + TEST_ASSERT_EQUAL_STRING("hello", t.data.c_str()); + TEST_ASSERT_EQUAL_size_t(5, recved(pcb)); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + + c.close(); +} + +static void test_alloc_recv_of_a_chain_is_delivered_whole_once(void) { + AsyncClient c; + Recorder t; + t.attach(c, "CEDR"); + tcp_pcb *pcb = establish(c); + pbuf *chain = make_pbuf("aaa", 3); + pbuf_cat(chain, make_pbuf("bb", 2)); + + mockalloc::fail_next(); + fire_recv_pbuf(pcb, chain); + asynctcp_test_pump(); + retry_refused(pcb); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("aaabb", t.data.c_str()); + TEST_ASSERT_EQUAL_size_t(5, recved(pcb)); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + + c.close(); +} + +static void test_alloc_recv_keeps_its_place_ahead_of_later_data(void) { + // lwIP offers the refused data again before the next segment. + AsyncClient c; + Recorder t; + t.attach(c, "CEDR"); + tcp_pcb *pcb = establish(c); + + mockalloc::fail_next(); + fire_recv(pcb, "first ", 6); + asynctcp_test_pump(); + fire_recv(pcb, "second", 6); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("first second", t.data.c_str()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); + + c.close(); +} + +static void test_alloc_recv_then_fin_delivers_the_data_then_the_disconnect(void) { + AsyncClient c; + Recorder t; + t.attach(c, "CEDR"); + tcp_pcb *pcb = establish(c); + + mockalloc::fail_next(); + fire_recv(pcb, "last", 4); + fire_fin(pcb); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("CRD", t.seq.c_str()); + TEST_ASSERT_EQUAL_STRING("last", t.data.c_str()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_alloc_recv_then_close_leaks_nothing(void) { + AsyncClient c; + Recorder t; + t.attach(c, "CEDR"); + tcp_pcb *pcb = establish(c); + + mockalloc::fail_next(); + fire_recv(pcb, "unread", 6); + asynctcp_test_pump(); + c.close(); + asynctcp_test_pump(); + + // The data may be dropped, but only whole. + TEST_ASSERT_EQUAL_STRING(t.data.empty() ? "CD" : "CRD", t.seq.c_str()); + TEST_ASSERT_TRUE(t.data.empty() || t.data == "unread"); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_alloc_recv_then_reset_leaks_nothing(void) { + AsyncClient c; + Recorder t; + t.attach(c, "CEDR"); + tcp_pcb *pcb = establish(c); + + mockalloc::fail_next(); + fire_recv(pcb, "unread", 6); + fire_error(pcb, ERR_RST); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("CED", t.seq.c_str()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_alloc_recv_then_destroy_leaks_nothing(void) { + AsyncClient *c = new AsyncClient(); + Recorder t; + t.attach(*c, "CEDR"); + tcp_pcb *pcb = establish(*c); + + mockalloc::fail_next(); + fire_recv(pcb, "unread", 6); + delete c; + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("CD", t.seq.c_str()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_alloc_fin_still_ends_the_connection_once(void) { + // lwIP reports a FIN once, whatever the callback returns (tcp_input), so the close has + // to be noticed some other way; lwIP keeps polling the half-closed connection. + AsyncClient c; + Recorder t; + t.attach(c); + tcp_pcb *pcb = establish(c); + + mockalloc::fail_next(); + fire_fin(pcb); + asynctcp_test_pump(); + fire_poll(pcb); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("CD", t.seq.c_str()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_alloc_fin_leaves_a_client_that_closes_cleanly(void) { + AsyncClient *c = new AsyncClient(); + Recorder t; + t.attach(*c); + tcp_pcb *pcb = establish(*c); + + mockalloc::fail_next(); + fire_fin(pcb); + asynctcp_test_pump(); + + delete c; + TEST_ASSERT_EQUAL_INT(1, t.count('D')); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_alloc_sent_leaves_later_acks_flowing(void) { + AsyncClient c; + Recorder t; + t.attach(c, "CEDA"); + tcp_pcb *pcb = establish(c); + TEST_ASSERT_EQUAL_size_t(6, c.write("abcdef", 6)); + + mockalloc::fail_next(); + fire_sent(pcb, 2); + asynctcp_test_pump(); + + fire_sent(pcb, 4); + asynctcp_test_pump(); + TEST_ASSERT_TRUE(t.count('A') >= 1); + TEST_ASSERT_EQUAL_size_t(4, t.acked); + + c.close(); + TEST_ASSERT_EQUAL_INT(1, t.count('D')); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_alloc_poll_leaves_later_polls_flowing(void) { + AsyncClient c; + Recorder t; + t.attach(c, "CEDP"); + tcp_pcb *pcb = establish(c); + + mockalloc::fail_next(); + fire_poll(pcb); + asynctcp_test_pump(); + const int before = t.count('P'); + + fire_poll(pcb); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_INT(before + 1, t.count('P')); + + c.close(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_alloc_poll_still_times_out_an_idle_connection(void) { + AsyncClient c; + Recorder t; + t.attach(c); + tcp_pcb *pcb = establish(c); + c.setRxTimeout(1); + mockclock::advance(1500); + + mockalloc::fail_next(); + fire_poll(pcb); + asynctcp_test_pump(); + + fire_poll(pcb); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("CD", t.seq.c_str()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +// --------------------------------------------------------------------------- +// Errors: the pcb is gone, and lwIP will never call again +// --------------------------------------------------------------------------- + +static void test_alloc_error_is_still_reported(void) { + AsyncClient c; + Recorder t; + t.attach(c); + tcp_pcb *pcb = establish(c); + + mockalloc::fail_next(); + fire_error(pcb, ERR_RST); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("CED", t.seq.c_str()); + TEST_ASSERT_EQUAL_INT(ERR_RST, t.err); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_alloc_error_while_connecting_is_still_reported(void) { + AsyncClient c; + Recorder t; + t.attach(c); + TEST_ASSERT_TRUE(c.connect(kPeer, kPort)); + tcp_pcb *pcb = dialled_pcb(); + + mockalloc::fail_next(); + fire_error(pcb, ERR_RST); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("ED", t.seq.c_str()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_alloc_error_then_destroy_reports_once(void) { + AsyncClient *c = new AsyncClient(); + Recorder t; + t.attach(*c); + tcp_pcb *pcb = establish(*c); + + mockalloc::fail_next(); + fire_error(pcb, ERR_RST); + asynctcp_test_pump(); + delete c; + + TEST_ASSERT_EQUAL_INT(1, t.count('D')); + TEST_ASSERT_TRUE(t.count('E') <= 1); +} + +// --------------------------------------------------------------------------- +// Accepts: nothing is owed to the application until onClient runs +// --------------------------------------------------------------------------- + +static void test_alloc_accept_without_a_client_drops_the_connection(void) { + AsyncServer s(kServerPort); + Accepted accepted(s); + s.begin(); + + mockalloc::fail_next(); + tcp_pcb *conn = fire_accept(listen_pcb()); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(0, accepted.size()); + TEST_ASSERT_FALSE(is_live(conn)); + + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_alloc_accept_without_an_event_drops_the_connection(void) { + // The client is allocated, the event announcing it is not. + AsyncServer s(kServerPort); + Accepted accepted(s); + s.begin(); + + mockalloc::fail_nth(2); + tcp_pcb *conn = fire_accept(listen_pcb()); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(0, accepted.size()); + TEST_ASSERT_FALSE(is_live(conn)); + + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_alloc_accept_failure_leaves_later_accepts_working(void) { + AsyncServer s(kServerPort); + Accepted accepted(s); + s.begin(); + + mockalloc::fail_next(); + fire_accept(listen_pcb()); + asynctcp_test_pump(); + + tcp_pcb *conn = fire_accept(listen_pcb()); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(1, accepted.size()); + TEST_ASSERT_EQUAL_PTR(conn, accepted[0]->pcb()); + + accepted.clear(); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +// --------------------------------------------------------------------------- +// Lookups: lwIP calls back once, and connect() has already said yes +// --------------------------------------------------------------------------- + +static void test_alloc_dns_answer_still_concludes_the_connect(void) { + faults().dns_result = ERR_INPROGRESS; + AsyncClient *c = new AsyncClient(); + Recorder t; + t.attach(*c); + TEST_ASSERT_TRUE(c->connect("example.test", kPort)); + + mockalloc::fail_next(); + fire_dns(kResolved); + asynctcp_test_pump(); + tcp_pcb *pcb = dialled_pcb(); + if (pcb) { + fire_connected(pcb); + asynctcp_test_pump(); + } + + assert_connect_concluded(t); + + delete c; + TEST_ASSERT_EQUAL_INT(1, t.count('D')); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_alloc_dns_failure_is_still_reported(void) { + faults().dns_result = ERR_INPROGRESS; + AsyncClient c; + Recorder t; + t.attach(c); + TEST_ASSERT_TRUE(c.connect("example.test", kPort)); + + mockalloc::fail_next(); + fire_dns_failure(); + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_STRING("ED", t.seq.c_str()); +} + +// --------------------------------------------------------------------------- +// Every allocation in a whole exchange, failed one at a time +// --------------------------------------------------------------------------- + +static void test_alloc_any_failure_in_a_client_exchange_is_survived(void) { + for (unsigned k = 1; k <= kMaxAllocations; k++) { + mockalloc::reset(); + mockalloc::fail_nth(k); + + Recorder t; + AsyncClient *c = new AsyncClient(); + answer(*c, t); + const bool promised = c->connect(kPeer, kPort); + tcp_pcb *pcb = dialled_pcb(); + fire_connected(pcb); + asynctcp_test_pump(); + fire_poll(pcb); + asynctcp_test_pump(); + exchange(pcb); + delete c; + + char msg[64]; + snprintf(msg, sizeof(msg), "allocation %u failed", k); + TEST_ASSERT_EQUAL_size_t_MESSAGE(0, live_pcbs(), msg); + TEST_ASSERT_EQUAL_size_t_MESSAGE(0, live_pbufs(), msg); + TEST_ASSERT_EQUAL_INT_MESSAGE(promised ? 1 : 0, t.count('D'), msg); + assert_nothing_leaked(msg); + if (mockalloc::failures() == 0) { + return; // the exchange made fewer than k allocations + } + } + TEST_FAIL_MESSAGE("the exchange never ran without a failure"); +} + +static void test_alloc_any_failure_in_a_server_exchange_is_survived(void) { + for (unsigned k = 1; k <= kMaxAllocations; k++) { + mockalloc::reset(); + mockalloc::fail_nth(k); + + std::deque clients; + { + AsyncServer s(kServerPort); + Accepted accepted(s, [&](AsyncClient *c) { + clients.emplace_back(); + answer(*c, clients.back()); + }); + s.begin(); + tcp_pcb *pcb = fire_accept(listen_pcb(kServerPort)); + asynctcp_test_pump(); + exchange(pcb); + accepted.clear(); + s.end(); + } + + char msg[64]; + snprintf(msg, sizeof(msg), "allocation %u failed", k); + TEST_ASSERT_EQUAL_size_t_MESSAGE(0, live_pcbs(), msg); + TEST_ASSERT_EQUAL_size_t_MESSAGE(0, live_pbufs(), msg); + TEST_ASSERT_TRUE_MESSAGE(clients.size() <= 1, msg); + for (const Recorder &t : clients) { + TEST_ASSERT_EQUAL_INT_MESSAGE(1, t.count('D'), msg); + } + assert_nothing_leaked(msg); + if (mockalloc::failures() == 0) { + return; + } + } + TEST_FAIL_MESSAGE("the exchange never ran without a failure"); +} + +void run_alloc_tests(void) { + UnitySetTestFile(__FILE__); + RUN_TEST(test_alloc_connected_still_concludes_the_connect); + RUN_TEST(test_alloc_recv_is_delivered_once_lwip_offers_it_again); + RUN_TEST(test_alloc_recv_of_a_chain_is_delivered_whole_once); + RUN_TEST(test_alloc_recv_keeps_its_place_ahead_of_later_data); + RUN_TEST(test_alloc_recv_then_fin_delivers_the_data_then_the_disconnect); + RUN_TEST(test_alloc_recv_then_close_leaks_nothing); + RUN_TEST(test_alloc_recv_then_reset_leaks_nothing); + RUN_TEST(test_alloc_recv_then_destroy_leaks_nothing); + RUN_TEST(test_alloc_fin_still_ends_the_connection_once); + RUN_TEST(test_alloc_fin_leaves_a_client_that_closes_cleanly); + RUN_TEST(test_alloc_sent_leaves_later_acks_flowing); + RUN_TEST(test_alloc_poll_leaves_later_polls_flowing); + RUN_TEST(test_alloc_poll_still_times_out_an_idle_connection); + RUN_TEST(test_alloc_error_is_still_reported); + RUN_TEST(test_alloc_error_while_connecting_is_still_reported); + RUN_TEST(test_alloc_error_then_destroy_reports_once); + RUN_TEST(test_alloc_accept_without_a_client_drops_the_connection); + RUN_TEST(test_alloc_accept_without_an_event_drops_the_connection); + RUN_TEST(test_alloc_accept_failure_leaves_later_accepts_working); + RUN_TEST(test_alloc_dns_answer_still_concludes_the_connect); + RUN_TEST(test_alloc_dns_failure_is_still_reported); + RUN_TEST(test_alloc_any_failure_in_a_client_exchange_is_survived); + RUN_TEST(test_alloc_any_failure_in_a_server_exchange_is_survived); +} From 9765e38c1a26572eb4ae9b9c26c41bbf06dba479 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Wed, 30 Sep 2026 23:40:43 -0400 Subject: [PATCH 19/24] test: Run the suite against IPv4-only lwIP and without core locking Co-Authored-By: Claude Opus 5.5 --- platformio.ini | 15 ++++++++++++ test/test_native/README.md | 19 ++++++++++++--- .../test_native/mocks/include/lwip/ip6_addr.h | 4 ++++ test/test_native/mocks/include/lwip/ip_addr.h | 24 +++++++++++++++++-- test/test_native/mocks/include/lwip/opt.h | 12 ++++++++-- .../mocks/include/lwip/priv/tcpip_priv.h | 8 +++++-- test/test_native/mocks/include/lwip/tcpip.h | 5 ++++ test/test_native/mocks/include/sdkconfig.h | 3 +++ test/test_native/mocks/mock_lwip.cpp | 6 +++++ test/test_native/mocks/mock_lwip.h | 2 +- test/test_native/test_main.cpp | 2 ++ 11 files changed, 90 insertions(+), 10 deletions(-) diff --git a/platformio.ini b/platformio.ini index 3a59de6c..6e4e8abb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -96,6 +96,8 @@ custom_versions.freertos = 9.0.0 ; ; pio test -e native ; pio test -e native-asan +; pio test -e native-no-ipv6 +; pio test -e native-no-core-locking [env:native] platform = native @@ -136,3 +138,16 @@ build_flags = -fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer + +; LwIP built for IPv4 only, as LibreTiny builds it: ip_addr_t is then plain ip4_addr_t. +[env:native-no-ipv6] +extends = env:native +build_flags = + ${env:native.build_flags} + -D LWIP_IPV6=0 + +; LwIP without core locking, as ESP-IDF builds it by default and LibreTiny always +; does: raw tcp_* calls are then safe only on the LwIP thread. +[env:native-no-core-locking] +extends = env:native +build_unflags = -D CONFIG_LWIP_TCPIP_CORE_LOCKING=1 diff --git a/test/test_native/README.md b/test/test_native/README.md index 2f7e0f6c..77756b4c 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -11,9 +11,13 @@ Nothing under `src/` is modified or needs to be. ``` pio test -e native # the suite pio test -e native-asan # ... under ASan/UBSan, with leak detection +pio test -e native-no-ipv6 # ... against lwIP built for IPv4 only +pio test -e native-no-core-locking # ... against lwIP without core locking ``` -Each reports each test separately and exits non-zero if any fails. +Each reports each test separately and exits non-zero if any fails. The two +lwIP configurations change address layout and locking, not who owns what, so +they run without the sanitizers. To work under a debugger, run the built program directly; `ASYNCTCP_TEST_NOFORK=1` keeps every test in one process: @@ -179,7 +183,9 @@ on it: leaked listen pcb shows up as a leaked port. `tcpip_api_call(fn, msg)` calls `fn(msg)` directly on the calling thread. With -`CONFIG_LWIP_TCPIP_CORE_LOCKING` that is what lwIP does, under the core lock. +`CONFIG_LWIP_TCPIP_CORE_LOCKING` that is what lwIP does, under the core lock; +without it, lwIP runs `fn` on its own thread, and there is no core lock at all, +so `LOCK_TCPIP_CORE()` is empty and the raw API is safe only on the lwIP thread. Inside `tcpip_api_call()` and the `fire_*` helpers the mock counts as the lwIP thread. After every test the runner fails it if the library: @@ -195,6 +201,9 @@ thread. After every test the runner fails it if the library: Addresses are kept in **host** byte order throughout, which keeps `IPAddress(10,0,0,1)` and `remote_ip4(pcb)` directly comparable. +`LWIP_IPV6` defaults to 1; with it 0, as LibreTiny builds lwIP, `ip_addr_t` is +plain `ip4_addr_t` with no `type` or `u_addr`. + ## Adding a test Add it to the file for its topic, or drop a new `.cpp` in `tests/`; PlatformIO @@ -229,6 +238,10 @@ Include `fixtures.h`, or at least `runner.h`, rather than ``: `runner.h is what redirects `RUN_TEST` into the forking runner. A new file's `run_*_tests()` has to be declared and called in `test_main.cpp`. +Every configuration builds every test, so a test about IPv6 goes inside +`#if LWIP_IPV6`, and addresses are read through `remote_ip4(pcb)` rather than +the fields of `ip_addr_t`, whose layout depends on it. + `fixtures.h` holds what most tests start with: * `kPeer`, `kPort`, `kServerPort`, `kResolved`: where clients dial, where @@ -324,7 +337,7 @@ count("tcp_close", pcb) recved(pcb) // total bytes handed to tcp_recved(): the window reopened saw("tcp_recved", pcb, 42) // called on this pcb with first arg 42 written(pcb) // everything handed to tcp_write(), concatenated -remote_ip4(pcb) // the pcb's remote IPv4 address +remote_ip4(pcb) // the pcb's remote IPv4 address, with or without IPv6 mockrtos::deadlocks() // non-recursive mutex re-taken -- a deadlock on target mockclock::advance(1500) // move millis() forward ``` diff --git a/test/test_native/mocks/include/lwip/ip6_addr.h b/test/test_native/mocks/include/lwip/ip6_addr.h index d040e5fb..64f94554 100644 --- a/test/test_native/mocks/include/lwip/ip6_addr.h +++ b/test/test_native/mocks/include/lwip/ip6_addr.h @@ -5,6 +5,8 @@ #include "lwip/arch.h" #include "lwip/opt.h" +#if LWIP_IPV6 + #ifdef __cplusplus extern "C" { #endif @@ -24,4 +26,6 @@ typedef struct ip6_addr ip6_addr_t; } #endif +#endif // LWIP_IPV6 + #endif diff --git a/test/test_native/mocks/include/lwip/ip_addr.h b/test/test_native/mocks/include/lwip/ip_addr.h index f589b53a..eb38fae9 100644 --- a/test/test_native/mocks/include/lwip/ip_addr.h +++ b/test/test_native/mocks/include/lwip/ip_addr.h @@ -1,6 +1,6 @@ // Host-native mock of lwip/ip_addr.h. -// As in lwIP built with both IPv4 and IPv6, ip_addr_t is a tagged union of the -// two families. +// As in lwIP, ip_addr_t is a tagged union of the two families when both are +// enabled, and plain ip4_addr_t when only IPv4 is. #ifndef MOCK_LWIP_IP_ADDR_H #define MOCK_LWIP_IP_ADDR_H @@ -19,6 +19,8 @@ enum lwip_ip_addr_type { IPADDR_TYPE_ANY = 46U }; +#if LWIP_IPV4 && LWIP_IPV6 + typedef struct ip_addr { union { ip6_addr_t ip6; @@ -57,6 +59,24 @@ typedef struct ip_addr { #define IPADDR6_INIT(a, b, c, d) \ { {{{a, b, c, d}}}, IPADDR_TYPE_V6 } +#else // LWIP_IPV4 && LWIP_IPV6 + +typedef ip4_addr_t ip_addr_t; + +#define IP_SET_TYPE_VAL(ipaddr, iptype) +#define IP_SET_TYPE(ipaddr, iptype) + +#define ip_2_ip4(ipaddr) (ipaddr) +#define ip_addr_get_ip4_u32(ipaddr) ip4_addr_get_u32(ip_2_ip4(ipaddr)) +#define ip_addr_set_ip4_u32(ipaddr, val) ip4_addr_set_u32(ip_2_ip4(ipaddr), val) +#define ip_addr_set_ip4_u32_val(ipaddr, val) ip_addr_set_ip4_u32(&(ipaddr), val) +#define ip_addr_isany_val(ipaddr) ip4_addr_isany_val(ipaddr) + +#define IPADDR4_INIT(u32val) \ + { u32val } + +#endif // LWIP_IPV4 && LWIP_IPV6 + #ifdef __cplusplus } #endif diff --git a/test/test_native/mocks/include/lwip/opt.h b/test/test_native/mocks/include/lwip/opt.h index 6d588e1b..470a7959 100644 --- a/test/test_native/mocks/include/lwip/opt.h +++ b/test/test_native/mocks/include/lwip/opt.h @@ -4,8 +4,10 @@ #include "lwip/arch.h" -#define LWIP_IPV4 1 -#define LWIP_IPV6 1 +#define LWIP_IPV4 1 +#ifndef LWIP_IPV6 +#define LWIP_IPV6 1 +#endif #define TCP_MSS 1436 #define TCP_SND_BUF (4 * TCP_MSS) #define TCP_SND_QUEUELEN ((4 * (TCP_SND_BUF) + (TCP_MSS - 1)) / (TCP_MSS)) @@ -15,6 +17,12 @@ #ifndef LWIP_WND_SCALE #define LWIP_WND_SCALE 1 #endif + +// As ESP-IDF's lwipopts.h derives it. +#ifdef CONFIG_LWIP_TCPIP_CORE_LOCKING #define LWIP_TCPIP_CORE_LOCKING 1 +#else +#define LWIP_TCPIP_CORE_LOCKING 0 +#endif #endif diff --git a/test/test_native/mocks/include/lwip/priv/tcpip_priv.h b/test/test_native/mocks/include/lwip/priv/tcpip_priv.h index de8360a5..3ed7ff73 100644 --- a/test/test_native/mocks/include/lwip/priv/tcpip_priv.h +++ b/test/test_native/mocks/include/lwip/priv/tcpip_priv.h @@ -1,7 +1,7 @@ // Host-native mock of lwip/priv/tcpip_priv.h. // -// tcpip_api_call() invokes the callback on the calling thread, which keeps the -// library deterministic and single-threaded under test. +// tcpip_api_call() invokes the callback on the calling thread in either flavor, +// which keeps the library deterministic and single-threaded under test. #ifndef MOCK_LWIP_TCPIP_PRIV_H #define MOCK_LWIP_TCPIP_PRIV_H @@ -13,7 +13,11 @@ extern "C" { #endif struct tcpip_api_call_data { +#if LWIP_TCPIP_CORE_LOCKING u8_t dummy; +#else + err_t err; // no sem: ESP-IDF sets LWIP_NETCONN_SEM_PER_THREAD +#endif }; typedef err_t (*tcpip_api_call_fn)(struct tcpip_api_call_data *call); diff --git a/test/test_native/mocks/include/lwip/tcpip.h b/test/test_native/mocks/include/lwip/tcpip.h index 00a822cb..d589340e 100644 --- a/test/test_native/mocks/include/lwip/tcpip.h +++ b/test/test_native/mocks/include/lwip/tcpip.h @@ -13,6 +13,7 @@ extern "C" { int sys_thread_tcpip(int type); +#if LWIP_TCPIP_CORE_LOCKING // The mock is single-threaded: the "TCPIP core lock" is a plain recursion // counter so the library's lock/unlock bookkeeping still balances. void mock_lock_tcpip_core(void); @@ -20,6 +21,10 @@ void mock_unlock_tcpip_core(void); #define LOCK_TCPIP_CORE() mock_lock_tcpip_core() #define UNLOCK_TCPIP_CORE() mock_unlock_tcpip_core() +#else +#define LOCK_TCPIP_CORE() +#define UNLOCK_TCPIP_CORE() +#endif #ifdef __cplusplus } diff --git a/test/test_native/mocks/include/sdkconfig.h b/test/test_native/mocks/include/sdkconfig.h index cf6e4e6b..cbef7194 100644 --- a/test/test_native/mocks/include/sdkconfig.h +++ b/test/test_native/mocks/include/sdkconfig.h @@ -2,6 +2,9 @@ #ifndef MOCK_SDKCONFIG_H #define MOCK_SDKCONFIG_H +// CONFIG_LWIP_TCPIP_CORE_LOCKING comes from the build flags, so an environment can +// leave it out. + #define CONFIG_FREERTOS_UNICORE 1 #define CONFIG_ESP_TASK_WDT_TIMEOUT_S 5 diff --git a/test/test_native/mocks/mock_lwip.cpp b/test/test_native/mocks/mock_lwip.cpp index 9da6369b..15878b32 100644 --- a/test/test_native/mocks/mock_lwip.cpp +++ b/test/test_native/mocks/mock_lwip.cpp @@ -490,6 +490,10 @@ extern "C" err_t tcpip_api_call(tcpip_api_call_fn fn, struct tcpip_api_call_data ++g_on_lwip_thread; err_t r = fn(call); --g_on_lwip_thread; +#if !LWIP_TCPIP_CORE_LOCKING + // The LwIP thread ran fn and posts its result back. + call->err = r; +#endif return r; } @@ -841,9 +845,11 @@ tcp_pcb *fire_accept(tcp_pcb *listen_pcb, const ip_addr_t &peer, uint16_t port, tcp_pcb *newpcb = alloc_pcb(); newpcb->state = ESTABLISHED; newpcb->local_ip = listen_pcb->local_ip; +#if LWIP_IPV6 if (listen_pcb->local_ip.type == IPADDR_TYPE_ANY) { newpcb->local_ip.type = peer.type; // a dual-stack listener answers in the peer's family } +#endif newpcb->local_port = listen_pcb->local_port; newpcb->remote_ip = peer; newpcb->remote_port = port; diff --git a/test/test_native/mocks/mock_lwip.h b/test/test_native/mocks/mock_lwip.h index 064679d1..8889fe23 100644 --- a/test/test_native/mocks/mock_lwip.h +++ b/test/test_native/mocks/mock_lwip.h @@ -56,7 +56,7 @@ bool port_is_bound(uint16_t port); // Everything a pcb was handed to tcp_write(), in order, kept after tcp_close(). std::string written(const tcp_pcb *pcb); -// A pcb's remote IPv4 address. +// A pcb's remote IPv4 address, whether or not lwIP is built with IPv6. inline uint32_t remote_ip4(const tcp_pcb *pcb) { return ip_addr_get_ip4_u32(&pcb->remote_ip); } diff --git a/test/test_native/test_main.cpp b/test/test_native/test_main.cpp index 566edcaa..ce646bb7 100644 --- a/test/test_native/test_main.cpp +++ b/test/test_native/test_main.cpp @@ -2,6 +2,8 @@ // // pio test -e native run everything // pio test -e native-asan ... under ASan/UBSan +// pio test -e native-no-ipv6 ... against IPv4-only lwIP +// pio test -e native-no-core-locking ... without the TCPIP core lock // ASYNCTCP_TEST_VERBOSE=1 also print the library's log_* output // ASYNCTCP_TEST_NOFORK=1 run in-process, for a debugger // From 44d84500255ae2419de40a78f6e9c7da3e4873c8 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Wed, 30 Sep 2026 23:43:49 -0400 Subject: [PATCH 20/24] test: Flood the event queue across one and several connections Co-Authored-By: Claude Opus 5.5 --- test/test_native/README.md | 1 + test/test_native/test_main.cpp | 2 + test/test_native/tests/test_congestion.cpp | 295 +++++++++++++++++++++ 3 files changed, 298 insertions(+) create mode 100644 test/test_native/tests/test_congestion.cpp diff --git a/test/test_native/README.md b/test/test_native/README.md index 77756b4c..95f85072 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -134,6 +134,7 @@ test/test_native/ test_server_accept.cpp delivering accepted connections test_server_config.cpp setNoDelay() test_alloc.cpp each lwIP callback when the library cannot allocate + test_congestion.cpp far more events queued than the queue is sized for mocks/ include/ fake system headers -- on the include path FIRST, so #include "lwip/tcp.h" etc. resolve here diff --git a/test/test_native/test_main.cpp b/test/test_native/test_main.cpp index ce646bb7..a45b18d0 100644 --- a/test/test_native/test_main.cpp +++ b/test/test_native/test_main.cpp @@ -59,6 +59,7 @@ void run_lifetime_tests(void); void run_callback_tests(void); void run_context_tests(void); void run_alloc_tests(void); +void run_congestion_tests(void); namespace { @@ -357,6 +358,7 @@ int main(int argc, char **argv) { run_callback_tests(); run_context_tests(); run_alloc_tests(); + run_congestion_tests(); if (g_list_only) { return 0; diff --git a/test/test_native/tests/test_congestion.cpp b/test/test_native/tests/test_congestion.cpp new file mode 100644 index 00000000..8fe4d206 --- /dev/null +++ b/test/test_native/tests/test_congestion.cpp @@ -0,0 +1,295 @@ +// A flooded event queue: many more events waiting than CONFIG_ASYNC_TCP_QUEUE_SIZE. +// Polls may be dropped or merged, and acks merged and moved among the data; nothing else +// may be dropped, repeated or reordered within a connection. + +#include "fixtures.h" + +#include +#include +#include +#include + +using namespace mocklwip; + +namespace { + +// Far more events than the queue is sized for. +const int kRounds = 4 * CONFIG_ASYNC_TCP_QUEUE_SIZE; + +// Records like Recorder, and keeps every onData and every ack length. +struct Flooded { + Recorder t; + std::vector got; // each onData + std::vector acks; // each onAck + std::vector sent; // what the peer sent, in order + size_t acked = 0; // ... and how much of ours it acked + + void attach(AsyncClient &c) { + t.attach(c, "CEDRP"); + c.onData([this](void *, AsyncClient *, void *d, size_t n) { + t.seq += 'R'; + t.data.append((const char *)d, n); + got.push_back(std::string((const char *)d, n)); + }); + c.onAck([this](void *, AsyncClient *, size_t n, uint32_t) { + t.seq += 'A'; + acks.push_back(n); + }); + } + + // The callbacks other than onPoll, in order. + std::string without_polls() const { + std::string s; + for (char e : t.seq) { + if (e != 'P') { + s += e; + } + } + return s; + } + + // Every chunk arrived once and in order, the acks sum to what was acked, and the + // connection opened first and ended with . + void check_lossless(const std::string &end, const char *msg) const { + TEST_ASSERT_TRUE_MESSAGE(got == sent, msg); + size_t sum = 0; + for (size_t n : acks) { + TEST_ASSERT_NOT_EQUAL_MESSAGE(0, n, msg); + sum += n; + } + TEST_ASSERT_EQUAL_size_t_MESSAGE(acked, sum, msg); + const std::string s = without_polls(); + const size_t traffic = got.size() + acks.size(); + TEST_ASSERT_EQUAL_size_t_MESSAGE(1 + traffic + end.size(), s.size(), msg); + TEST_ASSERT_EQUAL_CHAR_MESSAGE('C', s[0], msg); + TEST_ASSERT_EQUAL_STRING_MESSAGE(end.c_str(), s.substr(1 + traffic).c_str(), msg); + } +}; + +// The i-th chunk a peer sends: varied in length so a dropped or repeated one shows. +std::string chunk(int i) { + char buf[16]; + snprintf(buf, sizeof(buf), "<%d>", i); + return std::string(buf) + std::string(i % 7, (char)('a' + i % 26)); +} + +// One round of traffic on one connection: data, an ack, a poll. +void round(tcp_pcb *pcb, Flooded &f, int i) { + std::string d = chunk(i); + fire_recv(pcb, d.data(), d.size()); + f.sent.push_back(d); + const uint16_t n = (uint16_t)(1 + i % 13); + fire_sent(pcb, n); + f.acked += n; + fire_poll(pcb); +} + +} // namespace + +static void test_congestion_one_connection_loses_nothing_but_polls(void) { + AsyncClient c; + Flooded f; + f.attach(c); + tcp_pcb *pcb = establish(c); + + for (int i = 0; i < kRounds; i++) { + round(pcb, f, i); + } + asynctcp_test_pump(); + size_t bytes = 0; + for (const std::string &d : f.sent) { + bytes += d.size(); + } + TEST_ASSERT_EQUAL_size_t(bytes, recved(pcb)); + + for (int i = 0; i < kRounds; i++) { + fire_poll(pcb); + } + fire_fin(pcb); + asynctcp_test_pump(); + + f.check_lossless("D", ""); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_congestion_several_connections_each_lose_nothing_but_polls(void) { + // Interleaved, and each ends differently: the peer closes, the peer resets, the + // application closes, the application destroys the client. + const int kClients = 4; + std::deque f(kClients); + AsyncClient *c[kClients]; + tcp_pcb *pcb[kClients]; + for (int k = 0; k < kClients; k++) { + c[k] = new AsyncClient(); + f[k].attach(*c[k]); + pcb[k] = establish(*c[k]); + } + + for (int i = 0; i < kRounds; i++) { + for (int k = 0; k < kClients; k++) { + round(pcb[k], f[k], i * kClients + k); + } + } + fire_fin(pcb[0]); + asynctcp_test_pump(); + fire_error(pcb[1], ERR_RST); + asynctcp_test_pump(); + c[2]->close(); + delete c[3]; + + for (int k = 0; k < kClients; k++) { + char msg[32]; + snprintf(msg, sizeof(msg), "client %d", k); + f[k].check_lossless(k == 1 ? "ED" : "D", msg); + } + TEST_ASSERT_EQUAL_INT(ERR_RST, f[1].t.err); + + delete c[0]; + delete c[1]; + delete c[2]; + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_congestion_terminal_events_queued_in_a_flood_arrive_once(void) { + // Every connection's end is queued behind the flood before the task runs at all. + const int kClients = 4; + std::deque f(kClients); + AsyncClient *c[kClients]; + tcp_pcb *pcb[kClients]; + for (int k = 0; k < kClients; k++) { + c[k] = new AsyncClient(); + f[k].attach(*c[k]); + pcb[k] = establish(*c[k]); + } + + for (int i = 0; i < kRounds; i++) { + for (int k = 0; k < kClients; k++) { + fire_sent(pcb[k], 1); + fire_poll(pcb[k]); + } + } + fire_fin(pcb[0]); + fire_error(pcb[1], ERR_RST); + fire_fin(pcb[2]); + fire_error(pcb[3], ERR_ABRT); + asynctcp_test_pump(); + + for (int k = 0; k < kClients; k++) { + char msg[32]; + snprintf(msg, sizeof(msg), "client %d", k); + const std::string s = f[k].without_polls(); + const std::string end = (k % 2) ? "ED" : "D"; + TEST_ASSERT_EQUAL_INT_MESSAGE(1, f[k].t.count('D'), msg); + TEST_ASSERT_TRUE_MESSAGE(s.size() >= end.size() && s.compare(s.size() - end.size(), end.size(), end) == 0, msg); + delete c[k]; + } + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +static void test_congestion_accepts_and_their_data_all_arrive(void) { + const int kConnections = 2 * CONFIG_ASYNC_TCP_QUEUE_SIZE; + AsyncServer s(kServerPort); + std::deque f; + Accepted accepted(s, [&](AsyncClient *c) { + f.emplace_back(); + f.back().attach(*c); + }); + s.begin(); + + std::vector conns; + std::vector sent; + for (int i = 0; i < kConnections; i++) { + tcp_pcb *pcb = fire_accept(listen_pcb(kServerPort)); + conns.push_back(pcb); + sent.push_back(chunk(i)); + fire_recv(pcb, sent.back().data(), sent.back().size()); + fire_poll(pcb); + } + asynctcp_test_pump(); + + TEST_ASSERT_EQUAL_size_t(kConnections, accepted.size()); + for (int i = 0; i < kConnections; i++) { + AsyncClient *c = accepted.holding(conns[i]); + TEST_ASSERT_NOT_NULL(c); + size_t k = 0; + while (accepted[k] != c) { + k++; + } + TEST_ASSERT_EQUAL_STRING(sent[i].c_str(), f[k].t.data.c_str()); + TEST_ASSERT_EQUAL_STRING("R", f[k].without_polls().c_str()); + } + + accepted.clear(); + s.end(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + TEST_ASSERT_EQUAL_size_t(0, live_pbufs()); +} + +static void test_congestion_idle_connection_is_polled_once_the_queue_drains(void) { + AsyncClient busy; + Flooded b; + b.attach(busy); + tcp_pcb *bp = establish(busy); + AsyncClient idle; + Recorder t; + t.attach(idle, "CEDP"); + tcp_pcb *ip = establish(idle); + + for (int i = 0; i < kRounds; i++) { + round(bp, b, i); + fire_poll(ip); + } + asynctcp_test_pump(); + + // lwIP polls every connection every poll interval, whatever the library did with the + // last one. + for (int i = 0; i < 4; i++) { + fire_poll(ip); + asynctcp_test_pump(); + } + TEST_ASSERT_TRUE(t.count('P') >= 1); + + busy.close(); + idle.close(); +} + +static void test_congestion_idle_connection_times_out_once_the_queue_drains(void) { + AsyncClient busy; + Flooded b; + b.attach(busy); + tcp_pcb *bp = establish(busy); + AsyncClient idle; + Recorder t; + t.attach(idle); + tcp_pcb *ip = establish(idle); + idle.setRxTimeout(1); + mockclock::advance(1500); + + for (int i = 0; i < kRounds; i++) { + round(bp, b, i); + fire_poll(ip); + } + asynctcp_test_pump(); + for (int i = 0; i < 4 && is_live(ip); i++) { + fire_poll(ip); + asynctcp_test_pump(); + } + + TEST_ASSERT_EQUAL_STRING("CD", t.seq.c_str()); + TEST_ASSERT_FALSE(is_live(ip)); + + busy.close(); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +void run_congestion_tests(void) { + UnitySetTestFile(__FILE__); + RUN_TEST(test_congestion_one_connection_loses_nothing_but_polls); + RUN_TEST(test_congestion_several_connections_each_lose_nothing_but_polls); + RUN_TEST(test_congestion_terminal_events_queued_in_a_flood_arrive_once); + RUN_TEST(test_congestion_accepts_and_their_data_all_arrive); + RUN_TEST(test_congestion_idle_connection_is_polled_once_the_queue_drains); + RUN_TEST(test_congestion_idle_connection_times_out_once_the_queue_drains); +} From 3ae85ada02164b81c8fc25b9ef9a00214df1456d Mon Sep 17 00:00:00 2001 From: Will Miles Date: Fri, 2 Oct 2026 22:56:09 -0400 Subject: [PATCH 21/24] test: Cover failing to create the queue mutex or the async task Co-Authored-By: Claude Opus 5.5 --- test/test_native/README.md | 12 +- .../mocks/include/freertos/FreeRTOS.h | 2 + test/test_native/mocks/mock_lwip.cpp | 15 +++ test/test_native/mocks/mock_lwip.h | 5 + test/test_native/mocks/mock_rtos.cpp | 12 ++ test/test_native/mocks/mock_rtos.h | 7 ++ test/test_native/test_main.cpp | 2 + test/test_native/tests/test_start.cpp | 106 ++++++++++++++++++ 8 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 test/test_native/tests/test_start.cpp diff --git a/test/test_native/README.md b/test/test_native/README.md index 95f85072..0e3d6319 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -135,6 +135,7 @@ test/test_native/ test_server_config.cpp setNoDelay() test_alloc.cpp each lwIP callback when the library cannot allocate test_congestion.cpp far more events queued than the queue is sized for + test_start.cpp when FreeRTOS cannot create the queue mutex or the task mocks/ include/ fake system headers -- on the include path FIRST, so #include "lwip/tcp.h" etc. resolve here @@ -289,6 +290,7 @@ fire_error(pcb, ERR_RST); // frees pcb, then tcp_err_fn -- pcb is dead afte fire_accept(listen_pcb); // builds a new ESTABLISHED pcb, offers it fire_accept(listen_pcb, peer, 41234); // ... from the peer at that ip_addr_t and port fire_accept_failure(listen_pcb); // ... or NULL with ERR_MEM, as when no pcb is free +fire_raw_accept(fn); // a new ESTABLISHED pcb, offered to fn on the lwIP thread fire_dns(0x0A000005); // deferred dns_found_callback with an address fire_dns(addr); // ... or with any ip_addr_t, IPv6 included fire_dns_failure(); // ... or with NULL @@ -316,6 +318,13 @@ faults().dns_addr = 0x0A000005; // used when dns_result == ERR_OK faults().dns_fails_inside_the_call = true; // with ERR_INPROGRESS: answers NULL, runs the task, then returns ``` +`mockrtos::faults()` does the same for FreeRTOS: + +```cpp +mockrtos::faults().fail_mutex_create = true; // xSemaphoreCreateMutex() returns NULL +mockrtos::faults().fail_task_create = true; // xTaskCreate() fails +``` + The library allocates with `new (std::nothrow)`; nothing else in the program does, so `mockalloc` fails only the library's allocations: @@ -350,7 +359,8 @@ mockclock::advance(1500) // move millis() forward * `reset()` cannot un-start the library's async task or free its queue mutex; those are process-lifetime singletons in `src/AsyncTCP.cpp`. Under the default fork-per-test that costs nothing; under `ASYNCTCP_TEST_NOFORK=1` they persist - from one test to the next. + from one test to the next, so the tests in `test_start.cpp` that fail their + creation only mean anything as the first to start the library. * The mocks cover the API surface `src/AsyncTCP.cpp` actually uses. A change that reaches for more of lwIP will need them extended to match. * Forking is POSIX-only; elsewhere the tests run in one process and a crash ends diff --git a/test/test_native/mocks/include/freertos/FreeRTOS.h b/test/test_native/mocks/include/freertos/FreeRTOS.h index d7a7dd23..4cfc1fcc 100644 --- a/test/test_native/mocks/include/freertos/FreeRTOS.h +++ b/test/test_native/mocks/include/freertos/FreeRTOS.h @@ -17,6 +17,8 @@ typedef size_t configSTACK_DEPTH_TYPE; #define pdTRUE ((BaseType_t)1) #define pdPASS pdTRUE +#define errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY ((BaseType_t)(-1)) + #define portMAX_DELAY ((TickType_t)0xffffffffUL) #define pdMS_TO_TICKS(xTimeInMs) ((TickType_t)(xTimeInMs)) diff --git a/test/test_native/mocks/mock_lwip.cpp b/test/test_native/mocks/mock_lwip.cpp index 15878b32..b7b6cdd8 100644 --- a/test/test_native/mocks/mock_lwip.cpp +++ b/test/test_native/mocks/mock_lwip.cpp @@ -872,6 +872,21 @@ void fire_accept_failure(tcp_pcb *listen_pcb, err_t err) { listen_pcb->accept(listen_pcb->callback_arg, nullptr, err); } +tcp_pcb *fire_raw_accept(const std::function &accept) { + on_lwip_thread lwip; + tcp_pcb *newpcb = alloc_pcb(); + newpcb->state = ESTABLISHED; + ip_addr_set_ip4_u32_val(newpcb->remote_ip, 0x0A000002); // 10.0.0.2 + newpcb->remote_port = 40000; + rec("accept_offered", newpcb, ERR_OK); + const size_t mark = g_aborted.size(); + err_t r = accept(newpcb); + if (!check_return("accept", newpcb, mark, r) && r != ERR_OK && r != ERR_ABRT && is_live(newpcb)) { + abort_pcb(newpcb); + } + return newpcb; +} + unsigned reentrant_api_calls() { return g_reentrant_api_calls; } diff --git a/test/test_native/mocks/mock_lwip.h b/test/test_native/mocks/mock_lwip.h index 8889fe23..80902005 100644 --- a/test/test_native/mocks/mock_lwip.h +++ b/test/test_native/mocks/mock_lwip.h @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -134,6 +135,10 @@ tcp_pcb *fire_accept(tcp_pcb *listen_pcb, const ip_addr_t &peer, uint16_t port, // A SYN lwIP could not allocate a pcb for: tcp_accept_fn with a NULL pcb and , whose // return lwIP ignores. void fire_accept_failure(tcp_pcb *listen_pcb, err_t err = ERR_MEM); +// Builds a fresh ESTABLISHED pcb from 10.0.0.2:40000 and offers it to on the LwIP +// thread, as to an accept callback the application registered with lwIP itself. Returns +// the new pcb, as fire_accept() does, and treats the return the same way. +tcp_pcb *fire_raw_accept(const std::function &accept); // Convenience: build a pbuf holding bytes of . pbuf *make_pbuf(const void *data, size_t len); diff --git a/test/test_native/mocks/mock_rtos.cpp b/test/test_native/mocks/mock_rtos.cpp index dfa9ef89..c1ebce7d 100644 --- a/test/test_native/mocks/mock_rtos.cpp +++ b/test/test_native/mocks/mock_rtos.cpp @@ -84,10 +84,14 @@ struct MockSemaphore { std::set g_semaphores; unsigned g_deadlocks = 0; unsigned g_null_semaphores = 0; +mockrtos::Faults g_faults; } // namespace extern "C" SemaphoreHandle_t xSemaphoreCreateMutex(void) { + if (g_faults.fail_mutex_create) { + return nullptr; + } MockSemaphore *s = new MockSemaphore{false, 0}; g_semaphores.insert(s); return (SemaphoreHandle_t)s; @@ -135,6 +139,9 @@ int g_task_handle_storage = 0; // address used as the fake TaskHandle_t bool g_in_pump = false; BaseType_t create_task(TaskFunction_t fn, void *param, TaskHandle_t *handle) { + if (g_faults.fail_task_create) { + return errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY; // and *handle is left alone + } g_task_fn = fn; g_task_param = param; if (handle) { @@ -213,11 +220,16 @@ void reset() { // stateless. g_deadlocks = 0; g_null_semaphores = 0; + g_faults = Faults{}; for (MockSemaphore *s : g_semaphores) { s->depth = 0; } } +Faults &faults() { + return g_faults; +} + unsigned null_semaphores() { return g_null_semaphores; } diff --git a/test/test_native/mocks/mock_rtos.h b/test/test_native/mocks/mock_rtos.h index 90af1824..3a1adba1 100644 --- a/test/test_native/mocks/mock_rtos.h +++ b/test/test_native/mocks/mock_rtos.h @@ -43,6 +43,13 @@ unsigned deadlocks(); // any occurrence as a test failure. unsigned null_semaphores(); +// Failure injection. Reset by reset(). +struct Faults { + bool fail_mutex_create = false; // xSemaphoreCreateMutex() returns NULL + bool fail_task_create = false; // xTaskCreate() fails and starts nothing +}; +Faults &faults(); + } // namespace mockrtos namespace mockclock { diff --git a/test/test_native/test_main.cpp b/test/test_native/test_main.cpp index a45b18d0..1b11b157 100644 --- a/test/test_native/test_main.cpp +++ b/test/test_native/test_main.cpp @@ -60,6 +60,7 @@ void run_callback_tests(void); void run_context_tests(void); void run_alloc_tests(void); void run_congestion_tests(void); +void run_start_tests(void); namespace { @@ -359,6 +360,7 @@ int main(int argc, char **argv) { run_context_tests(); run_alloc_tests(); run_congestion_tests(); + run_start_tests(); if (g_list_only) { return 0; diff --git a/test/test_native/tests/test_start.cpp b/test/test_native/tests/test_start.cpp new file mode 100644 index 00000000..665f8d12 --- /dev/null +++ b/test/test_native/tests/test_start.cpp @@ -0,0 +1,106 @@ +// Starting the library: what clients and servers do when FreeRTOS cannot create the queue +// mutex or the async task, and that a later start still succeeds. + +#include "fixtures.h" + +using namespace mocklwip; + +namespace { + +// Whether the library ever handed a callback or an argument. +bool bound(const tcp_pcb *pcb) { + for (const Call &call : calls()) { + if (call.pcb == pcb && call.a != 0 + && (call.fn == "tcp_arg" || call.fn == "tcp_recv" || call.fn == "tcp_sent" || call.fn == "tcp_err" || call.fn == "tcp_poll")) { + return true; + } + } + return false; +} + +// An AsyncClient made in the application's own accept callback closes the pcb it was +// handed, without taking it on. +void assert_adoption_refused() { + AsyncClient *c = nullptr; + tcp_pcb *pcb = fire_raw_accept([&](tcp_pcb *p) { + c = new AsyncClient(p); + return (err_t)ERR_OK; + }); + TEST_ASSERT_NOT_NULL(c); + TEST_ASSERT_FALSE(c->connected()); + TEST_ASSERT_NULL(c->pcb()); + TEST_ASSERT_FALSE(bound(pcb)); + TEST_ASSERT_EQUAL_size_t(1, count("tcp_close", pcb)); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_abort", pcb)); + TEST_ASSERT_FALSE(is_live(pcb)); + + app_delete(c); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_size_t(1, count("tcp_close", pcb)); + TEST_ASSERT_EQUAL_size_t(0, count("tcp_abort", pcb)); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); +} + +} // namespace + +static void test_start_client_that_never_connects_is_destructible_without_a_mutex(void) { + mockrtos::faults().fail_mutex_create = true; + AsyncClient *c = new AsyncClient(); + TEST_ASSERT_FALSE(c->connected()); + app_delete(c); + + AsyncServer *s = new AsyncServer(kServerPort); + TEST_ASSERT_EQUAL_INT((int)CLOSED, (int)s->status()); + delete s; +} + +static void test_start_connect_is_refused_without_a_task(void) { + mockrtos::faults().fail_task_create = true; + AsyncClient *c = new AsyncClient(); + Recorder t; + t.attach(*c); + TEST_ASSERT_FALSE(c->connect(kPeer, kPort)); + TEST_ASSERT_FALSE(c->connected()); + TEST_ASSERT_NULL(c->pcb()); + TEST_ASSERT_EQUAL_size_t(0, live_pcbs()); + + app_close(*c); + app_abort(*c); + asynctcp_test_pump(); + app_delete(c); + asynctcp_test_pump(); + TEST_ASSERT_EQUAL_STRING("", t.seq.c_str()); + TEST_ASSERT_EQUAL_STRING("", t.violations.c_str()); +} + +static void test_start_client_does_not_adopt_a_pcb_without_a_task(void) { + mockrtos::faults().fail_task_create = true; + assert_adoption_refused(); +} + +static void test_start_client_adopts_a_pcb_once_the_task_can_be_created(void) { + mockrtos::faults().fail_task_create = true; + assert_adoption_refused(); + mockrtos::faults().fail_task_create = false; + + AsyncClient *c = nullptr; + tcp_pcb *pcb = fire_raw_accept([&](tcp_pcb *p) { + c = new AsyncClient(p); + return (err_t)ERR_OK; + }); + TEST_ASSERT_NOT_NULL(c); + TEST_ASSERT_TRUE(c->connected()); + TEST_ASSERT_EQUAL_PTR(pcb, c->pcb()); + TEST_ASSERT_NOT_NULL(pcb->errf); + app_delete(c); + asynctcp_test_pump(); + TEST_ASSERT_FALSE(is_live(pcb)); +} + +void run_start_tests(void) { + UnitySetTestFile(__FILE__); + RUN_TEST(test_start_client_that_never_connects_is_destructible_without_a_mutex); + RUN_TEST(test_start_connect_is_refused_without_a_task); + RUN_TEST(test_start_client_does_not_adopt_a_pcb_without_a_task); + RUN_TEST(test_start_client_adopts_a_pcb_once_the_task_can_be_created); +} From b48fb2a3b774c9830ea640a4e784bc29e471b939 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Mon, 26 Oct 2026 17:29:48 -0400 Subject: [PATCH 22/24] test: Ignore the tests that fail until the library is fixed Co-Authored-By: Claude Opus 5.5 --- test/test_native/README.md | 11 ++++++----- test/test_native/tests/test_alloc.cpp | 6 ++++++ test/test_native/tests/test_dns.cpp | 1 + test/test_native/tests/test_lifetime.cpp | 1 + 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/test/test_native/README.md b/test/test_native/README.md index 0e3d6319..51ab4ebd 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -39,14 +39,15 @@ pio test -e native -a -x=congestion # all but those ## Reading the results -A failing test is a defect in the library, not in the test, and stays red until the -library is fixed: no test is skipped or expected to fail. So a run need not be green. -What counts is the difference: a change must not make a test fail that passes without it. +A failing test is a defect in the library, not in the test. Tests documenting +known library failures begin with a `TEST_IGNORE_MESSAGE()` describing the +defect, so a run is green. Fixing the defect includes deleting that line. A +change must not make any test fail. -To compare two runs, list the tests that failed in each: +To list what is still ignored: ``` -pio test -e native | awk -F': ' '/\[FAILED\]/ { print $2 }' | sort -u +pio test -e native -v | grep ':IGNORE:' ``` ## Why each test is forked diff --git a/test/test_native/tests/test_alloc.cpp b/test/test_native/tests/test_alloc.cpp index 53cdf598..aa4a72d5 100644 --- a/test/test_native/tests/test_alloc.cpp +++ b/test/test_native/tests/test_alloc.cpp @@ -72,6 +72,7 @@ void assert_connect_concluded(const Recorder &t) { // --------------------------------------------------------------------------- static void test_alloc_connected_still_concludes_the_connect(void) { + TEST_IGNORE_MESSAGE("a failed allocation loses the connect"); // lwIP ignores what the connected callback returns unless it is ERR_ABRT // (tcp_process), so it will not say again that the connection is up. It still polls // the connection. @@ -222,6 +223,7 @@ static void test_alloc_recv_then_destroy_leaks_nothing(void) { } static void test_alloc_fin_still_ends_the_connection_once(void) { + TEST_IGNORE_MESSAGE("a failed allocation loses the FIN"); // lwIP reports a FIN once, whatever the callback returns (tcp_input), so the close has // to be noticed some other way; lwIP keeps polling the half-closed connection. AsyncClient c; @@ -317,6 +319,7 @@ static void test_alloc_poll_still_times_out_an_idle_connection(void) { // --------------------------------------------------------------------------- static void test_alloc_error_is_still_reported(void) { + TEST_IGNORE_MESSAGE("a failed allocation loses the error"); AsyncClient c; Recorder t; t.attach(c); @@ -332,6 +335,7 @@ static void test_alloc_error_is_still_reported(void) { } static void test_alloc_error_while_connecting_is_still_reported(void) { + TEST_IGNORE_MESSAGE("a failed allocation loses the error"); AsyncClient c; Recorder t; t.attach(c); @@ -422,6 +426,7 @@ static void test_alloc_accept_failure_leaves_later_accepts_working(void) { // --------------------------------------------------------------------------- static void test_alloc_dns_answer_still_concludes_the_connect(void) { + TEST_IGNORE_MESSAGE("a failed allocation loses the lookup's answer"); faults().dns_result = ERR_INPROGRESS; AsyncClient *c = new AsyncClient(); Recorder t; @@ -445,6 +450,7 @@ static void test_alloc_dns_answer_still_concludes_the_connect(void) { } static void test_alloc_dns_failure_is_still_reported(void) { + TEST_IGNORE_MESSAGE("a failed allocation loses the lookup's failure"); faults().dns_result = ERR_INPROGRESS; AsyncClient c; Recorder t; diff --git a/test/test_native/tests/test_dns.cpp b/test/test_native/tests/test_dns.cpp index 5f3f7f17..3d83eddd 100644 --- a/test/test_native/tests/test_dns.cpp +++ b/test/test_native/tests/test_dns.cpp @@ -108,6 +108,7 @@ static void test_dns_failure_reports_error_55_and_does_not_connect(void) { } static void test_dns_failure_answered_inside_the_lookup_is_reported_once(void) { + TEST_IGNORE_MESSAGE("onDisconnect is not reported after a lookup that fails inside dns_gethostbyname()"); // lwIP with no DNS server answers inside dns_gethostbyname(), then returns ERR_INPROGRESS; // the async task may deliver the answer before connect() returns. faults().dns_result = ERR_INPROGRESS; diff --git a/test/test_native/tests/test_lifetime.cpp b/test/test_native/tests/test_lifetime.cpp index 3941cf43..e3e3c047 100644 --- a/test/test_native/tests/test_lifetime.cpp +++ b/test/test_native/tests/test_lifetime.cpp @@ -432,6 +432,7 @@ static void test_lifetime_destroying_another_client_with_a_queued_error_reports_ } static void test_lifetime_destroy_closes_a_connection_made_from_onDisconnect(void) { + TEST_IGNORE_MESSAGE("~AsyncClient() leaves open a connection dialled from onDisconnect"); // ~AsyncClient() runs onDisconnect, which dials again. AsyncClient *c = new AsyncClient(); Recorder t; From f534da507938c8d1a429489a87e2148f31e12908 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Wed, 30 Sep 2026 23:29:29 -0400 Subject: [PATCH 23/24] ci: Run the host-native tests Needs no toolchain, so it reports in well under a minute. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++++ test/test_native/README.md | 6 +++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63408f52..2811f600 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,34 @@ jobs: - name: FetchWebsite run: arduino-cli --config-file ${{ matrix.config }} --library ./src/ compile --fqbn esp32:esp32:esp32 ./examples/FetchWebsite/FetchWebsite.ino + native-tests: + name: "pio:native" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Cache PlatformIO + uses: actions/cache@v6 + with: + key: ${{ runner.os }}-pio-native + path: | + ~/.cache/pip + ~/.platformio + + - name: Python + uses: actions/setup-python@v7 + with: + python-version: "3.13" + + - name: Install + run: | + python -m pip install --upgrade pip + pip install --upgrade platformio + + - run: python3 test/check_registration.py + - run: pio test -e native -e native-asan -e native-no-ipv6 -e native-no-core-locking + platformio: name: "pio:${{ matrix.env }}:${{ matrix.board }}" runs-on: ubuntu-latest diff --git a/test/test_native/README.md b/test/test_native/README.md index 51ab4ebd..4002ae21 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -259,9 +259,9 @@ the fields of `ip_addr_t`, whose layout depends on it. * `Accepted`: takes delivery of a server's clients and deletes whatever is left of them at scope exit. -`python3 test/check_registration.py` (run by pre-commit) fails if a test is -never registered, or is not named for its file: tests in `test_topic.cpp` are -`test_topic_*`. +`python3 test/check_registration.py` (run by pre-commit and CI) fails if a test +is never registered, or is not named for its file: tests in `test_topic.cpp` +are `test_topic_*`. `test_main.cpp` calls `mocklwip::reset()`, `mockrtos::reset()`, `mockclock::reset()` and `mockalloc::reset()` before every test, and `mocklwip::reset()` again after, so From 431feccbc4293e86848b24cd7834d9fefc1ac947 Mon Sep 17 00:00:00 2001 From: Will Miles Date: Tue, 27 Oct 2026 15:29:37 -0400 Subject: [PATCH 24/24] doc: Reword the native harness README Co-Authored-By: Claude Opus 5.5 --- test/test_native/README.md | 79 ++++++++++++++++++++------------------ 1 file changed, 41 insertions(+), 38 deletions(-) diff --git a/test/test_native/README.md b/test/test_native/README.md index 4002ae21..5349c813 100644 --- a/test/test_native/README.md +++ b/test/test_native/README.md @@ -1,10 +1,10 @@ # Host-native test harness -Compiles `src/AsyncTCP.cpp` for the host against mock lwIP, FreeRTOS and Arduino -headers, so the library's logic can be exercised on a desktop with no ESP32 and -no network. +The harness compiles `src/AsyncTCP.cpp` for the host against mock lwIP, FreeRTOS +and Arduino headers, so the library's logic can be exercised on a desktop with no +ESP32 and no network. -Nothing under `src/` is modified or needs to be. +It needs no changes to anything under `src/`. ## Running @@ -15,9 +15,9 @@ pio test -e native-no-ipv6 # ... against lwIP built for IPv4 only pio test -e native-no-core-locking # ... against lwIP without core locking ``` -Each reports each test separately and exits non-zero if any fails. The two -lwIP configurations change address layout and locking, not who owns what, so -they run without the sanitizers. +Each environment reports every test separately, and `pio test` exits non-zero if +any test fails. The two lwIP configurations change address layout and locking, +not who owns what, so they run without the sanitizers. To work under a debugger, run the built program directly; `ASYNCTCP_TEST_NOFORK=1` keeps every test in one process: @@ -28,8 +28,8 @@ ASYNCTCP_TEST_NOFORK=1 gdb .pio/build/native/program `ASYNCTCP_TEST_VERBOSE=1` additionally prints the library's `log_*` output. -Unity's own options select tests; `-h` lists them. Through `pio test`, pass each as one -`-a`, joined by `=`: +Unity's own options select which tests run; `-h` lists them. To pass one through +`pio test`, give it as a single `-a` argument, with any value joined by `=`: ``` pio test -e native -a -f=test_dns_ # only tests whose name contains test_dns_ @@ -52,23 +52,24 @@ pio test -e native -v | grep ':IGNORE:' ## Why each test is forked -`test_main.cpp` runs every test in a child process. A test that segfaults then +`test_main.cpp` runs every test in a child process, so a test that segfaults costs one red result instead of the rest of the run -- which matters, because the -bugs this suite is aimed at tend to crash rather than to fail an assertion. It -also lets `tearDown()` run LeakSanitizer's check on one test at a time, so a +bugs this suite is aimed at tend to crash rather than to fail an assertion. +Forking also lets `tearDown()` run LeakSanitizer's check on one test at a time, so a leak fails the test that caused it. ## How a failed assertion ends a test `unity_config.h` makes Unity's `TEST_ABORT()` throw, and `test_main.cpp` catches that around the test, `setUp()` and `tearDown()`. So a failed assertion ends the -test on the spot, as it would with Unity's default `longjmp`, but its destructors -run and `tearDown()` still does. That holds inside a callback run by a pump too. +test on the spot, as it would with Unity's default `longjmp`, but the test's +destructors still run, and so does `tearDown()`. The same is true of a failed +assertion in a callback that a pump runs. Where nothing can be unwound -- a failed assertion in a callback run from a -destructor, which is `noexcept` -- the throw ends in `std::terminate()`. The -forked child concludes the test as failed there; under `ASYNCTCP_TEST_NOFORK=1` -it ends the run. +destructor, which is `noexcept` -- the throw ends in `std::terminate()`. In a +forked child, the runner's terminate handler then records the test as failed; +under `ASYNCTCP_TEST_NOFORK=1`, the whole run ends. ## How the async task is pumped @@ -119,7 +120,7 @@ test/test_native/ asynctcp.cpp the library under test, compiled into the test program fixtures.h what most tests share: the peer and ports, establish(), listen_pcb(), AppCall, Recorder, Accepted - tests/ one file per topic; add files here, they are picked up + tests/ one file per topic; new files here are picked up automatically test_client.cpp end-to-end smoke tests, one per main path test_state.cpp the connection state machine and the accessors test_write.cpp the outbound path @@ -153,7 +154,7 @@ test/test_native/ ## What the mock lwIP models -Behavior is matched to real lwIP wherever AsyncTCP's lifetime handling depends +The mock behaves as real lwIP does wherever AsyncTCP's lifetime handling depends on it: * `tcp_abort()` frees the pcb and **then** invokes the error callback with @@ -190,13 +191,14 @@ on it: without it, lwIP runs `fn` on its own thread, and there is no core lock at all, so `LOCK_TCPIP_CORE()` is empty and the raw API is safe only on the lwIP thread. -Inside `tcpip_api_call()` and the `fire_*` helpers the mock counts as the lwIP -thread. After every test the runner fails it if the library: +The mock treats code running inside `tcpip_api_call()` or a `fire_*` helper as +running on the lwIP thread. After every test, the runner fails that test if the +library: * called `tcpip_api_call()` from the lwIP thread, which would deadlock; * left the TCPIP core lock held; * called any `tcp_*` or `dns_*` function neither on the lwIP thread nor under - the core lock, which would race the lwIP thread. + the core lock, which would race the lwIP thread; * returned `ERR_ABRT` from a callback for a pcb it had not aborted, or anything else for one it had, which would leave lwIP using a freed pcb or dropping a live one. @@ -209,9 +211,10 @@ plain `ip4_addr_t` with no `type` or `u_addr`. ## Adding a test -Add it to the file for its topic, or drop a new `.cpp` in `tests/`; PlatformIO -compiles everything under this directory. The tests are plain Unity, and each -file lists what it holds in one `run_*_tests()`: +Add a new test to the file for its topic, or put it in a new `.cpp` file in +`tests/`; PlatformIO compiles everything under this directory. The tests are +plain Unity, and each file registers its tests in a single `run_*_tests()` +function: ```cpp #include "fixtures.h" @@ -241,9 +244,9 @@ Include `fixtures.h`, or at least `runner.h`, rather than ``: `runner.h is what redirects `RUN_TEST` into the forking runner. A new file's `run_*_tests()` has to be declared and called in `test_main.cpp`. -Every configuration builds every test, so a test about IPv6 goes inside -`#if LWIP_IPV6`, and addresses are read through `remote_ip4(pcb)` rather than -the fields of `ip_addr_t`, whose layout depends on it. +Every configuration builds every test, so tests about IPv6 should go inside +`#if LWIP_IPV6`, and tests should read addresses through `remote_ip4(pcb)` +rather than the fields of `ip_addr_t`, whose layout depends on `LWIP_IPV6`. `fixtures.h` holds what most tests start with: @@ -253,20 +256,20 @@ the fields of `ip_addr_t`, whose layout depends on it. * `listen_pcb(port)`: the listening pcb, on `port` or on any port; * `Recorder`: records a client's callbacks as a string, one letter each, so order, count and exactly-once are one assertion; -* `AppCall`, `app()`, `app_close()`, `app_abort()`, `app_delete()`: mark a call - the application makes into the library, so a `Recorder` notes in - `violations` any callback run inside it that the call may not run; +* `AppCall`, `app()`, `app_close()`, `app_abort()`, `app_delete()`: mark the span + of an application call into the library. A `Recorder` adds to `violations` + any callback that runs during that span but that the call may not run; * `Accepted`: takes delivery of a server's clients and deletes whatever is left of them at scope exit. `python3 test/check_registration.py` (run by pre-commit and CI) fails if a test is never registered, or is not named for its file: tests in `test_topic.cpp` -are `test_topic_*`. +must be named `test_topic_*`. `test_main.cpp` calls `mocklwip::reset()`, `mockrtos::reset()`, `mockclock::reset()` and `mockalloc::reset()` before every test, and `mocklwip::reset()` again after, so each test starts from zero pcbs, zero pbufs, no faults and `millis() == 1000`. A -test about `millis() == 0` sets it with `mockclock::set_millis(0)`. +test that needs `millis() == 0` should set it with `mockclock::set_millis(0)`. **Destroy your `AsyncClient`/`AsyncServer` before the test returns** if you want leak assertions to mean anything — `reset()` reclaims whatever is left, but a @@ -360,12 +363,12 @@ mockclock::advance(1500) // move millis() forward * `reset()` cannot un-start the library's async task or free its queue mutex; those are process-lifetime singletons in `src/AsyncTCP.cpp`. Under the default fork-per-test that costs nothing; under `ASYNCTCP_TEST_NOFORK=1` they persist - from one test to the next, so the tests in `test_start.cpp` that fail their - creation only mean anything as the first to start the library. + from one test to the next, so the tests in `test_start.cpp` that make creating + them fail are meaningful only when they are the first to start the library. * The mocks cover the API surface `src/AsyncTCP.cpp` actually uses. A change that reaches for more of lwIP will need them extended to match. * Forking is POSIX-only; elsewhere the tests run in one process and a crash ends the run. -* Memory the library reads before writing holds 0xfe bytes, on the stack and on - the heap, so a failure that depends on it reads the same on every run. The - heap fill needs glibc or ASan. +* The harness fills new stack and heap memory with 0xfe bytes, so if the library + reads memory before writing it, the test gets the same result on every run. + The heap fill needs glibc or ASan.