core(M0): монорепо-каркас — CMake (posix+esp-idf), платслой, лог, мини-httpd

- CMakeLists в корне: ветвление ESP_PLATFORM (idf_component_register,
  lwip/esp_timer/esp_hw_support/pthread) / POSIX (статическая библиотека
  fgl-aircon, C++20, -fno-exceptions -fno-rtti, -Werror).
- src/ayla/platform: сокеты/потоки/CSPRNG/время; posix (getrandom, poll,
  pthread_join) и esp-idf (lwip_select, esp_fill_random, pthread-слой IDF);
  tcp_shutdown/tcp_local_port/thread_join для управляемой остановки.
- src/ayla: log (sink, без printf); мини-httpd/1.1 (keep-alive, Content-Length,
  лимиты заголовков/тела, ephemeral-порт, жизненный цикл с гарантией
  завершения потока: shutdown(active)→join→close).
- tests/ayla: platform (join, loopback+shutdown) и httpd (404, keep-alive,
  обработчик/парсинг, oversize-400, stop при живом соединении, стрим
  заголовков). doctest через FetchContent.
- scripts/ci.sh: сборка+ctest. ESP-IDF v5.5.5 esp32: смоук-сборка с ядром
  как компонентом — Project build complete.
Ревью под-агентом: 3 круга, все блокеры (жизненный цикл httpd) закрыты, APPROVED.
This commit is contained in:
2026-09-22 00:27:33 +03:00
parent b21817ab9f
commit b44004627b
15 changed files with 1362 additions and 1 deletions

224
tests/ayla/test_httpd.cpp Normal file
View File

@@ -0,0 +1,224 @@
// Тесты мини-httpd: парсинг запросов, keep-alive, 404 по умолчанию,
// корректный stop при живом keep-alive соединении.
#include "doctest/doctest.h"
#include <cstring>
#include <string>
#include "ayla/httpd.hpp"
#include "ayla/platform/platform.hpp"
namespace {
// Простой блокирующий HTTP-клиент для тестов (ephemeral-порт сервера).
std::string build_request(const char* method, const char* target,
const char* body, bool keep_alive) {
char head[512];
int n = snprintf(head, sizeof(head),
"%s %s HTTP/1.1\r\n"
"Content-Length: %u\r\n"
"Connection: %s\r\n"
"\r\n",
method, target,
body != nullptr ? static_cast<unsigned>(strlen(body)) : 0u,
keep_alive ? "keep-alive" : "close");
std::string req(head, head + n);
if (body != nullptr) req += body;
return req;
}
struct RawResponse {
int status = 0;
std::string body;
};
// Один запрос на новом соединении.
RawResponse http_request(uint16_t port, const char* method, const char* target,
const char* body = nullptr, bool keep_alive = true) {
int fd = fgl::plat::tcp_connect("127.0.0.1", port, 2000);
REQUIRE(fd >= 0);
fgl::plat::tcp_set_timeout(fd, 2000, 2000);
std::string req = build_request(method, target, body, keep_alive);
REQUIRE(fgl::plat::tcp_send(fd, req.data(), req.size()) ==
static_cast<long>(req.size()));
std::string raw;
char buf[1024];
for (;;) {
long r = fgl::plat::tcp_recv(fd, buf, sizeof(buf));
if (r <= 0) break;
raw.append(buf, buf + r);
size_t hdr_end = raw.find("\r\n\r\n");
if (hdr_end != std::string::npos) {
unsigned clen = 0;
size_t cl = raw.find("Content-Length:");
if (cl != std::string::npos) {
clen = static_cast<unsigned>(atoi(raw.c_str() + cl + 15));
}
if (raw.size() >= hdr_end + 4 + clen) break;
}
}
fgl::plat::tcp_close(fd);
RawResponse out;
out.status = atoi(raw.c_str() + 9); // "HTTP/1.1 NNN"
size_t hdr_end = raw.find("\r\n\r\n");
if (hdr_end != std::string::npos) out.body = raw.substr(hdr_end + 4);
return out;
}
} // namespace
TEST_CASE("httpd: ephemeral порт и 404 по умолчанию") {
fgl::ayla::HttpServer srv;
REQUIRE(srv.start(0, nullptr, nullptr));
const uint16_t port = srv.port();
REQUIRE(port != 0);
auto resp = http_request(port, "GET", "/local_lan/commands.json");
CHECK(resp.status == 404);
CHECK(resp.body.empty());
srv.stop();
CHECK_FALSE(srv.is_running());
}
TEST_CASE("httpd: keep-alive — два запроса на одном соединении") {
fgl::ayla::HttpServer srv;
REQUIRE(srv.start(0, nullptr, nullptr));
const uint16_t port = srv.port();
int fd = fgl::plat::tcp_connect("127.0.0.1", port, 2000);
REQUIRE(fd >= 0);
fgl::plat::tcp_set_timeout(fd, 2000, 2000);
for (int i = 0; i < 2; i++) {
std::string req = build_request("GET", "/local_lan/commands.json", nullptr,
true);
REQUIRE(fgl::plat::tcp_send(fd, req.data(), req.size()) ==
static_cast<long>(req.size()));
std::string raw;
char buf[512];
while (raw.find("\r\n\r\n") == std::string::npos) {
long r = fgl::plat::tcp_recv(fd, buf, sizeof(buf));
if (r <= 0) break;
raw.append(buf, buf + r);
}
CHECK(atoi(raw.c_str() + 9) == 404);
}
fgl::plat::tcp_close(fd);
srv.stop();
}
namespace {
struct HandlerCtx {
fgl::ayla::HttpRequest last_req;
int calls = 0;
};
bool capture_handler(const fgl::ayla::HttpRequest& req,
fgl::ayla::HttpResponse& resp, void* ctx) {
auto* h = static_cast<HandlerCtx*>(ctx);
h->last_req = req;
h->calls++;
resp.status = 200;
static const char kBody[] = "{\"ok\":true}";
resp.body = reinterpret_cast<const uint8_t*>(kBody);
resp.body_len = sizeof(kBody) - 1;
return true;
}
} // namespace
TEST_CASE("httpd: обработчик, парсинг метода/пути/query/тела/пира") {
fgl::ayla::HttpServer srv;
HandlerCtx h;
REQUIRE(srv.start(0, capture_handler, &h));
auto resp = http_request(srv.port(), "POST",
"/local_lan/property/datapoint.json?cmd_id=5&status=200",
"{\"enc\":\"abc\"}");
CHECK(resp.status == 200);
CHECK(resp.body == std::string("{\"ok\":true}"));
CHECK(h.calls == 1);
CHECK(std::string(h.last_req.method) == "POST");
CHECK(std::string(h.last_req.target) == "/local_lan/property/datapoint.json");
CHECK(std::string(h.last_req.query) == "cmd_id=5&status=200");
CHECK(h.last_req.body_len == 13);
CHECK(memcmp(h.last_req.body, "{\"enc\":\"abc\"}", 13) == 0);
CHECK(h.last_req.peer_ip == 0x7f000001);
srv.stop();
}
TEST_CASE("httpd: oversized body -> 400 + Connection: close") {
fgl::ayla::HttpServer srv;
REQUIRE(srv.start(0, nullptr, nullptr));
std::string big(fgl::ayla::kHttpdMaxBody + 100, 'x');
int fd = fgl::plat::tcp_connect("127.0.0.1", srv.port(), 2000);
REQUIRE(fd >= 0);
fgl::plat::tcp_set_timeout(fd, 2000, 2000);
std::string req = build_request("POST", "/local_lan/property/datapoint.json",
big.c_str(), false);
REQUIRE(fgl::plat::tcp_send(fd, req.data(), req.size()) ==
static_cast<long>(req.size()));
std::string raw;
char buf[1024];
for (;;) {
long r = fgl::plat::tcp_recv(fd, buf, sizeof(buf));
if (r <= 0) break;
raw.append(buf, buf + r);
}
fgl::plat::tcp_close(fd);
CHECK(atoi(raw.c_str() + 9) == 400);
CHECK(raw.find("Connection: close") != std::string::npos);
srv.stop();
}
TEST_CASE("httpd: stop() при открытом keep-alive соединении (нет UAF/зависания)") {
fgl::ayla::HttpServer srv;
REQUIRE(srv.start(0, nullptr, nullptr));
const uint16_t port = srv.port();
// Соединение без запроса: поток сервера сидит в recv с 30с таймаутом.
int fd = fgl::plat::tcp_connect("127.0.0.1", port, 2000);
REQUIRE(fd >= 0);
fgl::plat::sleep_ms(200); // даём серверу принять и уйти в ожидание
uint64_t t0 = fgl::plat::now_ms();
srv.stop(); // должен прервать соединение shutdown'ом и join'нуть поток
uint64_t elapsed = fgl::plat::now_ms() - t0;
CHECK(srv.port() == port);
CHECK(elapsed < 2000); // не ждём rx-таймаут
fgl::plat::tcp_close(fd);
}
TEST_CASE("httpd: stop() сразу после start()") {
fgl::ayla::HttpServer srv;
REQUIRE(srv.start(0, nullptr, nullptr));
uint64_t t0 = fgl::plat::now_ms();
srv.stop(); // поток мог не дойти до poll — join всё равно быстрый
CHECK(fgl::plat::now_ms() - t0 < 2000);
// Сервер можно перезапустить после stop.
REQUIRE(srv.start(0, nullptr, nullptr));
auto resp = http_request(srv.port(), "GET", "/");
CHECK(resp.status == 404);
srv.stop();
}
TEST_CASE("httpd: бесконечный стрим заголовков завершается (лимит 1КБ)") {
fgl::ayla::HttpServer srv;
REQUIRE(srv.start(0, nullptr, nullptr));
int fd = fgl::plat::tcp_connect("127.0.0.1", srv.port(), 2000);
REQUIRE(fd >= 0);
fgl::plat::tcp_set_timeout(fd, 2000, 2000);
// Отправляем request line и бессрочный поток заголовков малыми кусками.
std::string req = "GET / HTTP/1.1\r\n";
REQUIRE(fgl::plat::tcp_send(fd, req.data(), req.size()) ==
static_cast<long>(req.size()));
const char* filler = "X-Pad: 0123456789012345678901234567890123456789\r\n";
size_t flen = strlen(filler);
for (int i = 0; i < 80; i++) { // ~4КБ — больше лимита
REQUIRE(fgl::plat::tcp_send(fd, filler, flen) == static_cast<long>(flen));
}
// Сервер должен перестать читать и закрыть соединение: recv завершается
// (EOF или RST после close с непрочитанными данными), а не висит вечно.
fgl::plat::tcp_set_timeout(fd, 3000, 3000);
char buf[64];
long r;
while ((r = fgl::plat::tcp_recv(fd, buf, sizeof(buf))) > 0) {
}
CHECK(r <= 0); // соединение закрыто сервером
fgl::plat::tcp_close(fd);
srv.stop();
}