diff --git a/README.md b/README.md index 16942cc..3089b57 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,8 @@ void setup() { }); WorkerResult result = worker.spawn([]() { - while (true) { - vTaskDelay(pdMS_TO_TICKS(250)); - } + Serial.println("[worker] running"); + vTaskDelay(pdMS_TO_TICKS(250)); }, { .stackSizeBytes = 16 * 1024, .priority = 3, @@ -52,8 +51,10 @@ void setup() { }); if (result) { - result.handler->wait(pdMS_TO_TICKS(1000)); + result.handler->wait(pdMS_TO_TICKS(1000)); // or destroy() for long-lived loops } + + worker.deinit(); } ``` @@ -73,12 +74,14 @@ Check the runnable examples under `examples/`: ## Gotchas - Always call `worker.init()` once before spawning tasks. Each ESPWorker instance controls its own limits. +- Call `worker.deinit()` during shutdown/reset paths. It is safe before `init()` and safe to call repeatedly. - `spawn` creates persistent FreeRTOS tasks; remember to end the lambda (return) or `destroy()` the handler to reclaim slots. - Errors such as `MaxWorkersReached`, `TaskCreateFailed`, or `ExternalStackUnsupported` are reported in the returned `WorkerResult` _and_ via the error callback. - PSRAM stack requests fail fast with `ExternalStackUnsupported` when caps-based task allocation is unavailable, PSRAM is missing, or external stacks are disabled. ## API Reference - `void init(const ESPWorker::Config& config)` – sets defaults (max workers, default stack-bytes/priority/core, PSRAM allowance). +- `void deinit()` / `bool isInitialized() const` – explicit teardown and lifecycle state checks; `deinit()` is idempotent and safe pre-init. - `WorkerResult spawn(TaskCallback cb, const WorkerConfig& config = {})` – create a worker. The returned handler provides `wait()` and `destroy()` helpers plus per-job diagnostics. - `WorkerResult spawnExt(...)` – identical to `spawn` but forces PSRAM stacks using `xTaskCreatePinnedToCoreWithCaps(...)`. - `size_t activeWorkers() const` / `void cleanupFinished()` – query or prune finished tasks. diff --git a/examples/basic_lambda_worker/basic_lambda_worker.ino b/examples/basic_lambda_worker/basic_lambda_worker.ino index ede7a28..603cb3f 100644 --- a/examples/basic_lambda_worker/basic_lambda_worker.ino +++ b/examples/basic_lambda_worker/basic_lambda_worker.ino @@ -18,12 +18,14 @@ void setup() { worker.init(workerConfig); // Spawn a default job - auto testJob = worker.spawn([](){ + auto testJob = worker.spawn([](){ Serial.println("[Worker] task is triggered!"); vTaskDelay(pdMS_TO_TICKS(1000)); }); testJob.handler->wait(); // Wait for the job to finish, indefinietly Serial.println("[Worker] task is completed!"); + + worker.deinit(); } void loop() {} diff --git a/examples/basic_worker/basic_worker.ino b/examples/basic_worker/basic_worker.ino index 05717c8..4833f76 100644 --- a/examples/basic_worker/basic_worker.ino +++ b/examples/basic_worker/basic_worker.ino @@ -26,6 +26,8 @@ void setup() { auto testJob = worker.spawn(jobFunction); testJob.handler->wait(); // Wait for the job to finish, indefinietly Serial.println("[Worker] task is completed!"); + + worker.deinit(); } void loop() {} diff --git a/examples/psram_stack/psram_stack.ino b/examples/psram_stack/psram_stack.ino index 086fc0b..40a1bbf 100644 --- a/examples/psram_stack/psram_stack.ino +++ b/examples/psram_stack/psram_stack.ino @@ -35,6 +35,8 @@ void setup() { testJob.handler->wait(); // Wait for the job to finish, indefinietly printPSRAM("After job completed"); + + worker.deinit(); } void loop() {} diff --git a/src/esp_worker/worker.cpp b/src/esp_worker/worker.cpp index 2fe5d7a..c9ec8e3 100644 --- a/src/esp_worker/worker.cpp +++ b/src/esp_worker/worker.cpp @@ -135,7 +135,8 @@ void ESPWorker::deinit() { std::vector> controls; { std::lock_guard guard(_mutex); - controls = _activeControls; + _initialized.store(false, std::memory_order_release); + controls.swap(_activeControls); } for (auto &control : controls) { @@ -150,18 +151,11 @@ void ESPWorker::deinit() { control->owner = nullptr; } - { - std::lock_guard guard(_mutex); - _activeControls.clear(); - } - { std::lock_guard guard(_callbackMutex); _eventCallback = nullptr; _errorCallback = nullptr; } - - _initialized = false; } bool WorkerHandler::valid() const { return static_cast(_control); } @@ -220,7 +214,7 @@ bool WorkerHandler::destroy() { void ESPWorker::init(const Config &config) { std::lock_guard guard(_mutex); _config = config; - _initialized = true; + _initialized.store(true, std::memory_order_release); } WorkerResult ESPWorker::spawn(TaskCallback callback, const WorkerConfig &config) { diff --git a/src/esp_worker/worker.h b/src/esp_worker/worker.h index c3f3897..6b779a3 100644 --- a/src/esp_worker/worker.h +++ b/src/esp_worker/worker.h @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -107,6 +108,7 @@ class ESPWorker { void init(const Config &config); void deinit(); + bool isInitialized() const { return _initialized.load(std::memory_order_acquire); } WorkerResult spawn(TaskCallback callback, const WorkerConfig &config = WorkerConfig{}); WorkerResult spawnExt(TaskCallback callback, const WorkerConfig &config = WorkerConfig{}); @@ -134,7 +136,7 @@ class ESPWorker { void notifyError(WorkerError error); Config _config{}; - bool _initialized = false; + std::atomic _initialized{false}; mutable std::mutex _mutex; std::vector> _activeControls; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt new file mode 100644 index 0000000..1125917 --- /dev/null +++ b/test/CMakeLists.txt @@ -0,0 +1,32 @@ +add_library(esp_worker_core STATIC + ${PROJECT_SOURCE_DIR}/src/esp_worker/worker.cpp +) + +target_include_directories(esp_worker_core + PUBLIC + ${PROJECT_SOURCE_DIR}/src + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/stubs +) + +target_compile_features(esp_worker_core PUBLIC cxx_std_17) + +add_executable(esp_worker_lifecycle_tests + esp_worker_lifecycle_tests.cpp + worker_test_stubs.cpp +) + +target_include_directories(esp_worker_lifecycle_tests + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/stubs +) + +target_link_libraries(esp_worker_lifecycle_tests + PRIVATE + esp_worker_core +) + +target_compile_features(esp_worker_lifecycle_tests PRIVATE cxx_std_17) + +add_test(NAME esp_worker_lifecycle_tests COMMAND esp_worker_lifecycle_tests) diff --git a/test/esp_worker_lifecycle_tests.cpp b/test/esp_worker_lifecycle_tests.cpp new file mode 100644 index 0000000..acf1538 --- /dev/null +++ b/test/esp_worker_lifecycle_tests.cpp @@ -0,0 +1,139 @@ +#include + +#include +#include +#include +#include + +#include "test_support.h" + +namespace { + +[[noreturn]] void fail(const std::string &message) { + throw std::runtime_error(message); +} + +void expectTrue(bool condition, const std::string &message) { + if (!condition) { + fail(message); + } +} + +void expectFalse(bool condition, const std::string &message) { + if (condition) { + fail(message); + } +} + +template +void expectEqual(const T &actual, const T &expected, const std::string &message) { + if (!(actual == expected)) { + fail(message); + } +} + +void testDeinitIsSafeBeforeInit() { + ESPWorker worker; + expectFalse(worker.isInitialized(), "worker should start deinitialized"); + expectEqual(worker.activeWorkers(), static_cast(0), "worker should start with no active jobs"); + + worker.deinit(); + expectFalse(worker.isInitialized(), "deinit before init should be a no-op"); + expectEqual(worker.activeWorkers(), static_cast(0), "deinit before init should keep worker count at zero"); +} + +void testDeinitIsIdempotent() { + ESPWorker worker; + ESPWorker::Config cfg{}; + cfg.maxWorkers = 4; + worker.init(cfg); + + expectTrue(worker.isInitialized(), "worker should be initialized"); + + worker.deinit(); + expectFalse(worker.isInitialized(), "worker should deinitialize cleanly"); + expectEqual(worker.activeWorkers(), static_cast(0), "deinit should clear worker state"); + + worker.deinit(); + expectFalse(worker.isInitialized(), "second deinit should remain safe"); + expectEqual(worker.activeWorkers(), static_cast(0), "second deinit should not reintroduce workers"); +} + +void testReinitLifecycleAfterDeinit() { + test_support::resetRuntime(); + + ESPWorker worker; + ESPWorker::Config cfg{}; + cfg.maxWorkers = 2; + worker.init(cfg); + expectTrue(worker.isInitialized(), "first init should succeed"); + + WorkerResult first = worker.spawn([]() {}); + expectTrue(static_cast(first), "spawn after first init should succeed"); + expectEqual(worker.activeWorkers(), static_cast(1), "first spawn should reserve one worker slot"); + + worker.deinit(); + expectFalse(worker.isInitialized(), "worker should be deinitialized after first lifecycle"); + expectEqual(worker.activeWorkers(), static_cast(0), "deinit should clear active workers"); + + worker.init(cfg); + expectTrue(worker.isInitialized(), "second init should succeed"); + + WorkerResult second = worker.spawn([]() {}); + expectTrue(static_cast(second), "spawn after reinit should succeed"); + expectEqual(worker.activeWorkers(), static_cast(1), "second spawn should reserve one worker slot"); + + worker.deinit(); +} + +void testDeinitReleasesActiveTaskHandles() { + test_support::resetRuntime(); + + ESPWorker worker; + worker.init(ESPWorker::Config{}); + WorkerResult first = worker.spawn([]() {}); + WorkerResult second = worker.spawn([]() {}); + + expectTrue(static_cast(first), "first spawn should succeed"); + expectTrue(static_cast(second), "second spawn should succeed"); + expectEqual(worker.activeWorkers(), static_cast(2), "two workers should be active before teardown"); + expectEqual(test_support::createdTaskCount(), static_cast(2), "stubs should observe two created tasks"); + + worker.deinit(); + + expectFalse(worker.isInitialized(), "worker should be deinitialized"); + expectEqual(worker.activeWorkers(), static_cast(0), "deinit should clear active workers"); + expectEqual(test_support::deletedTaskCount(), static_cast(2), "deinit should release all active task handles"); +} + +void testDestructorDelegatesToDeinit() { + test_support::resetRuntime(); + + { + ESPWorker worker; + worker.init(ESPWorker::Config{}); + WorkerResult result = worker.spawn([]() {}); + expectTrue(static_cast(result), "spawn should succeed for destructor test"); + expectEqual(worker.activeWorkers(), static_cast(1), "one worker should be active before scope exit"); + } + + expectEqual(test_support::deletedTaskCount(), static_cast(1), "destructor should deinit and release active worker"); +} + +} // namespace + +int main() { + try { + testDeinitIsSafeBeforeInit(); + testDeinitIsIdempotent(); + testReinitLifecycleAfterDeinit(); + testDeinitReleasesActiveTaskHandles(); + testDestructorDelegatesToDeinit(); + } catch (const std::exception &ex) { + std::cerr << "FAIL: " << ex.what() << '\n'; + return 1; + } + + std::cout << "All esp-worker lifecycle tests passed\n"; + return 0; +} diff --git a/test/stubs/Arduino.h b/test/stubs/Arduino.h new file mode 100644 index 0000000..4874c11 --- /dev/null +++ b/test/stubs/Arduino.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +unsigned long millis(void); + +#ifdef __cplusplus +} +#endif diff --git a/test/stubs/esp_heap_caps.h b/test/stubs/esp_heap_caps.h new file mode 100644 index 0000000..3a22525 --- /dev/null +++ b/test/stubs/esp_heap_caps.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define MALLOC_CAP_8BIT 0x1 +#define MALLOC_CAP_INTERNAL 0x2 +#define MALLOC_CAP_SPIRAM 0x4 + +void *heap_caps_malloc(size_t size, unsigned int caps); +void heap_caps_free(void *ptr); +size_t heap_caps_get_total_size(unsigned int caps); + +#ifdef __cplusplus +} +#endif diff --git a/test/stubs/freertos/FreeRTOS.h b/test/stubs/freertos/FreeRTOS.h new file mode 100644 index 0000000..eb6766c --- /dev/null +++ b/test/stubs/freertos/FreeRTOS.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef int BaseType_t; +typedef unsigned int UBaseType_t; +typedef uint32_t TickType_t; +typedef uint32_t configSTACK_DEPTH_TYPE; +typedef void *TaskHandle_t; +typedef void *SemaphoreHandle_t; +typedef uint32_t StackType_t; + +typedef struct StaticSemaphore { + uintptr_t storage[8]; +} StaticSemaphore_t; + +#define pdTRUE 1 +#define pdFALSE 0 +#define pdPASS 1 +#define pdFAIL 0 + +#define portMAX_DELAY ((TickType_t)-1) +#define portTICK_PERIOD_MS 1 +#define tskNO_AFFINITY (-1) + +#define pdMS_TO_TICKS(ms) ((TickType_t)(ms)) + +#ifdef __cplusplus +} +#endif diff --git a/test/stubs/freertos/semphr.h b/test/stubs/freertos/semphr.h new file mode 100644 index 0000000..db1f638 --- /dev/null +++ b/test/stubs/freertos/semphr.h @@ -0,0 +1,16 @@ +#pragma once + +#include "freertos/FreeRTOS.h" + +#ifdef __cplusplus +extern "C" { +#endif + +SemaphoreHandle_t xSemaphoreCreateBinaryStatic(StaticSemaphore_t *buffer); +BaseType_t xSemaphoreTake(SemaphoreHandle_t handle, TickType_t ticks); +BaseType_t xSemaphoreGive(SemaphoreHandle_t handle); +void vSemaphoreDelete(SemaphoreHandle_t handle); + +#ifdef __cplusplus +} +#endif diff --git a/test/stubs/freertos/task.h b/test/stubs/freertos/task.h new file mode 100644 index 0000000..3540841 --- /dev/null +++ b/test/stubs/freertos/task.h @@ -0,0 +1,26 @@ +#pragma once + +#include "freertos/FreeRTOS.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef void (*TaskFunction_t)(void *); + +BaseType_t xTaskCreatePinnedToCore(TaskFunction_t task, + const char *name, + uint32_t stackDepth, + void *parameters, + UBaseType_t priority, + TaskHandle_t *createdTask, + BaseType_t coreId); + +void vTaskDelete(TaskHandle_t task); +void vTaskDelay(TickType_t ticks); +TickType_t xTaskGetTickCount(void); +TaskHandle_t xTaskGetCurrentTaskHandle(void); + +#ifdef __cplusplus +} +#endif diff --git a/test/test_support.h b/test/test_support.h new file mode 100644 index 0000000..faa3ff6 --- /dev/null +++ b/test/test_support.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace test_support { + +void resetRuntime(); +size_t createdTaskCount(); +size_t deletedTaskCount(); + +} // namespace test_support diff --git a/test/worker_test_stubs.cpp b/test/worker_test_stubs.cpp new file mode 100644 index 0000000..274a7d2 --- /dev/null +++ b/test/worker_test_stubs.cpp @@ -0,0 +1,162 @@ +#include "Arduino.h" +#include "esp_heap_caps.h" +#include "freertos/semphr.h" +#include "freertos/task.h" +#include "test_support.h" + +#include +#include +#include +#include +#include + +namespace { + +struct FakeSemaphore { + std::atomic available{false}; +}; + +struct FakeTask { + TaskFunction_t entry{nullptr}; + void *arg{nullptr}; +}; + +std::atomic g_tickCount{0}; +std::atomic g_createdTasks{0}; +std::atomic g_deletedTasks{0}; + +std::mutex g_taskMutex; +std::unordered_set g_liveTasks; + +TaskHandle_t g_currentTaskHandle = nullptr; + +} // namespace + +extern "C" unsigned long millis(void) { + return static_cast(g_tickCount.load(std::memory_order_relaxed)); +} + +extern "C" SemaphoreHandle_t xSemaphoreCreateBinaryStatic(StaticSemaphore_t * /*buffer*/) { + auto *sem = new (std::nothrow) FakeSemaphore{}; + return reinterpret_cast(sem); +} + +extern "C" BaseType_t xSemaphoreTake(SemaphoreHandle_t handle, TickType_t /*ticks*/) { + if (!handle) { + return pdFALSE; + } + auto *sem = reinterpret_cast(handle); + bool expected = true; + if (sem->available.compare_exchange_strong(expected, false, std::memory_order_acq_rel)) { + return pdTRUE; + } + return pdFALSE; +} + +extern "C" BaseType_t xSemaphoreGive(SemaphoreHandle_t handle) { + if (!handle) { + return pdFALSE; + } + auto *sem = reinterpret_cast(handle); + sem->available.store(true, std::memory_order_release); + return pdTRUE; +} + +extern "C" void vSemaphoreDelete(SemaphoreHandle_t handle) { + auto *sem = reinterpret_cast(handle); + delete sem; +} + +extern "C" BaseType_t xTaskCreatePinnedToCore(TaskFunction_t task, + const char * /*name*/, + uint32_t /*stackDepth*/, + void *parameters, + UBaseType_t /*priority*/, + TaskHandle_t *createdTask, + BaseType_t /*coreId*/) { + auto *fakeTask = new (std::nothrow) FakeTask{task, parameters}; + if (!fakeTask) { + return pdFAIL; + } + + TaskHandle_t handle = reinterpret_cast(fakeTask); + if (createdTask) { + *createdTask = handle; + } + + { + std::lock_guard guard(g_taskMutex); + g_liveTasks.insert(handle); + } + + g_createdTasks.fetch_add(1, std::memory_order_relaxed); + return pdPASS; +} + +extern "C" void vTaskDelete(TaskHandle_t task) { + TaskHandle_t target = task ? task : g_currentTaskHandle; + if (!target) { + return; + } + + bool removed = false; + { + std::lock_guard guard(g_taskMutex); + removed = g_liveTasks.erase(target) > 0; + } + + if (removed) { + g_deletedTasks.fetch_add(1, std::memory_order_relaxed); + auto *fakeTask = reinterpret_cast(target); + delete fakeTask; + } +} + +extern "C" void vTaskDelay(TickType_t ticks) { + g_tickCount.fetch_add(ticks, std::memory_order_relaxed); +} + +extern "C" TickType_t xTaskGetTickCount(void) { + return g_tickCount.load(std::memory_order_relaxed); +} + +extern "C" TaskHandle_t xTaskGetCurrentTaskHandle(void) { + return g_currentTaskHandle; +} + +extern "C" void *heap_caps_malloc(size_t size, unsigned int /*caps*/) { + return std::malloc(size); +} + +extern "C" void heap_caps_free(void *ptr) { + std::free(ptr); +} + +extern "C" size_t heap_caps_get_total_size(unsigned int /*caps*/) { + return 0; +} + +namespace test_support { + +void resetRuntime() { + g_tickCount.store(0, std::memory_order_relaxed); + g_createdTasks.store(0, std::memory_order_relaxed); + g_deletedTasks.store(0, std::memory_order_relaxed); + + std::lock_guard guard(g_taskMutex); + for (TaskHandle_t handle : g_liveTasks) { + auto *fakeTask = reinterpret_cast(handle); + delete fakeTask; + } + g_liveTasks.clear(); +} + +size_t createdTaskCount() { + return g_createdTasks.load(std::memory_order_relaxed); +} + +size_t deletedTaskCount() { + return g_deletedTasks.load(std::memory_order_relaxed); +} + +} // namespace test_support