Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,19 @@ 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,
.name = "sensor-task",
});

if (result) {
result.handler->wait(pdMS_TO_TICKS(1000));
result.handler->wait(pdMS_TO_TICKS(1000)); // or destroy() for long-lived loops
}

worker.deinit();
}
```

Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion examples/basic_lambda_worker/basic_lambda_worker.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
2 changes: 2 additions & 0 deletions examples/basic_worker/basic_worker.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}
2 changes: 2 additions & 0 deletions examples/psram_stack/psram_stack.ino
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ void setup() {

testJob.handler->wait(); // Wait for the job to finish, indefinietly
printPSRAM("After job completed");

worker.deinit();
}

void loop() {}
12 changes: 3 additions & 9 deletions src/esp_worker/worker.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ void ESPWorker::deinit() {
std::vector<std::shared_ptr<WorkerHandler::Impl>> controls;
{
std::lock_guard<std::mutex> guard(_mutex);
controls = _activeControls;
_initialized.store(false, std::memory_order_release);
controls.swap(_activeControls);
}

for (auto &control : controls) {
Expand All @@ -150,18 +151,11 @@ void ESPWorker::deinit() {
control->owner = nullptr;
}

{
std::lock_guard<std::mutex> guard(_mutex);
_activeControls.clear();
}

{
std::lock_guard<std::mutex> guard(_callbackMutex);
_eventCallback = nullptr;
_errorCallback = nullptr;
}

_initialized = false;
}

bool WorkerHandler::valid() const { return static_cast<bool>(_control); }
Expand Down Expand Up @@ -220,7 +214,7 @@ bool WorkerHandler::destroy() {
void ESPWorker::init(const Config &config) {
std::lock_guard<std::mutex> guard(_mutex);
_config = config;
_initialized = true;
_initialized.store(true, std::memory_order_release);
}

WorkerResult ESPWorker::spawn(TaskCallback callback, const WorkerConfig &config) {
Expand Down
4 changes: 3 additions & 1 deletion src/esp_worker/worker.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#include <Arduino.h>

#include <atomic>
#include <functional>
#include <memory>
#include <mutex>
Expand Down Expand Up @@ -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{});
Expand Down Expand Up @@ -134,7 +136,7 @@ class ESPWorker {
void notifyError(WorkerError error);

Config _config{};
bool _initialized = false;
std::atomic<bool> _initialized{false};

mutable std::mutex _mutex;
std::vector<std::shared_ptr<WorkerHandler::Impl>> _activeControls;
Expand Down
32 changes: 32 additions & 0 deletions test/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
139 changes: 139 additions & 0 deletions test/esp_worker_lifecycle_tests.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#include <ESPWorker.h>

#include <exception>
#include <iostream>
#include <stdexcept>
#include <string>

#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 <typename T>
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<size_t>(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<size_t>(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<size_t>(0), "deinit should clear worker state");

worker.deinit();
expectFalse(worker.isInitialized(), "second deinit should remain safe");
expectEqual(worker.activeWorkers(), static_cast<size_t>(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<bool>(first), "spawn after first init should succeed");
expectEqual(worker.activeWorkers(), static_cast<size_t>(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<size_t>(0), "deinit should clear active workers");

worker.init(cfg);
expectTrue(worker.isInitialized(), "second init should succeed");

WorkerResult second = worker.spawn([]() {});
expectTrue(static_cast<bool>(second), "spawn after reinit should succeed");
expectEqual(worker.activeWorkers(), static_cast<size_t>(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<bool>(first), "first spawn should succeed");
expectTrue(static_cast<bool>(second), "second spawn should succeed");
expectEqual(worker.activeWorkers(), static_cast<size_t>(2), "two workers should be active before teardown");
expectEqual(test_support::createdTaskCount(), static_cast<size_t>(2), "stubs should observe two created tasks");

worker.deinit();

expectFalse(worker.isInitialized(), "worker should be deinitialized");
expectEqual(worker.activeWorkers(), static_cast<size_t>(0), "deinit should clear active workers");
expectEqual(test_support::deletedTaskCount(), static_cast<size_t>(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<bool>(result), "spawn should succeed for destructor test");
expectEqual(worker.activeWorkers(), static_cast<size_t>(1), "one worker should be active before scope exit");
}

expectEqual(test_support::deletedTaskCount(), static_cast<size_t>(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;
}
13 changes: 13 additions & 0 deletions test/stubs/Arduino.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#pragma once

#include <stdint.h>

#ifdef __cplusplus
extern "C" {
#endif

unsigned long millis(void);

#ifdef __cplusplus
}
#endif
19 changes: 19 additions & 0 deletions test/stubs/esp_heap_caps.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#pragma once

#include <stddef.h>

#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
35 changes: 35 additions & 0 deletions test/stubs/freertos/FreeRTOS.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#pragma once

#include <stddef.h>
#include <stdint.h>

#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
16 changes: 16 additions & 0 deletions test/stubs/freertos/semphr.h
Original file line number Diff line number Diff line change
@@ -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
Loading