From b44004627b6ecaf0de6c8ecb9b157110253ba5c7 Mon Sep 17 00:00:00 2001 From: Petr Polezhaev Date: Tue, 22 Sep 2026 00:27:33 +0300 Subject: [PATCH] =?UTF-8?q?core(M0):=20=D0=BC=D0=BE=D0=BD=D0=BE=D1=80?= =?UTF-8?q?=D0=B5=D0=BF=D0=BE-=D0=BA=D0=B0=D1=80=D0=BA=D0=B0=D1=81=20?= =?UTF-8?q?=E2=80=94=20CMake=20(posix+esp-idf),=20=D0=BF=D0=BB=D0=B0=D1=82?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D0=B9,=20=D0=BB=D0=BE=D0=B3,=20=D0=BC=D0=B8?= =?UTF-8?q?=D0=BD=D0=B8-httpd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .gitignore | 4 + CMakeLists.txt | 57 +++++ docs/PLAN_CORE_LIBRARY.md | 2 +- include/fgl-aircon/version.hpp | 7 + scripts/ci.sh | 13 ++ src/ayla/httpd.cpp | 285 +++++++++++++++++++++++++ src/ayla/httpd.hpp | 78 +++++++ src/ayla/log.cpp | 42 ++++ src/ayla/log.hpp | 32 +++ src/ayla/platform/esp-idf/platform.cpp | 214 +++++++++++++++++++ src/ayla/platform/platform.hpp | 57 +++++ src/ayla/platform/posix/platform.cpp | 216 +++++++++++++++++++ tests/CMakeLists.txt | 24 +++ tests/ayla/test_httpd.cpp | 224 +++++++++++++++++++ tests/ayla/test_platform.cpp | 108 ++++++++++ 15 files changed, 1362 insertions(+), 1 deletion(-) create mode 100644 CMakeLists.txt create mode 100644 include/fgl-aircon/version.hpp create mode 100755 scripts/ci.sh create mode 100644 src/ayla/httpd.cpp create mode 100644 src/ayla/httpd.hpp create mode 100644 src/ayla/log.cpp create mode 100644 src/ayla/log.hpp create mode 100644 src/ayla/platform/esp-idf/platform.cpp create mode 100644 src/ayla/platform/platform.hpp create mode 100644 src/ayla/platform/posix/platform.cpp create mode 100644 tests/CMakeLists.txt create mode 100644 tests/ayla/test_httpd.cpp create mode 100644 tests/ayla/test_platform.cpp diff --git a/.gitignore b/.gitignore index b1d11e2..eb0392c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ venv/ # Локальный конфиг устройства (содержит lanip_key) docs/legacy/config_kata.json + +# Сборка +build/ +build-*/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..fc4a7ed --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,57 @@ +cmake_minimum_required(VERSION 3.16) + +if(ESP_PLATFORM) + # --------------------------------------------------------------------------- + # Сборка как компонента ESP-IDF (корень репозитория = компонент; имя + # компонента = имя каталога, из которого он подключён). + # ВАЖНО: cmake_minimum_required должен остаться до этой ветки, project() + # для IDF-сборки не вызывается. + # --------------------------------------------------------------------------- + idf_component_register( + SRCS + "src/ayla/httpd.cpp" + "src/ayla/log.cpp" + "src/ayla/platform/esp-idf/platform.cpp" + INCLUDE_DIRS + "include" + PRIV_INCLUDE_DIRS + "src" + PRIV_REQUIRES + lwip + esp_timer + esp_hw_support + pthread + ) + target_compile_options(${COMPONENT_LIB} PRIVATE + -Wall -Wextra -Werror + -fno-exceptions -fno-rtti + ) + target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_20) +else() + # --------------------------------------------------------------------------- + # Обычная (POSIX) сборка: статическая библиотека + тесты. + # --------------------------------------------------------------------------- + project(fgl-aircon VERSION 0.1.0 LANGUAGES CXX) + + option(FGL_BUILD_TESTS "Build tests" ON) + + add_library(fgl-aircon STATIC + src/ayla/httpd.cpp + src/ayla/log.cpp + src/ayla/platform/posix/platform.cpp + ) + target_include_directories(fgl-aircon + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include" + PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src" + ) + target_compile_features(fgl-aircon PUBLIC cxx_std_20) + target_compile_options(fgl-aircon PRIVATE + -Wall -Wextra -Werror + -fno-exceptions -fno-rtti + ) + + if(FGL_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) + endif() +endif() diff --git a/docs/PLAN_CORE_LIBRARY.md b/docs/PLAN_CORE_LIBRARY.md index b656a56..4a5a92d 100644 --- a/docs/PLAN_CORE_LIBRARY.md +++ b/docs/PLAN_CORE_LIBRARY.md @@ -289,7 +289,7 @@ HA-превью (PLAN_HOME_ASSISTANT §4) и тестами. | # | Содержимое | Критерии приёмки | |---|------------|------------------| -| M0 | Монорепо-каркас: CMake (корень) + IDF-подключение, платслой, лог, CI | Собирается linux+esp-idf; пустой httpd отвечает 404 | +| M0 ✅ | Монорепо-каркас: CMake (корень) + IDF-подключение, платслой, лог, CI | Собирается linux+esp-idf; пустой httpd отвечает 404 | | M1 | `src/ayla`: crypto+envelope, мини-httpd/httpc, jsmn-вендор | Векторы зелёные; httpd-тесты; совместимость с probe_reference.py | | M2 | `src/ayla`: сессия (установка/активация/keep-alive/re-key/слоты/503/delete) с mock-модулем | Все сценарии mock; на приборе: активация ≤5 с, re-key каждые 45–60 с | | M3 | `src/aircon`: шаблоны, конверсии+override, публичный API, batch | `tests/aircon` зелёные; на приборе: чтение всех свойств, batch=1 notify | diff --git a/include/fgl-aircon/version.hpp b/include/fgl-aircon/version.hpp new file mode 100644 index 0000000..272fd16 --- /dev/null +++ b/include/fgl-aircon/version.hpp @@ -0,0 +1,7 @@ +// Версия библиотеки fgl-aircon. +#pragma once + +#define FGL_AIRCON_VERSION_MAJOR 0 +#define FGL_AIRCON_VERSION_MINOR 1 +#define FGL_AIRCON_VERSION_PATCH 0 +#define FGL_AIRCON_VERSION_STRING "0.1.0" diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..8cb33b6 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# CI: сборка и тесты POSIX-платформы. +# ESP-IDF-сборка проверяется отдельно (см. README): +# . $IDF_PATH/export.sh && idf.py build (в тестовом проекте-компоненте) +set -euo pipefail +cd "$(dirname "$0")/.." + +BUILD_DIR="${BUILD_DIR:-build-ci}" +cmake -S . -B "$BUILD_DIR" -G Ninja -DCMAKE_BUILD_TYPE=Release + +cmake --build "$BUILD_DIR" +ctest --test-dir "$BUILD_DIR" --output-on-failure +echo "CI OK" diff --git a/src/ayla/httpd.cpp b/src/ayla/httpd.cpp new file mode 100644 index 0000000..1b6506f --- /dev/null +++ b/src/ayla/httpd.cpp @@ -0,0 +1,285 @@ +#include "ayla/httpd.hpp" + +#include +#include +#include +#include + +#include "ayla/log.hpp" + +namespace fgl::ayla { + +struct ThreadCtx { + HttpServer* self; +}; + +namespace { + +// Статусные строки для используемых кодов. +struct StatusText { int code; const char* text; }; +constexpr StatusText kStatusTexts[] = { + {200, "OK"}, {206, "Partial Content"}, {400, "Bad Request"}, + {401, "Unauthorized"}, {404, "Not Found"}, {412, "Precondition Failed"}, + {426, "Upgrade Required"}, {500, "Internal Server Error"}, +}; + +const char* status_text(int code) { + for (const auto& st : kStatusTexts) { + if (st.code == code) return st.text; + } + return "Unknown"; +} + +bool recv_line(int fd, char* buf, size_t buflen, size_t* out_len) { + size_t len = 0; + for (;;) { + if (len + 1 >= buflen) return false; // слишком длинно + uint8_t ch; + long n = fgl::plat::tcp_recv(fd, &ch, 1); + if (n <= 0) return false; + if (ch == '\n') { + if (len > 0 && buf[len - 1] == '\r') len--; // CRLF + buf[len] = '\0'; + *out_len = len; + return true; + } + buf[len++] = static_cast(ch); + } +} + +// Регистронезависимое сравнение первых n символов. +bool ieq_prefix(const char* a, const char* b, size_t n) { + for (size_t i = 0; i < n; i++) { + char ca = a[i] | 0x20, cb = b[i] | 0x20; + if (ca != cb) return false; + } + return true; +} + +// Читает заголовки до пустой строки; суммарный объём ограничен +// kHttpdMaxHeadersTotal (защита от бесконечного стрима заголовков). +bool recv_headers(int fd, long* content_length, bool* connection_close) { + char line[kHttpdMaxHeaderLine]; + size_t len; + size_t total = 2; // завершающий CRLF + *content_length = 0; + *connection_close = false; + for (;;) { + if (!recv_line(fd, line, sizeof(line), &len)) return false; + if (len == 0) return true; // конец заголовков + total += len + 2; + if (total > kHttpdMaxHeadersTotal) return false; + if (ieq_prefix(line, "Content-Length:", 15)) { + *content_length = strtol(line + 15, nullptr, 10); + } else if (ieq_prefix(line, "Connection:", 11)) { + const char* v = line + 11; + while (*v == ' ') v++; + if (ieq_prefix(v, "close", 5)) *connection_close = true; + } + } +} + +// close_after — добавить Connection: close и не ждать продолжения. +bool send_response(int fd, const HttpResponse& resp, bool close_after) { + char head[192]; + int n = snprintf(head, sizeof(head), + "HTTP/1.1 %d %s\r\n" + "Content-Type: %s\r\n" + "Content-Length: %u\r\n" + "Connection: %s\r\n" + "\r\n", + resp.status, status_text(resp.status), resp.content_type, + static_cast(resp.body_len), + close_after ? "close" : "keep-alive"); + if (n <= 0 || static_cast(n) >= sizeof(head)) return false; + if (fgl::plat::tcp_send(fd, head, static_cast(n)) != + static_cast(n)) { + return false; + } + if (resp.body != nullptr && resp.body_len > 0) { + if (fgl::plat::tcp_send(fd, resp.body, resp.body_len) != + static_cast(resp.body_len)) { + return false; + } + } + return true; +} + +void parse_target(const char* full_target, HttpRequest* req) { + const char* q = strchr(full_target, '?'); + size_t path_len = (q != nullptr) ? static_cast(q - full_target) + : strlen(full_target); + if (path_len >= sizeof(req->target)) path_len = sizeof(req->target) - 1; + memcpy(req->target, full_target, path_len); + req->target[path_len] = '\0'; + req->query[0] = '\0'; + if (q != nullptr && q[1] != '\0') { + size_t qlen = strlen(q + 1); + if (qlen >= sizeof(req->query)) qlen = sizeof(req->query) - 1; + memcpy(req->query, q + 1, qlen); + req->query[qlen] = '\0'; + } +} + +constexpr uint32_t kHttpdThreadStack = 8192; +constexpr uint32_t kAcceptPollMs = 100; +constexpr uint32_t kClientRxTimeoutMs = 30000; +constexpr uint32_t kClientTxTimeoutMs = 10000; + +} // namespace + +HttpServer::~HttpServer() { stop(); } + +bool HttpServer::start(uint16_t port, HttpHandler handler, void* ctx, + const char* thread_name) { + if (running_.load(std::memory_order_acquire)) return false; + handler_ = handler; + ctx_ = ctx; + int fd = fgl::plat::tcp_listen(port); + if (fd < 0) { + FGL_LOGE("httpd: tcp_listen(%u) failed", static_cast(port)); + return false; + } + listen_fd_ = fd; + port_ = fgl::plat::tcp_local_port(fd); + + auto* tc = new (std::nothrow) ThreadCtx{this}; + if (tc == nullptr) { + fgl::plat::tcp_close(listen_fd_); + listen_fd_ = -1; + return false; + } + thread_ctx_ = tc; + running_.store(true, std::memory_order_release); + if (!fgl::plat::thread_create( + [](void* p) { + static_cast(p)->self->run(); + }, + tc, thread_name, kHttpdThreadStack, &thread_)) { + running_.store(false, std::memory_order_release); + delete tc; + thread_ctx_ = nullptr; + fgl::plat::tcp_close(listen_fd_); + listen_fd_ = -1; + return false; + } + FGL_LOGI("httpd: listening on port %u", static_cast(port_)); + return true; +} + +void HttpServer::stop() { + if (!running_.exchange(false, std::memory_order_acq_rel)) { + return; + } + // Прерываем активное соединение, чтобы поток не ждал rx-таймаута. + int active = active_fd_.exchange(-1, std::memory_order_acq_rel); + if (active >= 0) { + fgl::plat::tcp_shutdown(active); + } + // Гарантированно ждём завершения потока; poll-цикл замечает running_ + // в пределах kAcceptPollMs. + if (thread_ != nullptr) { + fgl::plat::thread_join(thread_); + thread_ = nullptr; + } + delete thread_ctx_; + thread_ctx_ = nullptr; + if (listen_fd_ >= 0) { + fgl::plat::tcp_close(listen_fd_); + listen_fd_ = -1; + } +} + +void HttpServer::run() { + while (running_.load(std::memory_order_acquire)) { + if (!fgl::plat::tcp_poll_readable(listen_fd_, kAcceptPollMs)) { + continue; // таймаут поллинга — перепроверяем running_ + } + uint32_t peer_ip = 0; + uint16_t peer_port = 0; + int fd = fgl::plat::tcp_accept(listen_fd_, &peer_ip, &peer_port); + if (fd < 0) { + if (!running_.load(std::memory_order_acquire)) break; + fgl::plat::sleep_ms(20); + continue; + } + fgl::plat::tcp_set_timeout(fd, kClientRxTimeoutMs, kClientTxTimeoutMs); + active_fd_.store(fd, std::memory_order_release); + if (!running_.load(std::memory_order_acquire)) { + // stop() мог произойти в зазоре accept->store: закрываем и выходим. + // (Порядок store(fd)->load(running_) против exchange-ов в stop() + // закрывает все интерливинги: либо stop() увидит fd и shutdown'нет.) + active_fd_.store(-1, std::memory_order_release); + fgl::plat::tcp_close(fd); + break; + } + handle_connection(fd, peer_ip, peer_port); + active_fd_.store(-1, std::memory_order_release); + fgl::plat::tcp_close(fd); + } +} + +void HttpServer::handle_connection(int fd, uint32_t peer_ip, uint16_t peer_port) { + while (running_.load(std::memory_order_acquire)) { // keep-alive + char line[kHttpdMaxTarget + kHttpdMaxQuery + 16]; + size_t len; + if (!recv_line(fd, line, sizeof(line), &len)) return; + + HttpRequest req {}; + req.peer_ip = peer_ip; + req.peer_port = peer_port; + + // " HTTP/x.y" + char* sp1 = strchr(line, ' '); + if (sp1 == nullptr) return; + *sp1 = '\0'; + if (strlen(line) >= sizeof(req.method)) return; + strcpy(req.method, line); + char* target = sp1 + 1; + char* sp2 = strrchr(target, ' '); + if (sp2 == nullptr) return; + *sp2 = '\0'; + parse_target(target, &req); + + long content_length = 0; + bool connection_close = false; + if (!recv_headers(fd, &content_length, &connection_close)) return; + if (content_length < 0 || + static_cast(content_length) > kHttpdMaxBody) { + HttpResponse resp; + resp.status = 400; + send_response(fd, resp, /*close_after=*/true); + // shutdown до close предотвращает RST, обгоняющий ответ + // (recv после SHUT_RDWR сразу вернёт 0, дренаж не нужен). + fgl::plat::tcp_shutdown(fd); + return; + } + if (content_length > 0) { + size_t need = static_cast(content_length); + size_t done = 0; + while (done < need) { + long n = fgl::plat::tcp_recv(fd, req.body + done, need - done); + if (n <= 0) return; + done += static_cast(n); + } + req.body_len = done; + } + + FGL_LOGD("httpd: %s %s?%s from %u.%u.%u.%u", req.method, req.target, + req.query, static_cast((peer_ip >> 24) & 0xffu), + static_cast((peer_ip >> 16) & 0xffu), + static_cast((peer_ip >> 8) & 0xffu), + static_cast(peer_ip & 0xffu)); + + HttpResponse resp; // по умолчанию 404 — «пустой httpd» + resp.status = 404; + bool keep = true; + if (handler_ != nullptr) { + keep = handler_(req, resp, ctx_); + } + if (!send_response(fd, resp, /*close_after=*/!keep)) return; + if (!keep || connection_close) return; + } +} + +} // namespace fgl::ayla diff --git a/src/ayla/httpd.hpp b/src/ayla/httpd.hpp new file mode 100644 index 0000000..abcfa3d --- /dev/null +++ b/src/ayla/httpd.hpp @@ -0,0 +1,78 @@ +// Мини-HTTP/1.1 сервер для входящих запросов модуля кондиционера. +// Один поток, последовательная обработка соединений, keep-alive. +// Поведенческие требования — docs/PROTOCOL.md §4-5: пути /local_lan/*, +// коды 200/206/401/412/426, Content-Length-фрейминг, RST-обрывы — норма. +#pragma once + +#include +#include +#include + +#include "ayla/platform/platform.hpp" + +namespace fgl::ayla { + +constexpr size_t kHttpdMaxTarget = 128; // путь без query +constexpr size_t kHttpdMaxQuery = 96; // query-строка без '?' +constexpr size_t kHttpdMaxHeaderLine = 256; // одна строка заголовка +constexpr size_t kHttpdMaxBody = 2048; // тело запроса (envelope ~0.5КБ) +constexpr size_t kHttpdMaxHeadersTotal = 1024; // суммарный лимит заголовков + +struct HttpRequest { + char method[8]; + char target[kHttpdMaxTarget]; // путь без query + char query[kHttpdMaxQuery]; // query-строка без '?' + uint8_t body[kHttpdMaxBody]; + size_t body_len; + uint32_t peer_ip; // host byte order + uint16_t peer_port; +}; + +struct HttpResponse { + int status = 200; // HTTP-код + const char* content_type = "application/json"; + const uint8_t* body = nullptr; + size_t body_len = 0; +}; + +// Обработчик: заполняет resp. Возвращает true — соединение продолжается +// (keep-alive), false — закрыть после ответа. +using HttpHandler = bool (*)(const HttpRequest& req, HttpResponse& resp, + void* ctx); + +class HttpServer { + public: + HttpServer() = default; + ~HttpServer(); + + HttpServer(const HttpServer&) = delete; + HttpServer& operator=(const HttpServer&) = delete; + + // Запускает сервер (собственный поток). port==0 — любой свободный + // (узнать выбранный: port()). + // Возвращает false при ошибке bind/listen. + bool start(uint16_t port, HttpHandler handler, void* ctx, + const char* thread_name = "fgl_httpd"); + // Останавливает сервер и гарантированно завершает его поток + // (активное соединение прерывается shutdown'ом). + // КОНТРАКТ: start()/stop() вызываются из одного (управляющего) потока. + void stop(); + + uint16_t port() const { return port_; } + bool is_running() const { return running_.load(std::memory_order_acquire); } + + private: + void run(); + void handle_connection(int fd, uint32_t peer_ip, uint16_t peer_port); + + HttpHandler handler_ = nullptr; + void* ctx_ = nullptr; + int listen_fd_ = -1; + uint16_t port_ = 0; + fgl::plat::ThreadId thread_ = nullptr; // владелец-поток, join в stop() + struct ThreadCtx* thread_ctx_ = nullptr; + std::atomic running_{false}; + std::atomic active_fd_{-1}; // fd обрабатываемого соединения +}; + +} // namespace fgl::ayla diff --git a/src/ayla/log.cpp b/src/ayla/log.cpp new file mode 100644 index 0000000..a51b018 --- /dev/null +++ b/src/ayla/log.cpp @@ -0,0 +1,42 @@ +#include "log.hpp" + +#include +#include + +namespace fgl::ayla::log { + +namespace { +Sink g_sink = nullptr; +void* g_sink_ctx = nullptr; +int g_min_level = kInfo; +constexpr size_t kMaxLine = 256; +} // namespace + +void set_sink(Sink sink, void* ctx) { + g_sink = sink; + g_sink_ctx = ctx; +} + +int min_level() { return g_min_level; } +void set_min_level(int level) { g_min_level = level; } + +void write(int level, const char* fmt, ...) { + if (g_sink == nullptr || level < g_min_level) { + return; + } + char buf[kMaxLine]; + va_list args; + va_start(args, fmt); + int n = vsnprintf(buf, sizeof(buf), fmt, args); + va_end(args); + if (n < 0) { + return; + } + size_t len = static_cast(n); + if (len >= sizeof(buf)) { + len = sizeof(buf) - 1; // обрезано + } + g_sink(level, buf, len, g_sink_ctx); +} + +} // namespace fgl::ayla::log diff --git a/src/ayla/log.hpp b/src/ayla/log.hpp new file mode 100644 index 0000000..50a8f72 --- /dev/null +++ b/src/ayla/log.hpp @@ -0,0 +1,32 @@ +// Логирование ядра: без printf в stdout, вывод через настраиваемый sink. +// Вызовы из внутреннего потока сессии; sink должен быть быстрым и реентерабельным. +#pragma once + +#include + +namespace fgl::ayla::log { + +enum Level : int { + kDebug = 0, + kInfo = 1, + kWarn = 2, + kError = 3, +}; + +using Sink = void (*)(int level, const char* msg, size_t len, void* ctx); + +// Устанавливает приёмник логов (глобально). nullptr — логирование отключено. +void set_sink(Sink sink, void* ctx); + +// Форматирует в фиксированный буфер (без кучи) и отправляет в sink. +void write(int level, const char* fmt, ...) __attribute__((format(printf, 2, 3))); + +int min_level(); // по умолчанию kInfo +void set_min_level(int level); + +} // namespace fgl::ayla::log + +#define FGL_LOGD(...) ::fgl::ayla::log::write(::fgl::ayla::log::kDebug, __VA_ARGS__) +#define FGL_LOGI(...) ::fgl::ayla::log::write(::fgl::ayla::log::kInfo, __VA_ARGS__) +#define FGL_LOGW(...) ::fgl::ayla::log::write(::fgl::ayla::log::kWarn, __VA_ARGS__) +#define FGL_LOGE(...) ::fgl::ayla::log::write(::fgl::ayla::log::kError, __VA_ARGS__) diff --git a/src/ayla/platform/esp-idf/platform.cpp b/src/ayla/platform/esp-idf/platform.cpp new file mode 100644 index 0000000..bada591 --- /dev/null +++ b/src/ayla/platform/esp-idf/platform.cpp @@ -0,0 +1,214 @@ +// ESP-IDF-реализация платформенного слоя (lwip + esp_timer + esp_random). +// Потоки — через pthread-слой ESP-IDF (поддерживается pthread_join). +#ifdef ESP_PLATFORM + +#include "ayla/platform/platform.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +#include "esp_random.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include + +namespace fgl::plat { + +uint64_t now_ms() { + return static_cast(esp_timer_get_time()) / 1000ull; +} + +bool random(uint8_t* buf, size_t len) { + esp_fill_random(buf, len); + return true; +} + +namespace { +struct ThreadStart { + void (*fn)(void*); + void* ctx; +}; + +void* thread_trampoline(void* arg) { + auto* start = static_cast(arg); + ThreadStart tmp = *start; + delete start; + tmp.fn(tmp.ctx); + return nullptr; +} +} // namespace + +bool thread_create(void (*fn)(void*), void* ctx, const char* name, + uint32_t stack_bytes, ThreadId* out_id) { + auto* start = new (std::nothrow) ThreadStart{fn, ctx}; + if (start == nullptr) return false; + pthread_t tid; + pthread_attr_t attr; + pthread_attr_init(&attr); + // Стек ESP-IDF по умолчанию мал для httpd; задаём явно. + if (stack_bytes > 0) pthread_attr_setstacksize(&attr, stack_bytes); + int rc = pthread_create(&tid, &attr, thread_trampoline, start); + pthread_attr_destroy(&attr); + if (rc != 0) { + delete start; + return false; + } + pthread_setname_np(tid, name != nullptr ? name : "fgl"); + if (out_id != nullptr) { + *out_id = reinterpret_cast(tid); + } else { + pthread_detach(tid); + } + return true; +} + +void thread_join(ThreadId id) { + pthread_join(reinterpret_cast(id), nullptr); +} + +void sleep_ms(uint32_t ms) { vTaskDelay(pdMS_TO_TICKS(ms)); } + +int tcp_listen(uint16_t port) { + int fd = lwip_socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) return -1; + int one = 1; + lwip_setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + struct sockaddr_in addr {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); + addr.sin_port = htons(port); + if (lwip_bind(fd, reinterpret_cast(&addr), sizeof(addr)) < 0 || + lwip_listen(fd, 4) < 0) { + lwip_close(fd); + return -1; + } + return fd; +} + +uint16_t tcp_local_port(int fd) { + struct sockaddr_in addr {}; + socklen_t addrlen = sizeof(addr); + if (lwip_getsockname(fd, reinterpret_cast(&addr), &addrlen) != 0) { + return 0; + } + return ntohs(addr.sin_port); +} + +int tcp_accept(int listen_fd, uint32_t* peer_ip, uint16_t* peer_port) { + struct sockaddr_in addr {}; + socklen_t addrlen = sizeof(addr); + int fd = lwip_accept(listen_fd, reinterpret_cast(&addr), &addrlen); + if (fd < 0) return -1; + int one = 1; + lwip_setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)); + if (peer_ip != nullptr) *peer_ip = ntohl(addr.sin_addr.s_addr); + if (peer_port != nullptr) *peer_port = ntohs(addr.sin_port); + return fd; +} + +int tcp_connect(const char* host, uint16_t port, uint32_t timeout_ms) { + struct addrinfo hints {}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + struct addrinfo* list = nullptr; + if (lwip_getaddrinfo(host, nullptr, &hints, &list) != 0 || list == nullptr) { + return -1; + } + int fd = -1; + for (struct addrinfo* ai = list; ai != nullptr; ai = ai->ai_next) { + auto* addr = reinterpret_cast(ai->ai_addr); + addr->sin_port = htons(port); + fd = lwip_socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); + if (fd < 0) continue; + int flags = lwip_fcntl(fd, F_GETFL, 0); + lwip_fcntl(fd, F_SETFL, flags | O_NONBLOCK); + int rc = lwip_connect(fd, ai->ai_addr, ai->ai_addrlen); + if (rc == 0) { + lwip_fcntl(fd, F_SETFL, flags); + break; + } + if (errno == EINPROGRESS) { + fd_set wfds; + FD_ZERO(&wfds); + FD_SET(fd, &wfds); + struct timeval tv {}; + tv.tv_sec = timeout_ms / 1000; + tv.tv_usec = static_cast(timeout_ms % 1000) * 1000L; + if (lwip_select(fd + 1, nullptr, &wfds, nullptr, &tv) > 0) { + int soerr = 0; + socklen_t slen = sizeof(soerr); + lwip_getsockopt(fd, SOL_SOCKET, SO_ERROR, &soerr, &slen); + if (soerr == 0) { + lwip_fcntl(fd, F_SETFL, flags); + break; + } + } + } + lwip_close(fd); + fd = -1; + } + lwip_freeaddrinfo(list); + return fd; +} + +long tcp_send(int fd, const void* buf, size_t len) { + const uint8_t* p = static_cast(buf); + size_t done = 0; + while (done < len) { + long n = lwip_send(fd, p + done, len - done, 0); + if (n < 0) { + if (errno == EINTR) continue; + return -1; + } + done += static_cast(n); + } + return static_cast(done); +} + +long tcp_recv(int fd, void* buf, size_t len) { + for (;;) { + long n = lwip_recv(fd, buf, len, 0); + if (n < 0 && errno == EINTR) continue; + return n; + } +} + +bool tcp_set_timeout(int fd, uint32_t rx_ms, uint32_t tx_ms) { + struct timeval tv {}; + tv.tv_sec = rx_ms / 1000; + tv.tv_usec = static_cast(rx_ms % 1000) * 1000L; + if (lwip_setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) != 0) return false; + tv.tv_sec = tx_ms / 1000; + tv.tv_usec = static_cast(tx_ms % 1000) * 1000L; + return lwip_setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)) == 0; +} + +bool tcp_poll_readable(int fd, uint32_t timeout_ms) { + fd_set rfds; + FD_ZERO(&rfds); + FD_SET(fd, &rfds); + struct timeval tv {}; + tv.tv_sec = timeout_ms / 1000; + tv.tv_usec = static_cast(timeout_ms % 1000) * 1000L; + return lwip_select(fd + 1, &rfds, nullptr, nullptr, &tv) > 0; +} + +bool tcp_shutdown(int fd) { + return lwip_shutdown(fd, SHUT_RDWR) == 0; +} + +void tcp_close(int fd) { + if (fd >= 0) lwip_close(fd); +} + +} // namespace fgl::plat + +#endif // ESP_PLATFORM diff --git a/src/ayla/platform/platform.hpp b/src/ayla/platform/platform.hpp new file mode 100644 index 0000000..cab4e7c --- /dev/null +++ b/src/ayla/platform/platform.hpp @@ -0,0 +1,57 @@ +// Платформенный слой: сокеты, таймеры, CSPRNG, потоки. +// Одна реализация на платформу: platform/posix (Linux) и platform/esp-idf. +// Все функции — блокирующие; коды ошибок через возвращаемое значение (-1), +// без исключений. IP-адреса — uint32_t в host byte order. +#pragma once + +#include +#include + +namespace fgl::plat { + +// ---- время ---- +// Монотонные миллисекунды с неопределённой точки отсчёта. +uint64_t now_ms(); + +// ---- CSPRNG ---- +// Заполняет buf cryptographically-secure случайными байтами. +bool random(uint8_t* buf, size_t len); + +// ---- потоки ---- +// Идентификатор потока (непрозрачный). Валиден между thread_create и +// завершением thread_join. +using ThreadId = void*; +// Создаёт поток с именем name (для отладки) и стеком stack_bytes (0 — по +// умолчанию платформы; для ESP-IDF задавать явно, >= 4096). +// out_id может быть nullptr (тогда присоединиться нельзя). +bool thread_create(void (*fn)(void*), void* ctx, const char* name, + uint32_t stack_bytes, ThreadId* out_id); +// Блокирующе ждёт завершения потока. Гарантирует, что код потока больше не +// исполняется (объекты потока можно освобождать после возврата). +void thread_join(ThreadId id); +// Мягкая блокирующая задержка текущего потока. +void sleep_ms(uint32_t ms); + +// ---- TCP (клиент и сервер) ---- +// Создаёт слушающий сокет; возвращает fd или -1. port==0 — любой свободный +// (узнать выбранный: tcp_local_port). +int tcp_listen(uint16_t port); +// Локальный порт сокета (для ephemeral-слушателем). +uint16_t tcp_local_port(int fd); +// Принимает соединение (блокирующе); возвращает fd клиента или -1. +// peer_ip (host byte order) / peer_port могут быть nullptr. +int tcp_accept(int listen_fd, uint32_t* peer_ip, uint16_t* peer_port); +// Подключается к host:port (host — DNS-имя или dotted-quad) с таймаутом. +int tcp_connect(const char* host, uint16_t port, uint32_t timeout_ms); +long tcp_send(int fd, const void* buf, size_t len); // >0 / -1 +// Блокирующее чтение; 0 — EOF, -1 — ошибка/таймаут. +long tcp_recv(int fd, void* buf, size_t len); +// Таймауты на последующие recv/send (0 — без таймаута). +bool tcp_set_timeout(int fd, uint32_t rx_ms, uint32_t tx_ms); +// Ожидание читаемости fd (для выходa из блокирующего accept). Таймаут в мс. +bool tcp_poll_readable(int fd, uint32_t timeout_ms); +// Прерывает блокированные recv/send на сокете (для остановки сервера). +bool tcp_shutdown(int fd); +void tcp_close(int fd); + +} // namespace fgl::plat diff --git a/src/ayla/platform/posix/platform.cpp b/src/ayla/platform/posix/platform.cpp new file mode 100644 index 0000000..bca64ba --- /dev/null +++ b/src/ayla/platform/posix/platform.cpp @@ -0,0 +1,216 @@ +// POSIX-реализация платформенного слоя (Linux). +#include "ayla/platform/platform.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace fgl::plat { + +uint64_t now_ms() { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return static_cast(ts.tv_sec) * 1000u + + static_cast(ts.tv_nsec) / 1000000u; +} + +bool random(uint8_t* buf, size_t len) { + size_t done = 0; + while (done < len) { + ssize_t n = ::getrandom(buf + done, len - done, 0); + if (n < 0) { + if (errno == EINTR) continue; + return false; + } + done += static_cast(n); + } + return true; +} + +namespace { +struct ThreadStart { + void (*fn)(void*); + void* ctx; +}; + +void* thread_trampoline(void* arg) { + auto* start = static_cast(arg); + ThreadStart tmp = *start; + delete start; + tmp.fn(tmp.ctx); + return nullptr; +} +} // namespace + +bool thread_create(void (*fn)(void*), void* ctx, const char* name, + uint32_t stack_bytes, ThreadId* out_id) { + auto* start = new (std::nothrow) ThreadStart{fn, ctx}; + if (start == nullptr) return false; + pthread_t tid; + pthread_attr_t attr; + pthread_attr_init(&attr); + if (stack_bytes > 0) pthread_attr_setstacksize(&attr, stack_bytes); + int rc = pthread_create(&tid, &attr, thread_trampoline, start); + pthread_attr_destroy(&attr); + if (rc != 0) { + delete start; + return false; + } + pthread_setname_np(tid, name != nullptr ? name : "fgl"); + if (out_id != nullptr) { + *out_id = reinterpret_cast(tid); + } else { + pthread_detach(tid); + } + return true; +} + +void thread_join(ThreadId id) { + auto tid = reinterpret_cast(id); + pthread_join(tid, nullptr); +} + +void sleep_ms(uint32_t ms) { + struct timespec req; + req.tv_sec = ms / 1000; + req.tv_nsec = static_cast(ms % 1000) * 1000000L; + while (nanosleep(&req, &req) == -1 && errno == EINTR) { + } +} + +int tcp_listen(uint16_t port) { + int fd = ::socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) return -1; + int one = 1; + ::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + struct sockaddr_in addr {}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); + addr.sin_port = htons(port); + if (::bind(fd, reinterpret_cast(&addr), sizeof(addr)) < 0 || + ::listen(fd, 4) < 0) { + ::close(fd); + return -1; + } + return fd; +} + +uint16_t tcp_local_port(int fd) { + struct sockaddr_in addr {}; + socklen_t addrlen = sizeof(addr); + if (::getsockname(fd, reinterpret_cast(&addr), &addrlen) != 0) { + return 0; + } + return ntohs(addr.sin_port); +} + +int tcp_accept(int listen_fd, uint32_t* peer_ip, uint16_t* peer_port) { + struct sockaddr_in addr {}; + socklen_t addrlen = sizeof(addr); + int fd = ::accept(listen_fd, reinterpret_cast(&addr), &addrlen); + if (fd < 0) return -1; + int one = 1; + ::setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)); + if (peer_ip != nullptr) *peer_ip = ntohl(addr.sin_addr.s_addr); + if (peer_port != nullptr) *peer_port = ntohs(addr.sin_port); + return fd; +} + +int tcp_connect(const char* host, uint16_t port, uint32_t timeout_ms) { + struct addrinfo hints {}; + hints.ai_family = AF_INET; + hints.ai_socktype = SOCK_STREAM; + struct addrinfo* list = nullptr; + if (::getaddrinfo(host, nullptr, &hints, &list) != 0 || list == nullptr) { + return -1; + } + int fd = -1; + for (struct addrinfo* ai = list; ai != nullptr; ai = ai->ai_next) { + auto* addr = reinterpret_cast(ai->ai_addr); + addr->sin_port = htons(port); + fd = ::socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); + if (fd < 0) continue; + // Неблокирующее подключение + poll для таймаута. + int flags = ::fcntl(fd, F_GETFL, 0); + ::fcntl(fd, F_SETFL, flags | O_NONBLOCK); + int rc = ::connect(fd, ai->ai_addr, ai->ai_addrlen); + if (rc == 0) { + ::fcntl(fd, F_SETFL, flags); + break; + } + if (errno == EINPROGRESS) { + struct pollfd pfd {fd, POLLOUT, 0}; + if (::poll(&pfd, 1, static_cast(timeout_ms)) > 0) { + int soerr = 0; + socklen_t slen = sizeof(soerr); + ::getsockopt(fd, SOL_SOCKET, SO_ERROR, &soerr, &slen); + if (soerr == 0) { + ::fcntl(fd, F_SETFL, flags); + break; + } + } + } + ::close(fd); + fd = -1; + } + ::freeaddrinfo(list); + return fd; +} + +long tcp_send(int fd, const void* buf, size_t len) { + const uint8_t* p = static_cast(buf); + size_t done = 0; + while (done < len) { + long n = ::send(fd, p + done, len - done, MSG_NOSIGNAL); + if (n < 0) { + if (errno == EINTR) continue; + return -1; + } + done += static_cast(n); + } + return static_cast(done); +} + +long tcp_recv(int fd, void* buf, size_t len) { + for (;;) { + long n = ::recv(fd, buf, len, 0); + if (n < 0 && errno == EINTR) continue; + return n; + } +} + +bool tcp_set_timeout(int fd, uint32_t rx_ms, uint32_t tx_ms) { + struct timeval tv {}; + tv.tv_sec = rx_ms / 1000; + tv.tv_usec = static_cast(rx_ms % 1000) * 1000L; + if (::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) != 0) return false; + tv.tv_sec = tx_ms / 1000; + tv.tv_usec = static_cast(tx_ms % 1000) * 1000L; + return ::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)) == 0; +} + +bool tcp_poll_readable(int fd, uint32_t timeout_ms) { + struct pollfd pfd {fd, POLLIN, 0}; + return ::poll(&pfd, 1, static_cast(timeout_ms)) > 0; +} + +bool tcp_shutdown(int fd) { + return ::shutdown(fd, SHUT_RDWR) == 0; +} + +void tcp_close(int fd) { + if (fd >= 0) ::close(fd); +} + +} // namespace fgl::plat diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..bd33e09 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,24 @@ +# Тесты ядра. Структура зеркалит src: tests/ayla — протоколная часть, +# tests/aircon — конверсии/шаблоны (появятся в M3). +# +# Тесты собираются с исключениями (фреймворк), сама библиотека — без. + +include(FetchContent) +FetchContent_Declare( + doctest + GIT_REPOSITORY https://github.com/doctest/doctest.git + GIT_TAG v2.4.12 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(doctest) + +function(fgl_add_test name) + add_executable(test_${name} ${ARGN}) + target_compile_features(test_${name} PRIVATE cxx_std_20) + target_link_libraries(test_${name} PRIVATE fgl-aircon doctest_with_main) + target_include_directories(test_${name} PRIVATE "${CMAKE_SOURCE_DIR}/src") + add_test(NAME ${name} COMMAND test_${name}) +endfunction() + +fgl_add_test(ayla_platform ayla/test_platform.cpp) +fgl_add_test(ayla_httpd ayla/test_httpd.cpp) diff --git a/tests/ayla/test_httpd.cpp b/tests/ayla/test_httpd.cpp new file mode 100644 index 0000000..5d26907 --- /dev/null +++ b/tests/ayla/test_httpd.cpp @@ -0,0 +1,224 @@ +// Тесты мини-httpd: парсинг запросов, keep-alive, 404 по умолчанию, +// корректный stop при живом keep-alive соединении. +#include "doctest/doctest.h" + +#include +#include + +#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(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(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(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(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(ctx); + h->last_req = req; + h->calls++; + resp.status = 200; + static const char kBody[] = "{\"ok\":true}"; + resp.body = reinterpret_cast(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(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(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(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(); +} diff --git a/tests/ayla/test_platform.cpp b/tests/ayla/test_platform.cpp new file mode 100644 index 0000000..25ebb09 --- /dev/null +++ b/tests/ayla/test_platform.cpp @@ -0,0 +1,108 @@ +// Тесты платформенного слоя (POSIX-путь; на ESP-IDF тесты не запускаются — +// верификация IDF-слоя сборкой и on-device приёмкой). +#include "doctest/doctest.h" + +#include +#include +#include + +#include "ayla/platform/platform.hpp" + +TEST_CASE("now_ms монотонен") { + uint64_t a = fgl::plat::now_ms(); + fgl::plat::sleep_ms(30); + uint64_t b = fgl::plat::now_ms(); + CHECK(b >= a + 25); + CHECK(b - a < 1000); +} + +TEST_CASE("random заполняет и не повторяется тривиально") { + uint8_t a[16] = {}, b[16] = {}; + REQUIRE(fgl::plat::random(a, sizeof(a))); + REQUIRE(fgl::plat::random(b, sizeof(b))); + CHECK_FALSE(memcmp(a, b, sizeof(a)) == 0); + bool all_zero = true; + for (uint8_t x : a) all_zero = all_zero && (x == 0); + CHECK_FALSE(all_zero); +} + +TEST_CASE("thread_create + thread_join: поток завершён после join") { + struct Ctx { + std::atomic ran{false}; + std::atomic finished{false}; + }; + Ctx c; + fgl::plat::ThreadId tid = nullptr; + REQUIRE(fgl::plat::thread_create( + [](void* p) { + auto* x = static_cast(p); + x->ran.store(true); + fgl::plat::sleep_ms(100); + x->finished.store(true); + }, + &c, "t_join", 0, &tid)); + REQUIRE(tid != nullptr); + CHECK_FALSE(c.finished.load()); + fgl::plat::thread_join(tid); // вернётся после завершения потока + CHECK(c.ran.load()); + CHECK(c.finished.load()); +} + +TEST_CASE("tcp loopback: ephemeral-порт, connect/send/recv/echo, shutdown") { + int listen_fd = fgl::plat::tcp_listen(0); + REQUIRE(listen_fd >= 0); + const uint16_t port = fgl::plat::tcp_local_port(listen_fd); + REQUIRE(port != 0); + + struct SrvCtx { + int lfd; + int cfd = -1; + std::atomic accepted{false}; + std::atomic echoed{false}; + std::atomic peer_ip{0}; + }; + SrvCtx srv; + srv.lfd = listen_fd; + fgl::plat::ThreadId tid = nullptr; + REQUIRE(fgl::plat::thread_create( + [](void* p) { + auto* s = static_cast(p); + uint32_t ip = 0; + uint16_t peer_port = 0; + int cfd = fgl::plat::tcp_accept(s->lfd, &ip, &peer_port); + if (cfd < 0) return; + s->cfd = cfd; + s->accepted.store(true); + s->peer_ip.store(ip); + fgl::plat::tcp_set_timeout(cfd, 5000, 5000); + char buf[16]; + long n = fgl::plat::tcp_recv(cfd, buf, sizeof(buf)); + if (n > 0) { + fgl::plat::tcp_send(cfd, buf, static_cast(n)); + s->echoed.store(true); + // ждём возможного второго запроса (для проверки shutdown ниже) + char buf2[16]; + fgl::plat::tcp_recv(cfd, buf2, sizeof(buf2)); + } + fgl::plat::tcp_close(cfd); + }, + &srv, "t_srv", 0, &tid)); + + int cfd = fgl::plat::tcp_connect("127.0.0.1", port, 2000); + REQUIRE(cfd >= 0); + REQUIRE(fgl::plat::tcp_send(cfd, "ping", 4) == 4); + fgl::plat::tcp_set_timeout(cfd, 2000, 2000); + char buf[16] = {}; + REQUIRE(fgl::plat::tcp_recv(cfd, buf, sizeof(buf)) == 4); + CHECK(std::string(buf, 4) == "ping"); + fgl::plat::tcp_close(cfd); + + REQUIRE(srv.accepted.load()); + CHECK(srv.peer_ip.load() == 0x7f000001); // 127.0.0.1 в host byte order + REQUIRE(srv.echoed.load()); + + // shutdown прерывает блокированный recv сервера → поток завершается + fgl::plat::tcp_shutdown(srv.cfd); + fgl::plat::thread_join(tid); + fgl::plat::tcp_close(listen_fd); +}