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

4
.gitignore vendored
View File

@@ -9,3 +9,7 @@ venv/
# Локальный конфиг устройства (содержит lanip_key) # Локальный конфиг устройства (содержит lanip_key)
docs/legacy/config_kata.json docs/legacy/config_kata.json
# Сборка
build/
build-*/

57
CMakeLists.txt Normal file
View File

@@ -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()

View File

@@ -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 | | 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 с | | 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 | | M3 | `src/aircon`: шаблоны, конверсии+override, публичный API, batch | `tests/aircon` зелёные; на приборе: чтение всех свойств, batch=1 notify |

View File

@@ -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"

13
scripts/ci.sh Executable file
View File

@@ -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"

285
src/ayla/httpd.cpp Normal file
View File

@@ -0,0 +1,285 @@
#include "ayla/httpd.hpp"
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <new>
#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<char>(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<unsigned>(resp.body_len),
close_after ? "close" : "keep-alive");
if (n <= 0 || static_cast<size_t>(n) >= sizeof(head)) return false;
if (fgl::plat::tcp_send(fd, head, static_cast<size_t>(n)) !=
static_cast<long>(n)) {
return false;
}
if (resp.body != nullptr && resp.body_len > 0) {
if (fgl::plat::tcp_send(fd, resp.body, resp.body_len) !=
static_cast<long>(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<size_t>(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<unsigned>(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<ThreadCtx*>(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<unsigned>(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;
// "<METHOD> <target> 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<size_t>(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<size_t>(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<size_t>(n);
}
req.body_len = done;
}
FGL_LOGD("httpd: %s %s?%s from %u.%u.%u.%u", req.method, req.target,
req.query, static_cast<unsigned>((peer_ip >> 24) & 0xffu),
static_cast<unsigned>((peer_ip >> 16) & 0xffu),
static_cast<unsigned>((peer_ip >> 8) & 0xffu),
static_cast<unsigned>(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

78
src/ayla/httpd.hpp Normal file
View File

@@ -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 <atomic>
#include <cstddef>
#include <cstdint>
#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<bool> running_{false};
std::atomic<int> active_fd_{-1}; // fd обрабатываемого соединения
};
} // namespace fgl::ayla

42
src/ayla/log.cpp Normal file
View File

@@ -0,0 +1,42 @@
#include "log.hpp"
#include <cstdarg>
#include <cstdio>
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<size_t>(n);
if (len >= sizeof(buf)) {
len = sizeof(buf) - 1; // обрезано
}
g_sink(level, buf, len, g_sink_ctx);
}
} // namespace fgl::ayla::log

32
src/ayla/log.hpp Normal file
View File

@@ -0,0 +1,32 @@
// Логирование ядра: без printf в stdout, вывод через настраиваемый sink.
// Вызовы из внутреннего потока сессии; sink должен быть быстрым и реентерабельным.
#pragma once
#include <cstddef>
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__)

View File

@@ -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 <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <lwip/sockets.h>
#include <netdb.h>
#include <pthread.h>
#include <sys/select.h>
#include "esp_random.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <new>
namespace fgl::plat {
uint64_t now_ms() {
return static_cast<uint64_t>(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<ThreadStart*>(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<ThreadId>(tid);
} else {
pthread_detach(tid);
}
return true;
}
void thread_join(ThreadId id) {
pthread_join(reinterpret_cast<pthread_t>(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<struct sockaddr*>(&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<struct sockaddr*>(&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<struct sockaddr*>(&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<struct sockaddr_in*>(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<long>(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<const uint8_t*>(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<size_t>(n);
}
return static_cast<long>(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<long>(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<long>(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<long>(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

View File

@@ -0,0 +1,57 @@
// Платформенный слой: сокеты, таймеры, CSPRNG, потоки.
// Одна реализация на платформу: platform/posix (Linux) и platform/esp-idf.
// Все функции — блокирующие; коды ошибок через возвращаемое значение (-1),
// без исключений. IP-адреса — uint32_t в host byte order.
#pragma once
#include <cstddef>
#include <cstdint>
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

View File

@@ -0,0 +1,216 @@
// POSIX-реализация платформенного слоя (Linux).
#include "ayla/platform/platform.hpp"
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/tcp.h>
#include <netdb.h>
#include <poll.h>
#include <pthread.h>
#include <sys/random.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <time.h>
#include <unistd.h>
#include <new>
namespace fgl::plat {
uint64_t now_ms() {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return static_cast<uint64_t>(ts.tv_sec) * 1000u +
static_cast<uint64_t>(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<size_t>(n);
}
return true;
}
namespace {
struct ThreadStart {
void (*fn)(void*);
void* ctx;
};
void* thread_trampoline(void* arg) {
auto* start = static_cast<ThreadStart*>(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<ThreadId>(tid);
} else {
pthread_detach(tid);
}
return true;
}
void thread_join(ThreadId id) {
auto tid = reinterpret_cast<pthread_t>(id);
pthread_join(tid, nullptr);
}
void sleep_ms(uint32_t ms) {
struct timespec req;
req.tv_sec = ms / 1000;
req.tv_nsec = static_cast<long>(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<struct sockaddr*>(&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<struct sockaddr*>(&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<struct sockaddr*>(&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<struct sockaddr_in*>(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<int>(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<const uint8_t*>(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<size_t>(n);
}
return static_cast<long>(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<long>(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<long>(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<int>(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

24
tests/CMakeLists.txt Normal file
View File

@@ -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)

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();
}

View File

@@ -0,0 +1,108 @@
// Тесты платформенного слоя (POSIX-путь; на ESP-IDF тесты не запускаются —
// верификация IDF-слоя сборкой и on-device приёмкой).
#include "doctest/doctest.h"
#include <atomic>
#include <cstring>
#include <string>
#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<bool> ran{false};
std::atomic<bool> finished{false};
};
Ctx c;
fgl::plat::ThreadId tid = nullptr;
REQUIRE(fgl::plat::thread_create(
[](void* p) {
auto* x = static_cast<Ctx*>(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<bool> accepted{false};
std::atomic<bool> echoed{false};
std::atomic<uint32_t> 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<SrvCtx*>(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<size_t>(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);
}