From 6da65e52a34b04af2943c049442195e1c0d7d36d Mon Sep 17 00:00:00 2001 From: DrRandom <37899881+zekageri@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:43:00 +0200 Subject: [PATCH 01/11] Harden Worker task cleanup --- src/Worker.h | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/Worker.h b/src/Worker.h index 1a1c0e3..9ef39d3 100644 --- a/src/Worker.h +++ b/src/Worker.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include #include #include @@ -51,6 +53,9 @@ enum class WorkerJobState : uint8_t { Stopped, Finished, Failed, + CallbackComplete, + CleanupQueued, + CleanupComplete, }; struct WorkerEvent { @@ -69,6 +74,11 @@ struct WorkerConfig { UBaseType_t defaultPriority = 1; BaseType_t defaultCoreId = tskNO_AFFINITY; WorkerStackType defaultStackType = WorkerStackType::Auto; + + size_t maxConcurrentJobs = 8; + uint32_t cleanupTaskStackSize = 3072; + UBaseType_t cleanupTaskPriority = 1; + BaseType_t cleanupTaskCoreId = tskNO_AFFINITY; }; struct WorkerJobConfig { @@ -100,15 +110,14 @@ struct WorkerJobResult : WorkerResult { }; struct WorkerDiag { - uint32_t totalJobCount = 0; + uint32_t activeJobCount = 0; uint32_t runningJobCount = 0; uint32_t sleepingJobCount = 0; - uint32_t finishedJobCount = 0; - uint32_t stoppedJobCount = 0; - uint32_t failedJobCount = 0; - uint32_t psramStackJobCount = 0; - uint32_t internalStackJobCount = 0; - size_t totalStackHighWaterMarkBytes = 0; + uint32_t stoppingJobCount = 0; + uint32_t cleanupQueuedCount = 0; + bool cleanupTaskRunning = false; + uint32_t cleanupQueueDepth = 0; + uint32_t cleanupQueueHighWaterMark = 0; }; struct WorkerJobDiag { @@ -143,9 +152,9 @@ class WorkerJobContext { friend class Worker; friend struct WorkerImpl; - explicit WorkerJobContext(std::shared_ptr record); + explicit WorkerJobContext(WorkerJobRecord *record); - std::shared_ptr _record; + WorkerJobRecord *_record = nullptr; }; class Worker { @@ -175,6 +184,8 @@ class Worker { WorkerResult sleep(WorkerJobId jobId, uint32_t durationMs); WorkerResult waitFor(WorkerJobId jobId); WorkerResult waitFor(WorkerJobId jobId, uint32_t timeoutMs); + + [[deprecated("Worker cleans completed jobs automatically")]] WorkerResult clearFinished(); WorkerDiag getDiagnostics(); From 896967932ad8cf8776bc1e57a68d5a278fba3f6d Mon Sep 17 00:00:00 2001 From: DrRandom <37899881+zekageri@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:43:27 +0200 Subject: [PATCH 02/11] Harden Worker task cleanup --- src/internal/WorkerTaskSupport.h | 35 ++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/src/internal/WorkerTaskSupport.h b/src/internal/WorkerTaskSupport.h index efeea53..862a665 100644 --- a/src/internal/WorkerTaskSupport.h +++ b/src/internal/WorkerTaskSupport.h @@ -62,10 +62,8 @@ inline BaseType_t createTask( UBaseType_t priority, TaskHandle_t *handle, BaseType_t coreId, - bool usePsramStack, - bool &createdWithCaps + bool usePsramStack ) { - createdWithCaps = false; if (!isValidStackSize(stackBytes)) { return pdFAIL; } @@ -74,7 +72,7 @@ inline BaseType_t createTask( if (!hasExternalStackSupport()) { return pdFAIL; } - const BaseType_t created = xTaskCreatePinnedToCoreWithCaps( + return xTaskCreatePinnedToCoreWithCaps( entry, name, static_cast(stackBytes), @@ -84,8 +82,6 @@ inline BaseType_t createTask( coreId, kExternalStackCaps ); - createdWithCaps = created == pdPASS; - return created; #else return pdFAIL; #endif @@ -111,13 +107,34 @@ inline BaseType_t createTask( ); } -inline void deleteCurrentTask(bool withCaps) { +inline BaseType_t createInternalTask( + TaskFunction_t entry, + const char *name, + size_t stackBytes, + void *arg, + UBaseType_t priority, + TaskHandle_t *handle, + BaseType_t coreId +) { + return createTask(entry, name, stackBytes, arg, priority, handle, coreId, false); +} + +inline void deleteTask(TaskHandle_t handle, bool withCaps) { + if (handle == nullptr) { + return; + } #if WORKER_CAN_USE_EXTERNAL_STACKS if (withCaps) { - vTaskDeleteWithCaps(xTaskGetCurrentTaskHandle()); + vTaskDeleteWithCaps(handle); return; } #endif - vTaskDelete(nullptr); + vTaskSuspend(handle); +#if defined(INCLUDE_eTaskGetState) && (INCLUDE_eTaskGetState == 1) + while (eTaskGetState(handle) == eRunning) { + taskYIELD(); + } +#endif + vTaskDelete(handle); } } // namespace worker_task_support From d0f988520bb497d9e97f5ce5756e0bbe45338f9c Mon Sep 17 00:00:00 2001 From: DrRandom <37899881+zekageri@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:46:43 +0200 Subject: [PATCH 03/11] Harden Worker task cleanup --- src/Worker.cpp | 842 +++++++++++++++++++++++++++++-------------------- 1 file changed, 508 insertions(+), 334 deletions(-) diff --git a/src/Worker.cpp b/src/Worker.cpp index de68030..bfd6c17 100644 --- a/src/Worker.cpp +++ b/src/Worker.cpp @@ -8,12 +8,19 @@ #include #include #include +#include #include +extern "C" { +#include +} + namespace { constexpr WorkerJobId kInvalidJobId = 0; constexpr uint32_t kWaitPollMs = 10; constexpr size_t kMaxTaskNameLength = 32; +constexpr size_t kCompletionCapacity = 16; +constexpr const char *kCleanupTaskName = "worker-cleanup"; uint32_t nowMs() { return static_cast(millis()); @@ -36,13 +43,24 @@ TickType_t waitTicks(uint32_t durationMs) { return durationMs == UINT32_MAX ? portMAX_DELAY : pdMS_TO_TICKS(durationMs); } -bool isTerminalState(WorkerJobState state) { - return state == WorkerJobState::Stopped || state == WorkerJobState::Finished || - state == WorkerJobState::Failed; +bool isExecutionCompleteState(WorkerJobState state) { + switch (state) { + case WorkerJobState::CallbackComplete: + case WorkerJobState::CleanupQueued: + case WorkerJobState::CleanupComplete: + case WorkerJobState::Stopped: + case WorkerJobState::Finished: + case WorkerJobState::Failed: + return true; + case WorkerJobState::Created: + case WorkerJobState::Running: + case WorkerJobState::Sleeping: + case WorkerJobState::Stopping: + return false; + } + return false; } -bool isReapableJob(const std::shared_ptr &job); - void copyTaskName(char *destination, size_t destinationSize, const char *source) { if (destination == nullptr || destinationSize == 0 || source == nullptr || *source == '\0') { return; @@ -52,6 +70,11 @@ void copyTaskName(char *destination, size_t destinationSize, const char *source) } } // namespace +enum class WorkerTaskAllocation : uint8_t { + Internal, + WithCaps, +}; + struct WorkerJobRecord { WorkerImpl *owner = nullptr; WorkerJobId id = kInvalidJobId; @@ -64,10 +87,12 @@ struct WorkerJobRecord { BaseType_t coreId = tskNO_AFFINITY; WorkerStackType requestedStackType = WorkerStackType::Auto; WorkerStackType actualStackType = WorkerStackType::Internal; + WorkerTaskAllocation allocation = WorkerTaskAllocation::Internal; TaskHandle_t taskHandle = nullptr; - bool createdWithCaps = false; std::atomic stopRequested{false}; + std::atomic readyForDelete{false}; WorkerJobState state = WorkerJobState::Created; + WorkerJobState finalState = WorkerJobState::Finished; bool hasStarted = false; uint32_t runCount = 0; uint32_t startedAtMs = 0; @@ -77,37 +102,36 @@ struct WorkerJobRecord { uint32_t sleepDurationMs = 0; bool hasSleepDeadline = false; size_t stackHighWaterMarkBytes = 0; - bool terminalAccounted = false; - bool taskExited = false; }; -struct TaskEntryExit { - bool createdWithCaps = false; - WorkerImpl *owner = nullptr; +struct WorkerCompletion { WorkerJobId jobId = kInvalidJobId; + WorkerJobState finalState = WorkerJobState::Finished; }; -namespace { -bool isReapableJob(const std::shared_ptr &job) { - return job && isTerminalState(job->state) && job->taskExited; -} -} // namespace +struct WorkerCleanupRequest { + WorkerJobId jobId = kInvalidJobId; + TaskHandle_t taskHandle = nullptr; + WorkerJobRecord *record = nullptr; + bool withCaps = false; + bool stopCleanupTask = false; +}; struct WorkerImpl { WorkerConfig config{}; WorkerMutex mutex; - std::vector> jobs; + std::vector> jobs; + std::vector completions; WorkerEventCallback onEvent; + QueueHandle_t cleanupQueue = nullptr; + TaskHandle_t cleanupTaskHandle = nullptr; + bool cleanupTaskRunning = false; + bool cleanupTaskStopRequested = false; + std::atomic cleanupTaskReadyForDelete{false}; + uint32_t cleanupQueueHighWaterMark = 0; bool initialized = false; bool ending = false; WorkerJobId nextJobId = 1; - uint32_t totalJobCount = 0; - uint32_t finishedJobCount = 0; - uint32_t stoppedJobCount = 0; - uint32_t failedJobCount = 0; - uint32_t psramStackJobCount = 0; - uint32_t internalStackJobCount = 0; - size_t terminalStackHighWaterMarkBytes = 0; WorkerResult emitResult(WorkerResult result, WorkerJobId jobId = kInvalidJobId) { if (!result) { @@ -142,114 +166,87 @@ struct WorkerImpl { } } - std::shared_ptr findJob(WorkerJobId jobId) { + WorkerJobRecord *findJob(WorkerJobId jobId) { for (auto &job : jobs) { if (job && job->id == jobId) { - return job; + return job.get(); } } return nullptr; } - void resetDiagnostics() { - totalJobCount = 0; - finishedJobCount = 0; - stoppedJobCount = 0; - failedJobCount = 0; - psramStackJobCount = 0; - internalStackJobCount = 0; - terminalStackHighWaterMarkBytes = 0; - } - - void accountCreatedJob(const std::shared_ptr &job) { - if (!job) { - return; - } - totalJobCount++; - if (job->actualStackType == WorkerStackType::Psram) { - psramStackJobCount++; - } else { - internalStackJobCount++; - } + bool hasCompletion(WorkerJobId jobId) const { + return std::any_of( + completions.begin(), + completions.end(), + [jobId](const WorkerCompletion &completion) { return completion.jobId == jobId; } + ); } - void accountTerminalJob(const std::shared_ptr &job) { - if (!job || job->terminalAccounted) { - return; - } - job->terminalAccounted = true; - switch (job->state) { - case WorkerJobState::Finished: - finishedJobCount++; - break; - case WorkerJobState::Stopped: - stoppedJobCount++; - break; - case WorkerJobState::Failed: - failedJobCount++; - break; - case WorkerJobState::Created: - case WorkerJobState::Running: - case WorkerJobState::Sleeping: - case WorkerJobState::Stopping: - break; + bool consumeCompletion(WorkerJobId jobId, WorkerJobState &finalState) { + auto it = std::find_if( + completions.begin(), + completions.end(), + [jobId](const WorkerCompletion &completion) { return completion.jobId == jobId; } + ); + if (it == completions.end()) { + return false; } - terminalStackHighWaterMarkBytes += job->stackHighWaterMarkBytes; + finalState = it->finalState; + completions.erase(it); + return true; } - void reapJob(const std::shared_ptr &job) { - if (!job) { - return; - } - jobs.erase( + void recordCompletion(WorkerJobId jobId, WorkerJobState finalState) { + completions.erase( std::remove_if( - jobs.begin(), - jobs.end(), - [&](const std::shared_ptr &candidate) { - return candidate && candidate->id == job->id; - } + completions.begin(), + completions.end(), + [jobId](const WorkerCompletion &completion) { return completion.jobId == jobId; } ), - jobs.end() + completions.end() ); + if (completions.size() >= kCompletionCapacity) { + completions.erase(completions.begin()); + } + completions.push_back(WorkerCompletion{jobId, finalState}); } - void reapTerminalJobs() { + void eraseJob(WorkerJobId jobId) { jobs.erase( std::remove_if( jobs.begin(), jobs.end(), - [](const std::shared_ptr &candidate) { - return isReapableJob(candidate); + [jobId](const std::unique_ptr &job) { + return job && job->id == jobId; } ), jobs.end() ); } - void markFailed(const std::shared_ptr &job) { - if (!job) { - return; + WorkerJobId allocateJobId() { + WorkerJobId id = nextJobId++; + if (id == kInvalidJobId) { + id = nextJobId++; } - job->state = WorkerJobState::Failed; - job->finishedAtMs = nowMs(); - job->taskHandle = nullptr; - job->taskExited = true; - accountTerminalJob(job); + return id; } - WorkerResult waitForJob( - const std::shared_ptr &job, - WorkerJobId jobId, - uint32_t timeoutMs - ) { - if (!job) { - return emitResult( - WorkerResult::failure(WorkerStatus::JobNotFound, "job not found"), - jobId - ); + WorkerResult completionResult(WorkerJobState finalState) { + if (finalState == WorkerJobState::Failed) { + return WorkerResult::failure(WorkerStatus::InternalError, "job failed"); } + return WorkerResult::success( + finalState == WorkerJobState::Stopped ? "job stopped" : "job finished" + ); + } + + WorkerResult waitForJobId(WorkerJobId jobId, uint32_t timeoutMs) { const uint32_t startMs = nowMs(); while (true) { + WorkerJobState finalState = WorkerJobState::Failed; + bool foundActive = false; { WorkerLock lock(mutex); if (!lock) { @@ -258,12 +255,18 @@ struct WorkerImpl { jobId ); } - if (isTerminalState(job->state) && job->taskExited) { - reapJob(job); - return WorkerResult::success("job finished"); + if (consumeCompletion(jobId, finalState)) { + return completionResult(finalState); } + foundActive = findJob(jobId) != nullptr; } + if (!foundActive) { + return emitResult( + WorkerResult::failure(WorkerStatus::JobNotFound, "job not found"), + jobId + ); + } if (elapsedSince(startMs, timeoutMs)) { return emitResult( WorkerResult::failure(WorkerStatus::Timeout, "wait timed out"), @@ -294,24 +297,9 @@ struct WorkerImpl { return resolved; } - void setState( - const std::shared_ptr &job, - WorkerJobState state, - bool updateFinishTime = false - ) { + void markRunStart(WorkerJobRecord *job) { WorkerLock lock(mutex); - if (!lock || !job) { - return; - } - job->state = state; - if (updateFinishTime) { - job->finishedAtMs = nowMs(); - } - } - - void markRunStart(const std::shared_ptr &job) { - WorkerLock lock(mutex); - if (!lock || !job) { + if (!lock || job == nullptr) { return; } const uint32_t currentMs = nowMs(); @@ -324,53 +312,15 @@ struct WorkerImpl { job->state = WorkerJobState::Running; } - void markTaskFinished( - const std::shared_ptr &job, - WorkerJobState finalState - ) { - if (!job) { - return; - } - { - WorkerLock lock(mutex); - if (lock) { - job->stackHighWaterMarkBytes = - worker_task_support::currentStackHighWaterMarkBytes(); - job->state = finalState; - job->finishedAtMs = nowMs(); - job->taskHandle = nullptr; - accountTerminalJob(job); - } - } - if (finalState == WorkerJobState::Stopped) { - emitEvent(WorkerEventType::Info, WorkerStatus::Ok, job->id, "job stopped"); - } else if (finalState == WorkerJobState::Finished) { - emitEvent(WorkerEventType::Info, WorkerStatus::Ok, job->id, "job finished"); - } else { - emitEvent(WorkerEventType::Error, WorkerStatus::InternalError, job->id, "job failed"); - } - } - - void markTaskExited(WorkerJobId jobId) { - WorkerLock lock(mutex); - if (!lock) { - return; - } - auto job = findJob(jobId); - if (job) { - job->taskExited = true; - } - } - - bool waitWhileSleeping(const std::shared_ptr &job, uint32_t durationMs) { - if (!job || durationMs == 0) { + bool waitWhileSleeping(WorkerJobRecord *job, uint32_t durationMs) { + if (job == nullptr || durationMs == 0) { return true; } return waitForDuration(job, durationMs); } - bool waitForDuration(const std::shared_ptr &job, uint32_t durationMs) { - if (!job) { + bool waitForDuration(WorkerJobRecord *job, uint32_t durationMs) { + if (job == nullptr) { return false; } const uint32_t startMs = nowMs(); @@ -388,7 +338,7 @@ struct WorkerImpl { } } remainingMs = std::max(remainingMs, externalRemainingMs); - if (!isTerminalState(job->state)) { + if (!isExecutionCompleteState(job->state)) { job->state = WorkerJobState::Sleeping; } } @@ -397,33 +347,122 @@ struct WorkerImpl { if (remainingMs == 0) { break; } - ulTaskNotifyTake(pdTRUE, waitTicks(remainingMs)); } return !job->stopRequested.load(); } - void requestSleep(const std::shared_ptr &job, uint32_t durationMs) { - if (!job || durationMs == 0) { + WorkerJobState executeJob(WorkerJobRecord *job) { + WorkerCallback callback; + { + WorkerLock lock(mutex); + if (!lock || job == nullptr) { + return WorkerJobState::Failed; + } + callback = std::move(job->callback); + job->callback = {}; + } + if (!callback) { + return WorkerJobState::Failed; + } + + WorkerJobContext context(job); + WorkerJobState finalState = WorkerJobState::Finished; + if (job->recurring) { + while (!job->stopRequested.load()) { + markRunStart(job); + callback(context); + if (job->stopRequested.load()) { + break; + } + waitForDuration(job, job->intervalMs); + } + finalState = WorkerJobState::Stopped; + } else { + markRunStart(job); + callback(context); + finalState = + job->stopRequested.load() ? WorkerJobState::Stopped : WorkerJobState::Finished; + } + return finalState; + } + + void prepareCleanup( + WorkerJobRecord *job, + WorkerJobState finalState, + TaskHandle_t taskHandle + ) { + WorkerLock lock(mutex); + if (!lock || job == nullptr) { + return; + } + job->stackHighWaterMarkBytes = worker_task_support::currentStackHighWaterMarkBytes(); + job->finalState = finalState; + job->state = WorkerJobState::CallbackComplete; + job->finishedAtMs = nowMs(); + job->taskHandle = taskHandle; + } + + void queueCleanup(WorkerJobRecord *job, TaskHandle_t taskHandle) { + if (job == nullptr || cleanupQueue == nullptr) { return; } - TaskHandle_t handle = nullptr; { WorkerLock lock(mutex); - if (!lock || isTerminalState(job->state)) { + if (lock) { + job->state = WorkerJobState::CleanupQueued; + } + } + + const WorkerCleanupRequest request{ + job->id, + taskHandle, + job, + job->allocation == WorkerTaskAllocation::WithCaps, + false, + }; + while (xQueueSend(cleanupQueue, &request, portMAX_DELAY) != pdPASS) { + vTaskDelay(1); + } + + const uint32_t queueDepth = static_cast(uxQueueMessagesWaiting(cleanupQueue)); + WorkerLock lock(mutex); + if (lock) { + cleanupQueueHighWaterMark = std::max(cleanupQueueHighWaterMark, queueDepth); + } + } + + void completeCleanup(const WorkerCleanupRequest &request) { + WorkerJobState finalState = WorkerJobState::Failed; + bool found = false; + { + WorkerLock lock(mutex); + if (!lock) { return; } - const uint32_t existingRemainingMs = job->hasSleepDeadline - ? remainingSince(job->sleepStartMs, job->sleepDurationMs) - : 0; - job->sleepStartMs = nowMs(); - job->sleepDurationMs = std::max(existingRemainingMs, durationMs); - job->hasSleepDeadline = true; - job->state = WorkerJobState::Sleeping; - handle = job->taskHandle; + WorkerJobRecord *job = findJob(request.jobId); + if (job == request.record && job != nullptr) { + job->state = WorkerJobState::CleanupComplete; + finalState = job->finalState; + recordCompletion(job->id, finalState); + eraseJob(job->id); + found = true; + } } - if (handle != nullptr) { - xTaskNotifyGive(handle); + if (!found) { + return; + } + if (finalState == WorkerJobState::Stopped) { + emitEvent(WorkerEventType::Info, WorkerStatus::Ok, request.jobId, "job stopped"); + } else if (finalState == WorkerJobState::Finished) { + emitEvent(WorkerEventType::Info, WorkerStatus::Ok, request.jobId, "job finished"); + } else { + emitEvent( + WorkerEventType::Error, + WorkerStatus::InternalError, + request.jobId, + "job failed" + ); } } @@ -471,7 +510,7 @@ struct WorkerImpl { actualStackType = WorkerStackType::Psram; } - std::shared_ptr job(new (std::nothrow) WorkerJobRecord()); + std::unique_ptr job(new (std::nothrow) WorkerJobRecord()); if (!job) { return emitJobResult(WorkerJobResult::failure( WorkerStatus::OutOfMemory, @@ -480,7 +519,7 @@ struct WorkerImpl { } job->owner = this; - job->callback = callback; + job->callback = std::move(callback); job->recurring = recurring; job->intervalMs = intervalMs; job->stackSize = jobConfig.stackSize; @@ -488,127 +527,226 @@ struct WorkerImpl { job->coreId = jobConfig.coreId; job->requestedStackType = jobConfig.stackType; job->actualStackType = actualStackType; + job->allocation = usePsramStack + ? WorkerTaskAllocation::WithCaps + : WorkerTaskAllocation::Internal; if (jobConfig.name != nullptr && *jobConfig.name != '\0') { copyTaskName(job->name, sizeof(job->name), jobConfig.name); } - auto taskArg = new (std::nothrow) std::shared_ptr(job); - if (taskArg == nullptr) { - return emitJobResult(WorkerJobResult::failure( - WorkerStatus::OutOfMemory, - "failed to allocate task argument" - )); - } - - TaskHandle_t handle = nullptr; - bool createdWithCaps = false; - + WorkerJobResult result; { WorkerLock lock(mutex); if (!lock) { - delete taskArg; return emitJobResult(WorkerJobResult::failure( WorkerStatus::InternalError, "failed to lock worker registry" )); } - if (!initialized) { - delete taskArg; + if (!initialized || cleanupQueue == nullptr || cleanupTaskHandle == nullptr) { return emitJobResult(WorkerJobResult::failure( WorkerStatus::NotInitialized, "worker is not initialized" )); } if (ending) { - delete taskArg; return emitJobResult(WorkerJobResult::failure( WorkerStatus::Busy, "worker is ending" )); } + if (jobs.size() >= config.maxConcurrentJobs) { + return emitJobResult(WorkerJobResult::failure( + WorkerStatus::Busy, + "maximum concurrent jobs reached" + )); + } - job->id = nextJobId++; - accountCreatedJob(job); - jobs.push_back(job); + job->id = allocateJobId(); + WorkerJobRecord *jobRecord = job.get(); + const WorkerJobId jobId = job->id; + jobs.push_back(std::move(job)); + TaskHandle_t handle = nullptr; const BaseType_t created = worker_task_support::createTask( &WorkerImpl::taskEntry, - job->name, - job->stackSize, - taskArg, - job->priority, + jobRecord->name, + jobRecord->stackSize, + jobRecord, + jobRecord->priority, &handle, - job->coreId, - usePsramStack, - createdWithCaps + jobRecord->coreId, + usePsramStack ); if (created != pdPASS || handle == nullptr) { - delete taskArg; - markFailed(job); - return emitJobResult(WorkerJobResult::failure( + eraseJob(jobId); + result = WorkerJobResult::failure( WorkerStatus::TaskCreateFailed, "failed to create job task", - job->id - )); + jobId + ); + } else { + jobRecord->taskHandle = handle; + result = WorkerJobResult::success(jobId, "job started"); } - job->taskHandle = handle; - job->createdWithCaps = createdWithCaps; } - - return WorkerJobResult::success(job->id, "job started"); + return emitJobResult(result); } - static TaskEntryExit runTaskEntry(void *arg) { - TaskEntryExit exit; - std::unique_ptr> holder( - static_cast *>(arg) + bool initializeCleanupInfrastructure(const WorkerConfig &incomingConfig) { + cleanupQueue = xQueueCreate( + static_cast(incomingConfig.maxConcurrentJobs), + sizeof(WorkerCleanupRequest) ); - if (!holder || !(*holder)) { - // Defensive only: stack allocation caps are unknowable without a valid task arg. - return exit; + if (cleanupQueue == nullptr) { + return false; } - - auto job = *holder; - exit.createdWithCaps = job->createdWithCaps; - - WorkerImpl *owner = job->owner; - if (owner == nullptr) { - return exit; + cleanupTaskReadyForDelete.store(false); + cleanupTaskStopRequested = false; + cleanupTaskRunning = false; + cleanupQueueHighWaterMark = 0; + cleanupTaskHandle = nullptr; + const BaseType_t created = worker_task_support::createInternalTask( + &WorkerImpl::cleanupTaskEntry, + kCleanupTaskName, + incomingConfig.cleanupTaskStackSize, + this, + incomingConfig.cleanupTaskPriority, + &cleanupTaskHandle, + incomingConfig.cleanupTaskCoreId + ); + if (created != pdPASS || cleanupTaskHandle == nullptr) { + vQueueDelete(cleanupQueue); + cleanupQueue = nullptr; + cleanupTaskHandle = nullptr; + return false; } + return true; + } - exit.owner = owner; - exit.jobId = job->id; - - WorkerJobContext context(job); - WorkerJobState finalState = WorkerJobState::Finished; + WorkerResult stopCleanupInfrastructure(uint32_t startMs, uint32_t timeoutMs) { + QueueHandle_t queue = nullptr; + TaskHandle_t handle = nullptr; + bool sendStop = false; + { + WorkerLock lock(mutex); + if (!lock) { + return WorkerResult::failure(WorkerStatus::InternalError, "failed to lock worker"); + } + queue = cleanupQueue; + handle = cleanupTaskHandle; + if (queue == nullptr || handle == nullptr) { + return WorkerResult::success("cleanup task already stopped"); + } + if (!cleanupTaskStopRequested) { + cleanupTaskStopRequested = true; + sendStop = true; + } + } - if (job->recurring) { - while (!job->stopRequested.load()) { - owner->markRunStart(job); - job->callback(context); - if (job->stopRequested.load()) { - break; + if (sendStop) { + const WorkerCleanupRequest stopRequest{ + kInvalidJobId, + nullptr, + nullptr, + false, + true, + }; + if (xQueueSend(queue, &stopRequest, 0) != pdPASS) { + WorkerLock lock(mutex); + if (lock) { + cleanupTaskStopRequested = false; } - owner->waitForDuration(job, job->intervalMs); + return WorkerResult::failure( + WorkerStatus::InternalError, + "failed to stop cleanup task" + ); } - finalState = WorkerJobState::Stopped; - } else { - owner->markRunStart(job); - job->callback(context); - finalState = - job->stopRequested.load() ? WorkerJobState::Stopped : WorkerJobState::Finished; } - owner->markTaskFinished(job, finalState); - return exit; + while (!cleanupTaskReadyForDelete.load()) { + if (elapsedSince(startMs, timeoutMs)) { + return WorkerResult::failure(WorkerStatus::Timeout, "worker end timed out"); + } + vTaskDelay(pdMS_TO_TICKS(kWaitPollMs)); + } + + worker_task_support::deleteTask(handle, false); + { + WorkerLock lock(mutex); + if (lock) { + cleanupTaskHandle = nullptr; + cleanupTaskRunning = false; + cleanupTaskStopRequested = false; + cleanupTaskReadyForDelete.store(false); + cleanupQueue = nullptr; + } + } + vQueueDelete(queue); + return WorkerResult::success("cleanup task stopped"); } static void taskEntry(void *arg) { - const TaskEntryExit exit = runTaskEntry(arg); - if (exit.owner != nullptr && exit.jobId != kInvalidJobId) { - exit.owner->markTaskExited(exit.jobId); + auto *job = static_cast(arg); + if (job == nullptr || job->owner == nullptr) { + vTaskDelete(nullptr); + return; + } + + WorkerImpl *owner = job->owner; + const TaskHandle_t currentTask = xTaskGetCurrentTaskHandle(); + const WorkerJobState finalState = owner->executeJob(job); + owner->prepareCleanup(job, finalState, currentTask); + owner->queueCleanup(job, currentTask); + + job->readyForDelete.store(true, std::memory_order_release); + vTaskSuspend(nullptr); + for (;;) { + vTaskDelay(portMAX_DELAY); + } + } + + static void cleanupTaskEntry(void *arg) { + auto *owner = static_cast(arg); + if (owner == nullptr) { + vTaskDelete(nullptr); + return; + } + { + WorkerLock lock(owner->mutex); + if (lock) { + owner->cleanupTaskRunning = true; + } + } + + for (;;) { + WorkerCleanupRequest request; + if (xQueueReceive(owner->cleanupQueue, &request, portMAX_DELAY) != pdPASS) { + continue; + } + if (request.stopCleanupTask) { + { + WorkerLock lock(owner->mutex); + if (lock) { + owner->cleanupTaskRunning = false; + } + } + owner->cleanupTaskReadyForDelete.store(true, std::memory_order_release); + vTaskSuspend(nullptr); + for (;;) { + vTaskDelay(portMAX_DELAY); + } + } + + if (request.record == nullptr || request.taskHandle == nullptr) { + continue; + } + while (!request.record->readyForDelete.load(std::memory_order_acquire)) { + taskYIELD(); + } + worker_task_support::deleteTask(request.taskHandle, request.withCaps); + owner->completeCleanup(request); } - worker_task_support::deleteCurrentTask(exit.createdWithCaps); } }; @@ -650,21 +788,21 @@ WorkerJobResult WorkerJobResult::failure( return result; } -WorkerJobContext::WorkerJobContext(std::shared_ptr record) : _record(record) { +WorkerJobContext::WorkerJobContext(WorkerJobRecord *record) : _record(record) { } WorkerJobId WorkerJobContext::id() const { - return _record ? _record->id : kInvalidJobId; + return _record != nullptr ? _record->id : kInvalidJobId; } void WorkerJobContext::stop() { - if (_record) { + if (_record != nullptr) { _record->stopRequested.store(true); } } void WorkerJobContext::sleep(uint32_t durationMs) { - if (!_record || _record->owner == nullptr) { + if (_record == nullptr || _record->owner == nullptr) { return; } _record->owner->waitWhileSleeping(_record, durationMs); @@ -675,7 +813,7 @@ bool WorkerJobContext::shouldStop() const { } uint32_t WorkerJobContext::runCount() const { - if (!_record || _record->owner == nullptr) { + if (_record == nullptr || _record->owner == nullptr) { return 0; } WorkerLock lock(_record->owner->mutex); @@ -683,7 +821,7 @@ uint32_t WorkerJobContext::runCount() const { } uint64_t WorkerJobContext::startedAtMs() const { - if (!_record || _record->owner == nullptr) { + if (_record == nullptr || _record->owner == nullptr) { return 0; } WorkerLock lock(_record->owner->mutex); @@ -691,7 +829,7 @@ uint64_t WorkerJobContext::startedAtMs() const { } uint64_t WorkerJobContext::lastRunAtMs() const { - if (!_record || _record->owner == nullptr) { + if (_record == nullptr || _record->owner == nullptr) { return 0; } WorkerLock lock(_record->owner->mutex); @@ -711,6 +849,20 @@ WorkerResult Worker::init(const WorkerConfig &config) { if (!_impl) { return WorkerResult::failure(WorkerStatus::OutOfMemory, "failed to allocate worker"); } + if (!worker_task_support::isValidStackSize(config.defaultStackSize) || + !worker_task_support::isValidStackSize(config.cleanupTaskStackSize)) { + return _impl->emitResult(WorkerResult::failure( + WorkerStatus::InvalidArgument, + "task stack sizes must be at least 1024 bytes and aligned" + )); + } + if (config.maxConcurrentJobs == 0) { + return _impl->emitResult(WorkerResult::failure( + WorkerStatus::InvalidArgument, + "maximum concurrent jobs must be greater than zero" + )); + } + WorkerResult failure; bool hasFailure = false; { @@ -719,22 +871,26 @@ WorkerResult Worker::init(const WorkerConfig &config) { return WorkerResult::failure(WorkerStatus::InternalError, "failed to lock worker"); } if (_impl->initialized) { - failure = - WorkerResult::failure(WorkerStatus::AlreadyInitialized, "worker already initialized"); - hasFailure = true; - } else if (!worker_task_support::isValidStackSize(config.defaultStackSize)) { failure = WorkerResult::failure( - WorkerStatus::InvalidArgument, - "default stack size must be at least 1024 bytes and aligned" + WorkerStatus::AlreadyInitialized, + "worker already initialized" ); hasFailure = true; } else { _impl->config = config; - _impl->initialized = true; _impl->ending = false; _impl->nextJobId = 1; _impl->jobs.clear(); - _impl->resetDiagnostics(); + _impl->completions.clear(); + if (!_impl->initializeCleanupInfrastructure(config)) { + failure = WorkerResult::failure( + WorkerStatus::TaskCreateFailed, + "failed to initialize cleanup task" + ); + hasFailure = true; + } else { + _impl->initialized = true; + } } } if (hasFailure) { @@ -750,7 +906,7 @@ void Worker::onEvent(WorkerEventCallback callback) { } WorkerLock lock(_impl->mutex); if (lock) { - _impl->onEvent = callback; + _impl->onEvent = std::move(callback); } } @@ -758,21 +914,26 @@ WorkerJobResult Worker::once(WorkerCallback callback) { if (!_impl) { return WorkerJobResult::failure(WorkerStatus::OutOfMemory, "failed to allocate worker"); } - return _impl->startJob(_impl->defaultJobConfig(), callback, false, 0); + return _impl->startJob(_impl->defaultJobConfig(), std::move(callback), false, 0); } WorkerJobResult Worker::once(const WorkerJobConfig &config, WorkerCallback callback) { if (!_impl) { return WorkerJobResult::failure(WorkerStatus::OutOfMemory, "failed to allocate worker"); } - return _impl->startJob(config, callback, false, 0); + return _impl->startJob(config, std::move(callback), false, 0); } WorkerJobResult Worker::every(uint32_t intervalMs, WorkerCallback callback) { if (!_impl) { return WorkerJobResult::failure(WorkerStatus::OutOfMemory, "failed to allocate worker"); } - return _impl->startJob(_impl->defaultJobConfig(), callback, true, intervalMs); + return _impl->startJob( + _impl->defaultJobConfig(), + std::move(callback), + true, + intervalMs + ); } WorkerJobResult Worker::every( @@ -783,7 +944,7 @@ WorkerJobResult Worker::every( if (!_impl) { return WorkerJobResult::failure(WorkerStatus::OutOfMemory, "failed to allocate worker"); } - return _impl->startJob(config, callback, true, intervalMs); + return _impl->startJob(config, std::move(callback), true, intervalMs); } WorkerResult Worker::stop(WorkerJobId jobId) { @@ -800,11 +961,15 @@ WorkerResult Worker::stop(WorkerJobId jobId) { failure = WorkerResult::failure(WorkerStatus::InternalError, "failed to lock worker"); hasFailure = true; } else { - auto job = _impl->findJob(jobId); - if (!job) { - failure = WorkerResult::failure(WorkerStatus::JobNotFound, "job not found"); - hasFailure = true; - } else if (isTerminalState(job->state)) { + WorkerJobRecord *job = _impl->findJob(jobId); + if (job == nullptr) { + if (_impl->hasCompletion(jobId)) { + alreadyFinished = true; + } else { + failure = WorkerResult::failure(WorkerStatus::JobNotFound, "job not found"); + hasFailure = true; + } + } else if (isExecutionCompleteState(job->state)) { alreadyFinished = true; } else { job->stopRequested.store(true); @@ -830,7 +995,6 @@ WorkerResult Worker::stopAndWait(WorkerJobId jobId, uint32_t timeoutMs) { return WorkerResult::failure(WorkerStatus::OutOfMemory, "failed to allocate worker"); } TaskHandle_t handle = nullptr; - std::shared_ptr job; WorkerResult failure; bool hasFailure = false; { @@ -839,11 +1003,13 @@ WorkerResult Worker::stopAndWait(WorkerJobId jobId, uint32_t timeoutMs) { failure = WorkerResult::failure(WorkerStatus::InternalError, "failed to lock worker"); hasFailure = true; } else { - job = _impl->findJob(jobId); - if (!job) { - failure = WorkerResult::failure(WorkerStatus::JobNotFound, "job not found"); - hasFailure = true; - } else if (!isTerminalState(job->state)) { + WorkerJobRecord *job = _impl->findJob(jobId); + if (job == nullptr) { + if (!_impl->hasCompletion(jobId)) { + failure = WorkerResult::failure(WorkerStatus::JobNotFound, "job not found"); + hasFailure = true; + } + } else if (!isExecutionCompleteState(job->state)) { job->stopRequested.store(true); job->state = WorkerJobState::Stopping; handle = job->taskHandle; @@ -856,7 +1022,7 @@ WorkerResult Worker::stopAndWait(WorkerJobId jobId, uint32_t timeoutMs) { if (handle != nullptr) { xTaskNotifyGive(handle); } - return _impl->waitForJob(job, jobId, timeoutMs); + return _impl->waitForJobId(jobId, timeoutMs); } WorkerResult Worker::sleep(WorkerJobId jobId, uint32_t durationMs) { @@ -869,7 +1035,8 @@ WorkerResult Worker::sleep(WorkerJobId jobId, uint32_t durationMs) { jobId ); } - std::shared_ptr job; + + TaskHandle_t handle = nullptr; WorkerResult failure; bool hasFailure = false; { @@ -878,20 +1045,33 @@ WorkerResult Worker::sleep(WorkerJobId jobId, uint32_t durationMs) { failure = WorkerResult::failure(WorkerStatus::InternalError, "failed to lock worker"); hasFailure = true; } else { - job = _impl->findJob(jobId); - if (!job) { - failure = WorkerResult::failure(WorkerStatus::JobNotFound, "job not found"); + WorkerJobRecord *job = _impl->findJob(jobId); + if (job == nullptr) { + failure = _impl->hasCompletion(jobId) + ? WorkerResult::failure(WorkerStatus::InvalidArgument, "job already finished") + : WorkerResult::failure(WorkerStatus::JobNotFound, "job not found"); hasFailure = true; - } else if (isTerminalState(job->state)) { + } else if (isExecutionCompleteState(job->state)) { failure = WorkerResult::failure(WorkerStatus::InvalidArgument, "job already finished"); hasFailure = true; + } else { + const uint32_t existingRemainingMs = job->hasSleepDeadline + ? remainingSince(job->sleepStartMs, job->sleepDurationMs) + : 0; + job->sleepStartMs = nowMs(); + job->sleepDurationMs = std::max(existingRemainingMs, durationMs); + job->hasSleepDeadline = true; + job->state = WorkerJobState::Sleeping; + handle = job->taskHandle; } } } if (hasFailure) { return _impl->emitResult(failure, jobId); } - _impl->requestSleep(job, durationMs); + if (handle != nullptr) { + xTaskNotifyGive(handle); + } return WorkerResult::success("job sleep requested"); } @@ -903,36 +1083,14 @@ WorkerResult Worker::waitFor(WorkerJobId jobId, uint32_t timeoutMs) { if (!_impl) { return WorkerResult::failure(WorkerStatus::OutOfMemory, "failed to allocate worker"); } - std::shared_ptr job; - { - WorkerLock lock(_impl->mutex); - if (!lock) { - return _impl->emitResult( - WorkerResult::failure(WorkerStatus::InternalError, "failed to lock worker"), - jobId - ); - } - job = _impl->findJob(jobId); - if (!job) { - return _impl->emitResult( - WorkerResult::failure(WorkerStatus::JobNotFound, "job not found"), - jobId - ); - } - } - return _impl->waitForJob(job, jobId, timeoutMs); + return _impl->waitForJobId(jobId, timeoutMs); } WorkerResult Worker::clearFinished() { if (!_impl) { return WorkerResult::failure(WorkerStatus::OutOfMemory, "failed to allocate worker"); } - WorkerLock lock(_impl->mutex); - if (!lock) { - return WorkerResult::failure(WorkerStatus::InternalError, "failed to lock worker"); - } - _impl->reapTerminalJobs(); - return WorkerResult::success("finished jobs cleared"); + return WorkerResult::success("completed jobs are cleaned automatically"); } WorkerDiag Worker::getDiagnostics() { @@ -944,13 +1102,14 @@ WorkerDiag Worker::getDiagnostics() { if (!lock) { return diag; } - diag.totalJobCount = _impl->totalJobCount; - diag.finishedJobCount = _impl->finishedJobCount; - diag.stoppedJobCount = _impl->stoppedJobCount; - diag.failedJobCount = _impl->failedJobCount; - diag.psramStackJobCount = _impl->psramStackJobCount; - diag.internalStackJobCount = _impl->internalStackJobCount; - diag.totalStackHighWaterMarkBytes = _impl->terminalStackHighWaterMarkBytes; + + diag.activeJobCount = static_cast(_impl->jobs.size()); + diag.cleanupTaskRunning = _impl->cleanupTaskRunning; + diag.cleanupQueueHighWaterMark = _impl->cleanupQueueHighWaterMark; + if (_impl->cleanupQueue != nullptr) { + diag.cleanupQueueDepth = + static_cast(uxQueueMessagesWaiting(_impl->cleanupQueue)); + } for (const auto &job : _impl->jobs) { if (!job) { continue; @@ -962,8 +1121,15 @@ WorkerDiag Worker::getDiagnostics() { case WorkerJobState::Sleeping: diag.sleepingJobCount++; break; - case WorkerJobState::Created: case WorkerJobState::Stopping: + diag.stoppingJobCount++; + break; + case WorkerJobState::CallbackComplete: + case WorkerJobState::CleanupQueued: + diag.cleanupQueuedCount++; + break; + case WorkerJobState::Created: + case WorkerJobState::CleanupComplete: case WorkerJobState::Stopped: case WorkerJobState::Finished: case WorkerJobState::Failed: @@ -985,8 +1151,8 @@ WorkerResult Worker::getJobDiagnostics(WorkerJobId jobId, WorkerJobDiag &out) { failure = WorkerResult::failure(WorkerStatus::InternalError, "failed to lock worker"); hasFailure = true; } else { - auto job = _impl->findJob(jobId); - if (!job) { + WorkerJobRecord *job = _impl->findJob(jobId); + if (job == nullptr) { failure = WorkerResult::failure(WorkerStatus::JobNotFound, "job not found"); hasFailure = true; } else { @@ -1016,6 +1182,8 @@ WorkerResult Worker::end(uint32_t timeoutMs) { if (!_impl) { return WorkerResult::failure(WorkerStatus::OutOfMemory, "failed to allocate worker"); } + + const uint32_t startMs = nowMs(); std::vector handles; { WorkerLock lock(_impl->mutex); @@ -1027,7 +1195,7 @@ WorkerResult Worker::end(uint32_t timeoutMs) { } _impl->ending = true; for (auto &job : _impl->jobs) { - if (!job || isTerminalState(job->state)) { + if (!job || isExecutionCompleteState(job->state)) { continue; } job->stopRequested.store(true); @@ -1041,22 +1209,16 @@ WorkerResult Worker::end(uint32_t timeoutMs) { xTaskNotifyGive(handle); } - const uint32_t startMs = nowMs(); while (true) { - bool allFinished = true; + bool jobsEmpty = false; { WorkerLock lock(_impl->mutex); if (!lock) { return WorkerResult::failure(WorkerStatus::InternalError, "failed to lock worker"); } - for (auto &job : _impl->jobs) { - if (job && !isReapableJob(job)) { - allFinished = false; - break; - } - } + jobsEmpty = _impl->jobs.empty(); } - if (allFinished) { + if (jobsEmpty) { break; } if (elapsedSince(startMs, timeoutMs)) { @@ -1067,14 +1229,20 @@ WorkerResult Worker::end(uint32_t timeoutMs) { vTaskDelay(pdMS_TO_TICKS(kWaitPollMs)); } + WorkerResult cleanupResult = _impl->stopCleanupInfrastructure(startMs, timeoutMs); + if (!cleanupResult) { + return _impl->emitResult(cleanupResult); + } + { WorkerLock lock(_impl->mutex); if (lock) { _impl->jobs.clear(); + _impl->completions.clear(); _impl->nextJobId = 1; _impl->initialized = false; _impl->ending = false; - _impl->resetDiagnostics(); + _impl->cleanupQueueHighWaterMark = 0; } } _impl->emitEvent(WorkerEventType::Info, WorkerStatus::Ok, kInvalidJobId, "worker ended"); @@ -1135,6 +1303,12 @@ const char *Worker::jobStateToString(WorkerJobState state) const { return "finished"; case WorkerJobState::Failed: return "failed"; + case WorkerJobState::CallbackComplete: + return "callbackComplete"; + case WorkerJobState::CleanupQueued: + return "cleanupQueued"; + case WorkerJobState::CleanupComplete: + return "cleanupComplete"; } return "unknown"; } From 14492ca3ca3d3c88cb345a9eea6a64864160b8c4 Mon Sep 17 00:00:00 2001 From: DrRandom <37899881+zekageri@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:47:02 +0200 Subject: [PATCH 04/11] Update Worker lifecycle examples --- examples/Diagnostics/Diagnostics.ino | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/examples/Diagnostics/Diagnostics.ino b/examples/Diagnostics/Diagnostics.ino index 66b4e58..4942cc9 100644 --- a/examples/Diagnostics/Diagnostics.ino +++ b/examples/Diagnostics/Diagnostics.ino @@ -6,22 +6,28 @@ WorkerJobId jobId = 0; void printDiagnostics() { WorkerDiag diag = worker.getDiagnostics(); - Serial.printf("created=%u running=%u sleeping=%u finished=%u stopped=%u failed=%u\n", - static_cast(diag.totalJobCount), + Serial.printf( + "active=%u running=%u sleeping=%u stopping=%u cleanup=%u queue=%u cleanupTask=%s\n", + static_cast(diag.activeJobCount), static_cast(diag.runningJobCount), static_cast(diag.sleepingJobCount), - static_cast(diag.finishedJobCount), - static_cast(diag.stoppedJobCount), - static_cast(diag.failedJobCount)); + static_cast(diag.stoppingJobCount), + static_cast(diag.cleanupQueuedCount), + static_cast(diag.cleanupQueueDepth), + diag.cleanupTaskRunning ? "running" : "stopped" + ); WorkerJobDiag jobDiag; WorkerResult result = worker.getJobDiagnostics(jobId, jobDiag); if (result) { - Serial.printf("job=%u name=%s runs=%u stack=%u\n", + Serial.printf( + "job=%u state=%s name=%s runs=%u stack=%u\n", static_cast(jobDiag.jobId), + worker.jobStateToString(jobDiag.state), jobDiag.name, static_cast(jobDiag.runCount), - static_cast(jobDiag.stackSize)); + static_cast(jobDiag.stackSize) + ); } else { Serial.printf("job diagnostics unavailable: %s\n", result.message.c_str()); } From c994cf04734def9df524b4305ade89c505ef05e6 Mon Sep 17 00:00:00 2001 From: DrRandom <37899881+zekageri@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:47:30 +0200 Subject: [PATCH 05/11] Update Worker lifecycle examples --- .../TaskCleanupSentinel.ino | 109 +++++++++++------- 1 file changed, 70 insertions(+), 39 deletions(-) diff --git a/examples/TaskCleanupSentinel/TaskCleanupSentinel.ino b/examples/TaskCleanupSentinel/TaskCleanupSentinel.ino index f19ed82..4e26200 100644 --- a/examples/TaskCleanupSentinel/TaskCleanupSentinel.ino +++ b/examples/TaskCleanupSentinel/TaskCleanupSentinel.ino @@ -5,8 +5,19 @@ #include #include +extern "C" { +#include "esp_heap_caps.h" +#include "freertos/task.h" +} + +namespace { +constexpr size_t kWarmupJobs = 32; +constexpr size_t kStressJobs = 256; +constexpr uint32_t kIdleTimeoutMs = 5000; +constexpr size_t kHeapToleranceBytes = 512; + struct Probe { - std::atomic *destroyed = nullptr; + std::atomic *destroyed = nullptr; ~Probe() { if (destroyed != nullptr) { @@ -17,43 +28,37 @@ struct Probe { Worker worker; -void runOneShotCleanupSentinel() { - std::atomic destroyed{0}; - auto probe = std::make_shared(); - probe->destroyed = &destroyed; - - WorkerJobResult result = worker.once([probe](WorkerJobContext &) {}); - assert(result); - - probe.reset(); - assert(destroyed.load() == 0); - - WorkerResult waitResult = worker.waitFor(result.jobId, 2000); - assert(waitResult); - assert(destroyed.load() == 1); - - worker.clearFinished(); +bool waitUntilIdle(uint32_t timeoutMs) { + const uint32_t startedAt = millis(); + while (static_cast(millis() - startedAt) < timeoutMs) { + const WorkerDiag diag = worker.getDiagnostics(); + if (diag.activeJobCount == 0 && diag.cleanupQueuedCount == 0 && + diag.cleanupQueueDepth == 0) { + return true; + } + delay(1); + } + return false; } -void runRecurringCleanupSentinel() { - std::atomic destroyed{0}; - auto probe = std::make_shared(); - probe->destroyed = &destroyed; - - WorkerJobResult result = worker.every(10, [probe](WorkerJobContext &ctx) { - ctx.stop(); - }); - assert(result); - - probe.reset(); - assert(destroyed.load() == 0); - - WorkerResult waitResult = worker.waitFor(result.jobId, 2000); - assert(waitResult); - assert(destroyed.load() == 1); - - worker.clearFinished(); +void runFireAndForgetBatch(size_t jobCount, std::atomic &destroyed) { + for (size_t index = 0; index < jobCount; ++index) { + auto probe = std::make_shared(); + probe->destroyed = &destroyed; + + while (true) { + WorkerJobResult result = worker.once([probe](WorkerJobContext &) {}); + if (result) { + break; + } + assert(result.status == WorkerStatus::Busy); + delay(1); + } + probe.reset(); + } + assert(waitUntilIdle(kIdleTimeoutMs)); } +} // namespace void setup() { Serial.begin(115200); @@ -61,10 +66,36 @@ void setup() { WorkerResult initResult = worker.init(); assert(initResult); - runOneShotCleanupSentinel(); - runRecurringCleanupSentinel(); - - Serial.println("task cleanup sentinel passed"); + std::atomic warmupDestroyed{0}; + runFireAndForgetBatch(kWarmupJobs, warmupDestroyed); + assert(warmupDestroyed.load() == kWarmupJobs); + + const size_t internalBefore = heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + const size_t psramBefore = heap_caps_get_free_size(MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + const UBaseType_t tasksBefore = uxTaskGetNumberOfTasks(); + + std::atomic destroyed{0}; + runFireAndForgetBatch(kStressJobs, destroyed); + assert(destroyed.load() == kStressJobs); + + const WorkerDiag diag = worker.getDiagnostics(); + const size_t internalAfter = heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + const size_t psramAfter = heap_caps_get_free_size(MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + const UBaseType_t tasksAfter = uxTaskGetNumberOfTasks(); + + assert(diag.activeJobCount == 0); + assert(diag.cleanupQueuedCount == 0); + assert(diag.cleanupQueueDepth == 0); + assert(tasksAfter == tasksBefore); + assert(internalAfter + kHeapToleranceBytes >= internalBefore); + assert(psramAfter + kHeapToleranceBytes >= psramBefore); + + Serial.printf( + "cleanup sentinel passed: internal=%d psram=%d tasks=%u\n", + static_cast(internalAfter) - static_cast(internalBefore), + static_cast(psramAfter) - static_cast(psramBefore), + static_cast(tasksAfter) + ); } void loop() { From ae7ac5fc44a2e4e58b9fafaa0502da29e545159b Mon Sep 17 00:00:00 2001 From: DrRandom <37899881+zekageri@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:48:02 +0200 Subject: [PATCH 06/11] Document automatic Worker cleanup --- README.md | 168 ++++++++++++++++++++++++------------------------------ 1 file changed, 76 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index 6e09203..49d88fc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Worker is a FreeRTOS task and cooperative job execution library for ESP32. -Worker helps you run one-off and recurring background work in Arduino ESP32 projects with explicit task configuration, cooperative stop/sleep controls, event reporting, and diagnostics. It is designed for products that need predictable task behavior without spreading raw FreeRTOS task management across the app. +Worker runs one-off and recurring background work with explicit task configuration, cooperative stop and sleep controls, event reporting, runtime diagnostics, and automatic task cleanup. It is designed for products that need predictable task behavior without spreading raw FreeRTOS task management across the application. [![CI](https://github.com/ZekStack/worker/actions/workflows/ci.yml/badge.svg)](https://github.com/ZekStack/worker/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/ZekStack/worker?sort=semver)](https://github.com/ZekStack/worker/releases) @@ -10,11 +10,13 @@ Worker helps you run one-off and recurring background work in Arduino ESP32 proj ## Why use Worker? -* **Task-per-job execution** - each `once()` and `every()` job owns a FreeRTOS task. -* **Safe recurring jobs** - `every()` applies the interval delay internally after each callback. -* **ESP32 task control** - configure byte stack size, priority, core affinity, and stack memory preference. -* **Cooperative lifecycle** - jobs can stop or sleep through `WorkerJobContext`. -* **Production-minded** - result-based errors, event callbacks, diagnostics, thread-safe internals, and no intentional exceptions. +- **Task-per-job execution** — each `once()` and `every()` job owns a FreeRTOS task. +- **Automatic cleanup** — callers never need to reap completed jobs. +- **Correct PSRAM teardown** — capability-created tasks are externally deleted with `vTaskDeleteWithCaps()`. +- **Safe recurring jobs** — `every()` applies the interval after each callback. +- **ESP32 task control** — configure byte stack size, priority, core affinity, and stack memory preference. +- **Cooperative lifecycle** — jobs can stop or sleep through `WorkerJobContext`. +- **Runtime visibility** — current job and cleanup-task diagnostics without retained job history. ## Install @@ -27,19 +29,17 @@ board = esp32dev framework = arduino lib_deps = - https://github.com/ZekStack/worker.git + https://github.com/ZekStack/worker.git build_flags = - -std=gnu++20 + -std=gnu++20 build_unflags = - -std=gnu++11 + -std=gnu++11 ``` ### Arduino IDE -Worker is not published to Arduino Library Manager yet. - -Install it by downloading the repository ZIP or cloning it into your Arduino libraries folder. +Worker is not published to Arduino Library Manager yet. Install it by downloading the repository ZIP or cloning it into the Arduino libraries directory. ```txt Arduino/libraries/Worker @@ -55,60 +55,79 @@ Worker worker; WorkerJobId recurringJob = 0; void setup() { - Serial.begin(115200); - - WorkerResult initResult = worker.init(); - if (!initResult) { - Serial.println(initResult.message.c_str()); - return; - } - - worker.once([](WorkerJobContext &ctx) { - Serial.printf("one-off job id=%u\n", static_cast(ctx.id())); - }); - - WorkerJobResult result = worker.every(1000, [](WorkerJobContext &ctx) { - Serial.printf("run=%u\n", static_cast(ctx.runCount())); - if (ctx.runCount() >= 5) { - ctx.stop(); - } - }); - - if (result) { - recurringJob = result.jobId; - } + Serial.begin(115200); + + WorkerResult initResult = worker.init(); + if (!initResult) { + Serial.println(initResult.message.c_str()); + return; + } + + worker.once([](WorkerJobContext &ctx) { + Serial.printf("one-off job id=%u\n", static_cast(ctx.id())); + }); + + WorkerJobResult result = worker.every(1000, [](WorkerJobContext &ctx) { + Serial.printf("run=%u\n", static_cast(ctx.runCount())); + if (ctx.runCount() >= 5) { + ctx.stop(); + } + }); + + if (result) { + recurringJob = result.jobId; + } } void loop() { - delay(1000); + delay(1000); } ``` +No cleanup call is required after `once()` or `every()`. Worker releases callback captures, task stacks, task TCBs, and active job records automatically. + +## Cleanup model + +Worker creates one long-lived internal cleanup task during `init()`. + +When a job callback finishes, the job: + +1. releases its stored callback; +2. queues its handle and immutable allocation type; +3. suspends itself. + +The cleanup task then deletes the job externally with the correct FreeRTOS API. Worker emits the completion event and removes the active record only after deletion returns. + +This avoids the ESP-IDF temporary-task path used when a capability-created task calls `vTaskDeleteWithCaps()` on itself. + +`waitFor()` and `stopAndWait()` are optional synchronization APIs. They wait for physical task cleanup; they do not perform cleanup. + ## Important notes > [!IMPORTANT] -> Worker cancellation is cooperative. `stop()` requests that a job stops and wakes it if it is sleeping, but it does not force-delete a running callback. - -* A callback that blocks forever will prevent `stopAndWait()` and timed `end()` calls from completing before their timeout. The destructor waits without a timeout so tasks cannot outlive Worker internals. -* `every(intervalMs, callback)` delays internally after each callback. -* Completed jobs are retained until `waitFor()` can consume them after task cleanup, `clearFinished()` is called, or Worker ends. -* Stack sizes are FreeRTOS byte sizes on ESP32 and must be at least 1024 bytes. -* Custom task names are copied into a fixed internal buffer and may be truncated. -* `WorkerStackType::Auto` prefers PSRAM task stacks when supported and falls back to internal RAM. -* `WorkerEvent::type` clearly identifies `Info`, `Warning`, or `Error` events. -* Worker APIs use result objects for normal failures. Catastrophic STL allocation failure while constructing result messages, callbacks, or internal containers is not recoverable by Worker on platforms where the standard library throws or aborts. +> Worker cancellation is cooperative. `stop()` requests that a job stops and wakes it if it is sleeping, but it does not interrupt a running callback. + +- A callback that blocks forever prevents timed `stopAndWait()` and `end()` calls from completing. +- The destructor waits without a timeout so tasks cannot outlive Worker internals. +- `every(intervalMs, callback)` delays after each callback. +- `WorkerStackType::Auto` prefers PSRAM task stacks when supported and falls back to internal RAM. +- Stack sizes are FreeRTOS byte sizes on ESP32 and must be at least 1024 bytes. +- `maxConcurrentJobs` bounds active jobs and guarantees cleanup queue capacity. +- `clearFinished()` is retained only as a deprecated compatibility no-op. +- Completion synchronization uses a small bounded token window and never retains callbacks or full completed records. +- Worker APIs use result objects for normal failures. Catastrophic STL allocation failure is not recoverable on platforms where the standard library aborts. ## Examples | Example | Description | | --- | --- | -| `Basic` | Minimal init, one-off job, recurring job, wait, and cooperative stop. | +| `Basic` | Minimal initialization, one-off job, recurring job, wait, and cooperative stop. | | `JobConfig` | Stack size, priority, core affinity, internal stack, and PSRAM stack request. | | `Events` | Event callback and error event handling. | | `SleepAndWait` | Context sleep, external sleep, wait, and timeout behavior. | -| `Diagnostics` | Aggregate diagnostics and active per-job diagnostics. | +| `Diagnostics` | Current job and cleanup-task diagnostics. | | `BindableCallbacks` | `std::bind` with private class methods. | -| `TaskCleanupSentinel` | Runtime sentinel for verifying task-entry C++ cleanup before `waitFor()` returns. | +| `TaskCleanupSentinel` | Fire-and-forget capture, heap, PSRAM, and task-count cleanup checks. | Start with: @@ -118,15 +137,13 @@ examples/Basic ## Documentation -Detailed documentation is available in the `docs/` folder. - | Document | Description | | --- | --- | -| [`docs/getting-started.md`](docs/getting-started.md) | Step-by-step setup and first job flow. | -| [`docs/configuration.md`](docs/configuration.md) | Config options and stack behavior. | -| [`docs/api.md`](docs/api.md) | Public classes, result types, events, and diagnostics. | -| [`docs/examples.md`](docs/examples.md) | Explanation of all included examples. | -| [`docs/troubleshooting.md`](docs/troubleshooting.md) | Common issues and solutions. | +| [`docs/getting-started.md`](docs/getting-started.md) | Setup and first jobs. | +| [`docs/configuration.md`](docs/configuration.md) | Job defaults and cleanup infrastructure. | +| [`docs/api.md`](docs/api.md) | Public API and cleanup semantics. | +| [`docs/examples.md`](docs/examples.md) | Example descriptions. | +| [`docs/troubleshooting.md`](docs/troubleshooting.md) | Common lifecycle and configuration issues. | ## API overview @@ -139,62 +156,29 @@ WorkerJobResult once = worker.once([](WorkerJobContext &ctx) {}); WorkerJobResult loop = worker.every(1000, [](WorkerJobContext &ctx) {}); worker.sleep(loop.jobId, 5000); +worker.stopAndWait(loop.jobId, 2000); WorkerDiag diag = worker.getDiagnostics(); WorkerJobDiag jobDiag; worker.getJobDiagnostics(loop.jobId, jobDiag); - -worker.stopAndWait(loop.jobId, 2000); -worker.clearFinished(); ``` -For the full API, see [`docs/api.md`](docs/api.md). - ## Compatibility | Item | Support | | --- | --- | | Framework | Arduino ESP32 | -| Platform | `espressif32` | +| Platform | `espressif32` / PIOArduino | | Language | C++20 | | Filesystem | none | -| PSRAM | Optional for task stacks when ESP-IDF support is available | +| PSRAM | Optional task stacks through ESP-IDF capability APIs | | Dependencies | none | | Exceptions | Not used | | Status | Early-stage `0.1.0` | -## Configuration - -```cpp -WorkerConfig config; -config.defaultStackSize = 4096; -config.defaultPriority = 1; -config.defaultCoreId = tskNO_AFFINITY; -config.defaultStackType = WorkerStackType::Auto; - -WorkerResult result = worker.init(config); -``` - -For all options, see [`docs/configuration.md`](docs/configuration.md). - -## Error handling - -Worker reports operation status through `WorkerResult` and `WorkerJobResult`. - -```cpp -WorkerJobResult result = worker.every(1000, [](WorkerJobContext &ctx) {}); - -if (!result) { - Serial.println(result.message.c_str()); - return; -} -``` - -For result fields and status codes, see [`docs/api.md`](docs/api.md). - ## License -MIT - see [`LICENSE.md`](LICENSE.md). +MIT — see [`LICENSE.md`](LICENSE.md). ## ZekStack From 5d0a9bdf953a296f1b95b1bceff4190bb5185830 Mon Sep 17 00:00:00 2001 From: DrRandom <37899881+zekageri@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:48:22 +0200 Subject: [PATCH 07/11] Document automatic Worker cleanup --- docs/api.md | 60 ++++++++++++++++++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/docs/api.md b/docs/api.md index 3866211..2ca6fb9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -6,14 +6,12 @@ This page summarizes the public API declared in `src/Worker.h`. Worker does not intentionally throw exceptions. Operations report normal failures through `WorkerResult` or `WorkerJobResult`. -Catastrophic STL allocation failure while constructing result messages, callbacks, or internal containers is not recoverable by Worker on platforms where the standard library throws or aborts. - | Field | Meaning | | --- | --- | | `result` | `true` on success, `false` on failure. | | `status` | Machine-readable `WorkerStatus`. | | `message` | Human-readable status. | -| `jobId` | Returned by `WorkerJobResult` when a job was created or partially allocated. | +| `jobId` | Returned by `WorkerJobResult` after a job was created. | `WorkerStatus` values are `Ok`, `NotInitialized`, `AlreadyInitialized`, `InvalidArgument`, `OutOfMemory`, `TaskCreateFailed`, `JobNotFound`, `Busy`, `Timeout`, and `InternalError`. @@ -21,21 +19,35 @@ Catastrophic STL allocation failure while constructing result messages, callback | Method | Purpose | | --- | --- | -| `init(config)` | Initialize Worker defaults. | +| `init(config)` | Initialize Worker and its cleanup task. | | `onEvent(callback)` | Register a synchronous event callback. | -| `once(callback)` | Start a one-off task. | -| `once(config, callback)` | Start a configured one-off task. | +| `once(callback)` | Start an automatically cleaned one-off task. | +| `once(config, callback)` | Start a configured automatically cleaned one-off task. | | `every(intervalMs, callback)` | Start a recurring task with internal delay. | | `every(intervalMs, config, callback)` | Start a configured recurring task. | -| `stop(jobId)` | Request cooperative stop. | -| `stopAndWait(jobId, timeoutMs)` | Request stop and wait until terminal state and task cleanup. | +| `stop(jobId)` | Request cooperative stop. Cleanup continues automatically. | +| `stopAndWait(jobId, timeoutMs)` | Request stop and wait until the task stack and TCB are released. | | `sleep(jobId, durationMs)` | Request that a job sleeps. | -| `waitFor(jobId)` | Wait until a registered or retained job reaches terminal state and task cleanup. | -| `waitFor(jobId, timeoutMs)` | Wait with timeout until terminal state and task cleanup. | -| `clearFinished()` | Reap retained terminal job records. | -| `getDiagnostics()` | Return aggregate lifetime diagnostics and current active counts. | -| `getJobDiagnostics(jobId, out)` | Fill per-job diagnostics for an active or retained terminal job. | -| `end(timeoutMs)` | Stop jobs and end Worker, returning `Timeout` if callbacks do not finish in time. | +| `waitFor(jobId)` | Optionally wait until physical task cleanup completes. | +| `waitFor(jobId, timeoutMs)` | Wait with timeout until physical task cleanup completes. | +| `clearFinished()` | Deprecated compatibility no-op. Worker cleans jobs automatically. | +| `getDiagnostics()` | Return current runtime and cleanup-task state. | +| `getJobDiagnostics(jobId, out)` | Fill diagnostics for a currently active job. | +| `end(timeoutMs)` | Stop jobs, drain cleanup, and stop Worker infrastructure. | + +`once()` and `every()` are safe for fire-and-forget use. A caller never needs `waitFor()` or `clearFinished()` to release Worker-owned resources. + +## Cleanup lifecycle + +Worker owns every task it creates. A completed job follows this lifecycle: + +1. The callback returns and its stored `std::function` is released. +2. The job queues its task handle to the Worker cleanup task. +3. The job task suspends itself. +4. The cleanup task deletes it externally with `vTaskDelete()` or `vTaskDeleteWithCaps()` as appropriate. +5. Worker records a small bounded completion token and removes the full active job record. + +`waitFor()` and `stopAndWait()` succeed only after step 4. They are synchronization APIs, not cleanup APIs. ## Events @@ -47,15 +59,15 @@ worker.onEvent([](WorkerEvent event) { }); ``` -`WorkerEvent::type` is the source of truth for event severity. Error events use `WorkerEventType::Error` and include a `WorkerStatus`. +Completion events are emitted after physical task deletion completes. -## Job Context +## Job context Callbacks receive `WorkerJobContext&`. | Method | Purpose | | --- | --- | -| `id()` | Return the current job id. | +| `id()` | Return the current job ID. | | `stop()` | Request that the current job stops. | | `sleep(durationMs)` | Sleep the current job cooperatively. | | `shouldStop()` | Check the cooperative stop flag. | @@ -63,12 +75,18 @@ Callbacks receive `WorkerJobContext&`. | `startedAtMs()` | First run time from `millis()`. | | `lastRunAtMs()` | Most recent run time from `millis()`. | +The context is valid only during callback execution. + ## Diagnostics -`WorkerDiag` reports aggregate job counts and stack diagnostics. `totalJobCount`, `finishedJobCount`, `stoppedJobCount`, `failedJobCount`, stack type counts, and total stack high-water data are lifetime counters since `init()`. `runningJobCount` and `sleepingJobCount` describe currently active jobs. +`WorkerDiag` reports current state only: + +- active, running, sleeping, stopping, and cleanup-queued job counts; +- cleanup-task running state; +- cleanup queue depth and high-water mark. -Completed job records are retained after they reach `Finished`, `Stopped`, or `Failed`. `WorkerJobDiag` reports state, name, stack config, run count, timing, and stack high-water data while a job is active or retained. After `waitFor()` consumes the job following task cleanup, `clearFinished()` reaps it, or Worker ends, `getJobDiagnostics()` returns `JobNotFound`. +Worker does not retain lifetime job counters. `WorkerJobDiag` is available only while a job is active. After automatic cleanup removes the active record, `getJobDiagnostics()` returns `JobNotFound`. -`waitFor()` and `stopAndWait()` return success only after the job has reached a terminal state and its task entry has completed C++ cleanup. Callback captures and task-entry RAII objects have been released before the job is reaped. +A bounded internal completion window allows `waitFor()` to observe fast jobs after their active records have already been removed. It does not retain callbacks, task handles, names, or full diagnostics. -The Worker destructor performs the same cooperative shutdown without a timeout so running tasks cannot continue after Worker internals are destroyed. +The Worker destructor performs cooperative shutdown without a timeout so tasks cannot outlive Worker internals. From 2ec75d28df931ee58834d79ab6d53ea334fae570 Mon Sep 17 00:00:00 2001 From: DrRandom <37899881+zekageri@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:48:39 +0200 Subject: [PATCH 08/11] Document automatic Worker cleanup --- docs/configuration.md | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 9713691..9bf120b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,26 +1,32 @@ # Configuration -`WorkerConfig` controls defaults used by jobs created without an explicit `WorkerJobConfig`. +`WorkerConfig` controls job defaults and Worker-owned cleanup infrastructure. | Field | Default | Meaning | | --- | --- | --- | -| `defaultStackSize` | `4096` | FreeRTOS task stack size in bytes. | -| `defaultPriority` | `1` | FreeRTOS task priority. | -| `defaultCoreId` | `tskNO_AFFINITY` | Core affinity passed to task creation. | -| `defaultStackType` | `WorkerStackType::Auto` | Stack memory preference. | +| `defaultStackSize` | `4096` | Default job task stack size in bytes. | +| `defaultPriority` | `1` | Default job task priority. | +| `defaultCoreId` | `tskNO_AFFINITY` | Default job core affinity. | +| `defaultStackType` | `WorkerStackType::Auto` | Default stack memory preference. | +| `maxConcurrentJobs` | `8` | Maximum active jobs and cleanup queue capacity. | +| `cleanupTaskStackSize` | `3072` | Internal-RAM cleanup task stack size in bytes. | +| `cleanupTaskPriority` | `1` | Cleanup task priority. | +| `cleanupTaskCoreId` | `tskNO_AFFINITY` | Cleanup task core affinity. | + +Worker rejects new jobs with `WorkerStatus::Busy` when `maxConcurrentJobs` is reached. This bound guarantees one cleanup queue slot for every task Worker allows to exist. `WorkerJobConfig` controls a single job. | Field | Default | Meaning | | --- | --- | --- | -| `stackSize` | `0` | `0` uses the worker default. | -| `priority` | `0` | `0` uses the worker default. | +| `stackSize` | `0` | `0` uses the Worker default. | +| `priority` | `0` | `0` uses the Worker default. | | `coreId` | `tskNO_AFFINITY` | FreeRTOS core affinity. | | `stackType` | `WorkerStackType::Auto` | `Auto`, `Internal`, or `Psram`. | -| `name` | `nullptr` | Optional FreeRTOS task name. Names are copied into a fixed internal buffer and may be truncated. | +| `name` | `nullptr` | Optional task name copied into fixed Worker storage. | Stack sizes are byte counts on ESP32. Worker rejects stack sizes below 1024 bytes or sizes that are not aligned to `sizeof(StackType_t)`. -`WorkerStackType::Auto` uses PSRAM stacks when the ESP-IDF task-capability API and PSRAM are available. It falls back to internal RAM otherwise. +`WorkerStackType::Auto` uses PSRAM stacks when ESP-IDF task-capability support and PSRAM are available. It falls back to internal RAM otherwise. -`WorkerStackType::Psram` requires PSRAM task stack support. Job creation fails if it is unavailable. +`WorkerStackType::Psram` requires PSRAM task stack support. Job creation fails if it is unavailable. Worker always deletes a capability-created task externally with `vTaskDeleteWithCaps()`. From 9ff2b351de8ef44cf1d4053d71c24c3a5946e07a Mon Sep 17 00:00:00 2001 From: DrRandom <37899881+zekageri@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:48:56 +0200 Subject: [PATCH 09/11] Document automatic Worker cleanup --- docs/troubleshooting.md | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1d3fed4..a3d0f72 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -6,21 +6,32 @@ Check `WorkerJobResult::message` and `status`. Common causes: -* Worker was not initialized. -* The callback is empty. -* Stack size is below 1024 bytes. -* Stack size is not aligned to `sizeof(StackType_t)`. -* `WorkerStackType::Psram` was requested on a board or framework build without PSRAM task stack support. +- Worker was not initialized. +- The callback is empty. +- Stack size is below 1024 bytes or is not aligned. +- `WorkerStackType::Psram` was requested without PSRAM task stack support. +- `maxConcurrentJobs` was reached. Retry later or raise the configured bound. +- Worker cleanup infrastructure could not be created during `init()`. -## `stopAndWait()` times out +## `stopAndWait()` or `end()` times out -Worker uses cooperative cancellation. `stop()` sets a flag and wakes sleeping jobs, but a callback that blocks forever must return before the task can finish. +Cancellation is cooperative. Worker wakes sleeping jobs, but a callback must return before its task can be cleaned. Check `ctx.shouldStop()` inside long-running callbacks. -Check `ctx.shouldStop()` inside long-running callbacks. +`waitFor()` and `stopAndWait()` wait for external task deletion, including stack and TCB release. They may take slightly longer than callback completion. + +## Active jobs never return to zero + +Inspect `cleanupQueuedCount`, `cleanupQueueDepth`, and `cleanupTaskRunning`. + +- A nonzero cleanup queue should drain automatically. +- `cleanupTaskRunning` must remain true while Worker is initialized. +- A callback that never returns prevents its job from reaching cleanup. + +Callers must not invoke `clearFinished()` for recovery. It is a deprecated no-op because cleanup is Worker-owned. ## `every()` runs slower than expected -The interval delay starts after the callback returns. A callback that takes 200 ms with `every(1000, ...)` runs roughly every 1200 ms. +The interval starts after the callback returns. A 200 ms callback with `every(1000, ...)` runs roughly every 1200 ms. ## Stack diagnostics are zero From ffdca7474981f3a2f1875f48b41f511c9a3584ff Mon Sep 17 00:00:00 2001 From: DrRandom <37899881+zekageri@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:49:06 +0200 Subject: [PATCH 10/11] Document automatic Worker cleanup --- docs/getting-started.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index f3962b0..adfbeac 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -19,7 +19,7 @@ void setup() { } ``` -Create a one-off job with `once()`. +Create a fire-and-forget one-off job with `once()`. ```cpp worker.once([](WorkerJobContext &ctx) { @@ -27,6 +27,8 @@ worker.once([](WorkerJobContext &ctx) { }); ``` +No cleanup call is required. Worker releases the callback, task stack, TCB, and job record automatically. + Create a recurring job with `every()`. ```cpp @@ -39,3 +41,5 @@ worker.every(1000, [](WorkerJobContext &ctx) { ``` `every()` delays internally after the callback returns. Do not add a delay only to protect the system from spinning. + +Use `waitFor()` only when application logic needs synchronization with physical task cleanup. From 47589a90059094480cce30b9e88d7be58e7a635a Mon Sep 17 00:00:00 2001 From: DrRandom <37899881+zekageri@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:49:19 +0200 Subject: [PATCH 11/11] Document automatic Worker cleanup --- docs/examples.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index d6367d2..5205762 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -2,28 +2,28 @@ ## Basic -Shows initialization, a one-off job, a recurring job, `waitFor()`, and cooperative stop from inside the callback. +Shows initialization, a fire-and-forget one-off job, a recurring job, optional waiting, and cooperative stop. ## JobConfig -Shows `WorkerJobConfig` with explicit stack size, priority, core affinity, task name, and stack memory preference. +Shows stack size, priority, core affinity, internal stack selection, and PSRAM stack requests. ## Events -Shows `onEvent()` and how to check `event.isError()` or `event.type`. +Shows synchronous Worker event reporting. Job completion events are emitted after physical task cleanup. ## SleepAndWait -Shows `ctx.sleep()`, external `worker.sleep(jobId, durationMs)`, `waitFor()`, and `stopAndWait()`. +Shows `ctx.sleep()`, external `worker.sleep(jobId, durationMs)`, optional `waitFor()`, and timeout behavior. ## Diagnostics -Shows aggregate `getDiagnostics()` counters and `getJobDiagnostics()` for a job. Completed jobs remain available for diagnostics until `waitFor()` can consume them after task cleanup, `clearFinished()` is called, or Worker ends. +Shows current active-job counts, cleanup queue state, cleanup-task health, and active per-job diagnostics. ## BindableCallbacks -Shows `std::bind` with private class methods, so application classes can own job behavior. +Shows `std::bind` with private class methods so application classes can own job behavior. ## TaskCleanupSentinel -Shows a runtime sentinel for verifying callback captures and task-entry RAII objects are cleaned up before `waitFor()` returns. +Runs fire-and-forget one-shot jobs and verifies callback capture destruction, active record cleanup, internal heap stability, PSRAM stability, and task-count recovery after allocator warm-up.