How to safely track/callback the absolute end of task deletion (after TCB is no longer accessed by the kernel)?

I am looking for a safe way to receive a callback or hook after a task has been completely deleted and the kernel no longer touches its Task Control Block (TCB) or Stack.

The Goal:
I need to know the exact moment a task’s TCB is completely discarded by the kernel so I can perform external tracking/cleanup of that memory address without racing with the Idle Task.

The Problem:
I tried using both vTaskSetThreadLocalStoragePointerAndDelCallback() and void vTaskPreDeletionHook(void* pxTCB) (available in some configurations). However, performing operations or tracking at these points leads to system crashes during the internal cleanup inside prvDeleteTCB.

It triggers the following kernel assertion:

assert failed: prvDeleteTCB tasks.c (pxTCB->ucStaticallyAllocated == ( ( uint8_t ) 2 ))

It seems that these existing hooks are called too early. When vTaskDelete() is called, the task is moved to the termination list. Later, the Idle Task calls prvCheckTasksWaitingTermination() which invokes prvDeleteTCB().

Inside prvDeleteTCB(), the kernel evaluates pxTCB->ucStaticallyAllocated to determine how to free the memory. Because the hooks execute before this check, any modification or tracking that alters or assumes the TCB is dead causes this assertion to fail.

My Question:
Is there an official, vanilla FreeRTOS callback, macro, or trace hook (e.g., via FreeRTOS.h trace macros) that is executed at the very end of prvDeleteTCB(), specifically after the kernel has completely finished reading/freeing the TCB and will never look at that memory address again ?

If not, what is the recommended design pattern to safely detect when the Idle Task has completely finished freeing a dynamically or statically deleted task?

Context / Origin of the issue:
This issue stems from an integration tracking problem detailed here in the ESP-IDF repository:

Thank you!


Just thinking out loud, but I might be tempted to try using pvPortMallocStack, defined to use a preference for the faster memory if available. You could perhaps use a global flag (and a mutex to protect it) to allow marking some tasks to not want the faster memory. That way when the memory is freed, it is automatically reusable.

Of course, since my base design method is to use permanent and statically allocated tasks, the issue can’t come up when I do that.

I didn’t quite understand your solution.

I tested two alternatives below and they didn’t work.
My alternatives maintain the default freertos termination using vTaskDelete(NULL) or vTaskDelete(task_handle).

First alternative:

////////////////////////////////////////////////////////////////////////////////////////////
// Private Variables definition

// Estrutura para salvar os endereços alocados da Task
typedef struct
{
    void* tcb;
    void* stack;
} task_cleanup_t;

///////////////////////////////// Private Functions ////////////////////////////////////////

// Callback acionado automaticamente pelo FreeRTOS ANTES da task sumir da memória
static void vTaskStaticCleanupCallback(int idx, void* pvData)
{
    (void)idx;
    
    task_cleanup_t* res = (task_cleanup_t*)pvData;
    if (res != NULL) 
    {
        free(res->stack); // Libera a Stack (seja ela na SPIRAM ou INTERNAL)
        free(res->tcb);   // Libera o TCB da SRAM interna
        free(res);        // Libera a própria estrutura de controle
    }
}

//////////////////////////////// Public Functions //////////////////////////////////////////

//////////////////////////////////////////////////////////////////////////////////////////
//

BaseType_t xTaskCreatePinnedToCorePrefer(TaskFunction_t pxTaskCode,
                                         const char * const pcName,
                                         const uint32_t usStackDepth,
                                         void * const pvParameters,
                                         UBaseType_t uxPriority,
                                         TaskHandle_t * const pxCreatedTask,
                                         const BaseType_t xCoreID,
                                         size_t num_caps,
                                         ...)
{    
    StackType_t* pxStackBuffer = NULL;
    size_t stack_final_size = usStackDepth; 

    // 1. Aloca o Bloco de Controle (TCB) OBRIGATORIAMENTE na SRAM interna rápida
    StaticTask_t* tcb_TaskBuffer = heap_caps_calloc(sizeof(StaticTask_t), sizeof(uint8_t), MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
    if (tcb_TaskBuffer == NULL)
    {
        return errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY;
    }

    // 2. Loop varrendo as capacidades passadas no variadic (...) para a Stack
    if (num_caps > 0)
    {
        va_list argp;
        va_start(argp, num_caps);

        for (size_t i = 0 ; i < num_caps ; i++)
        {
            uint32_t cap_atual = va_arg(argp, uint32_t);
            
            if ((cap_atual & MALLOC_CAP_SPIRAM) == MALLOC_CAP_SPIRAM)
            {
                size_t size_mask = DATA_CACHE_ALIGNMENT - 1;
                size_t aligned_bytes = (usStackDepth + size_mask) & ~size_mask;  // if DATA_CACHE_ALIGNMENT = 64, aligned_bytes = ((usStackDepth + 63) / 64) * 64;
                                
                // PSRAM Totalmente blindada na entrada (64) e na saída (64 com safe_items_count)
                pxStackBuffer = heap_caps_aligned_calloc(DATA_CACHE_ALIGNMENT, aligned_bytes, sizeof(uint8_t), cap_atual | MALLOC_CAP_8BIT);
                if (pxStackBuffer != NULL)
                {
                    stack_final_size = aligned_bytes; 
                    break; // Achou uma memória disponível! Interrompe o loop.
                }
            }
            else
            {                
                // Arredonda o tamanho para o próximo múltiplo de 16 bytes
                size_t sram_mask = 16 - 1;
                stack_final_size = (stack_final_size + sram_mask) & ~sram_mask;
                
                // Aloca garantindo o endereço inicial alinhado em 16 bytes
                pxStackBuffer = heap_caps_aligned_calloc(16, stack_final_size, sizeof(uint8_t), cap_atual | MALLOC_CAP_8BIT);
                if (pxStackBuffer != NULL)
                {
                    break; // Achou uma memória disponível! Interrompe o loop.
                }
            }
        }
        va_end(argp);
    }

    // Se varreu todas as opções e não conseguiu alocar a Stack, limpa o TCB e aborta
    if (pxStackBuffer == NULL)
    {
        free(tcb_TaskBuffer);
        return errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY;
    }

    // ====================================================================
    // REGISTRO DO COLETOR DE LIXO AUTOMÁTICO
    // ====================================================================
    task_cleanup_t* res = heap_caps_malloc(sizeof(task_cleanup_t), MALLOC_CAP_INTERNAL);
    if (res != NULL) 
    {
        res->tcb = tcb_TaskBuffer;
        res->stack = pxStackBuffer;      
    }
    else
    {
        free(tcb_TaskBuffer);
        free(pxStackBuffer);
        return errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY;
    }
    // ====================================================================

    // 3. Cria a Task estática seguindo as regras de Core e assinaturas do ESP-IDF
    TaskHandle_t xHandle = xTaskCreateStaticPinnedToCore(pxTaskCode, pcName, stack_final_size, 
                                                         pvParameters, uxPriority, 
                                                         pxStackBuffer, tcb_TaskBuffer, 
                                                         xCoreID);

    // Se o FreeRTOS rejeitar por qualquer outro motivo de contexto interno
    if (xHandle == NULL)
    {
        free(res);
        free(tcb_TaskBuffer);
        free(pxStackBuffer);        
        return errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY;
    }

    // Vincula a estrutura ao indice 1 do TLS (Thread Local Storage) e passa o nosso callback de exclusão.
    // KCONFIG Name: FREERTOS_THREAD_LOCAL_STORAGE_POINTERS
    // Set the number of thread local storage pointers in each task (see configNUM_THREAD_LOCAL_STORAGE_POINTERS documentation for more details).
    // Note: In ESP-IDF, this value must be at least 1. Index 0 is reserved for use by the pthreads API thread-local-storage. Other indexes can be used for any desired purpose.
    // configNUM_THREAD_LOCAL_STORAGE_POINTERS = 2, setado no menuconfig.
    vTaskSetThreadLocalStoragePointerAndDelCallback(xHandle, 1, (void*)res, vTaskStaticCleanupCallback);

    // Passa o ponteiro da Task criada de volta se o ponteiro não for NULL
    if (pxCreatedTask != NULL)
    {
        *pxCreatedTask = xHandle;
    }

    return pdPASS;
}

Second alternative:

////////////////////////////////////////////////////////////////////////////////////////////
// Private Variables definition

typedef struct
{
    TaskHandle_t xHandle; // Opcional, mas útil para validação dupla se quiser
    void *tcb_ptr;        // Endereço do heap onde o TCB foi alocado (SRAM)
    void *stack_ptr;      // Endereço do heap onde a Stack foi alocada (SRAM/PSRAM)
} Task_cleanup_t;

///////////////////////////////// Private Functions ////////////////////////////////////////

// Esse hook é chamado globalmente pela Idle Task para TODA tarefa deletada no sistema.
// O parâmetro void* pxTCB é exatamente o mesmo valor e tipo que o TaskHandle_t. Eles apontam para o mesmíssimo byte na SRAM.

void vTaskPreDeletionHook(void* pxTCB)
{        
    if (pxTCB != NULL)
    {
        // Usa o pxTCB (que é o handle da task) para buscar a nossa struct no índice 1 do TLS
        Task_cleanup_t* mem = (Task_cleanup_t*)pvTaskGetThreadLocalStoragePointer((TaskHandle_t)pxTCB, 1);

        esp_rom_printf("Hook disparado para o TCB: %p\n", pxTCB);
        esp_rom_printf("xHandle: %p\n", mem->xHandle);
    
        // Se retornar um ponteiro válido, encontramos uma das nossas tarefas estáticas genéricas
        if (mem != NULL)
        {            
            // 1. Libera a Stack (SRAM ou PSRAM)
            if (mem->stack_ptr != NULL)
            {
                free(mem->stack_ptr);
            }

            // 2. Libera o TCB (SRAM interna)
            if (mem->tcb_ptr != NULL)
            {
                free(mem->tcb_ptr);
            }

            // 3. Por fim, libera a própria struct de controle que foi alocada dinamicamente
            free(mem);
        }
    }
}

//////////////////////////////// Public Functions //////////////////////////////////////////

////////////////////////////////////////////////////////////////////////////////////////////
// Precisa testar
// O codigo roda normal... Se precisar matar a tarefa de fora ou de dentro dela:
// vTaskDelete(meu_task_handle);

BaseType_t xTaskCreatePinnedToCorePrefer(TaskFunction_t pxTaskCode,
                                         const char * const pcName,
                                         const uint32_t usStackDepth,
                                         void * const pvParameters,
                                         UBaseType_t uxPriority,
                                         TaskHandle_t * const pxCreatedTask,
                                         const BaseType_t xCoreID,
                                         size_t num_caps,
                                         ...)
{    
    StackType_t* pxStackBuffer = NULL;
    size_t stack_final_size = usStackDepth; 

    // 1. Aloca o Bloco de Controle (TCB) OBRIGATORIAMENTE na SRAM interna rápida
    StaticTask_t* tcb_TaskBuffer = heap_caps_calloc(sizeof(StaticTask_t), sizeof(uint8_t), MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
    if (tcb_TaskBuffer == NULL)
    {
        return errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY;
    }

    // 2. Loop varrendo as capacidades passadas no variadic (...) para a Stack
    if (num_caps > 0)
    {
        va_list argp;
        va_start(argp, num_caps);

        for (size_t i = 0 ; i < num_caps ; i++)
        {
            uint32_t cap_atual = va_arg(argp, uint32_t);
            
            if ((cap_atual & MALLOC_CAP_SPIRAM) == MALLOC_CAP_SPIRAM)
            {
                size_t size_mask = DATA_CACHE_ALIGNMENT - 1;
                size_t aligned_bytes = (usStackDepth + size_mask) & ~size_mask;  // if DATA_CACHE_ALIGNMENT = 64, aligned_bytes = ((usStackDepth + 63) / 64) * 64;
                                
                // PSRAM Totalmente blindada na entrada (64) e na saída (64 com safe_items_count)
                pxStackBuffer = heap_caps_aligned_calloc(DATA_CACHE_ALIGNMENT, aligned_bytes, sizeof(uint8_t), cap_atual | MALLOC_CAP_8BIT);
                if (pxStackBuffer != NULL)
                {
                    stack_final_size = aligned_bytes; 
                    break; // Achou uma memória disponível! Interrompe o loop.
                }
            }
            else
            {                
                // Arredonda o tamanho para o próximo múltiplo de 16 bytes
                size_t sram_mask = 16 - 1;
                stack_final_size = (stack_final_size + sram_mask) & ~sram_mask;
                
                // Aloca garantindo o endereço inicial alinhado em 16 bytes
                pxStackBuffer = heap_caps_aligned_calloc(16, stack_final_size, sizeof(uint8_t), cap_atual | MALLOC_CAP_8BIT);
                if (pxStackBuffer != NULL)
                {
                    break; // Achou uma memória disponível! Interrompe o loop.
                }
            }
        }
        va_end(argp);
    }

    // Se varreu todas as opções e não conseguiu alocar a Stack, limpa o TCB e aborta
    if (pxStackBuffer == NULL)
    {
        free(tcb_TaskBuffer);
        return errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY;
    }

    // ====================================================================
    // REGISTRO DO COLETOR DE LIXO AUTOMÁTICO
    // ====================================================================
    Task_cleanup_t* res = heap_caps_malloc(sizeof(Task_cleanup_t), MALLOC_CAP_INTERNAL);
    if (res != NULL) 
    {
        res->tcb_ptr = tcb_TaskBuffer;
        res->stack_ptr = pxStackBuffer;      
    }
    else
    {
        free(tcb_TaskBuffer);
        free(pxStackBuffer);
        return errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY;
    }
    // ====================================================================

    // 3. Cria a Task estática seguindo as regras de Core e assinaturas do ESP-IDF
    TaskHandle_t xHandle = xTaskCreateStaticPinnedToCore(pxTaskCode, pcName, stack_final_size, 
                                                         pvParameters, uxPriority, 
                                                         pxStackBuffer, tcb_TaskBuffer, 
                                                         xCoreID);

    // Se o FreeRTOS rejeitar por qualquer outro motivo de contexto interno
    if (xHandle == NULL)
    {
        free(res);
        free(tcb_TaskBuffer);
        free(pxStackBuffer);        
        return errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY;
    }

    res->xHandle = xHandle;

    // Vincula a estrutura ao indice 1 do TLS (Thread Local Storage) e passa o nosso callback de exclusão.
    // KCONFIG Name: FREERTOS_THREAD_LOCAL_STORAGE_POINTERS
    // Set the number of thread local storage pointers in each task (see configNUM_THREAD_LOCAL_STORAGE_POINTERS documentation for more details).
    // Note: In ESP-IDF, this value must be at least 1. Index 0 is reserved for use by the pthreads API thread-local-storage. Other indexes can be used for any desired purpose.
    // configNUM_THREAD_LOCAL_STORAGE_POINTERS = 2, setado no menuconfig.
    vTaskSetThreadLocalStoragePointer(xHandle, 1, (void*)res);

    // Passa o ponteiro da Task criada de volta se o ponteiro não for NULL
    if (pxCreatedTask != NULL)
    {
        *pxCreatedTask = xHandle;
    }

    return pdPASS;
}

The solution adopted below requires using vTaskDeleteWithCaps(NULL) or vTaskDelete WithCaps(task_handle):

BaseType_t xTaskCreatePinnedToCorePrefer(TaskFunction_t pxTaskCode,
                                         const char * const pcName,
                                         const uint32_t usStackDepth,
                                         void * const pvParameters,
                                         UBaseType_t uxPriority,
                                         TaskHandle_t * const pxCreatedTask,
                                         const BaseType_t xCoreID,
                                         size_t num_caps,
                                         ...)
{       
    if (num_caps == 0)
        return pdFAIL;
    
    BaseType_t xResult;
    
    // 1. Loop varrendo as capacidades na ordem exata de preferência passada no variadic (...)
    va_list argp;
    va_start(argp, num_caps);

    for (size_t i = 0 ; i < num_caps ; i++)
    {
        uint32_t cap_atual = va_arg(argp, uint32_t);
        size_t stack_final_size = usStackDepth; 
           
        if ((cap_atual & MALLOC_CAP_SPIRAM) == MALLOC_CAP_SPIRAM)
        {
            // Alinhamento de 64 bytes de cache para PSRAM
            size_t size_mask = DATA_CACHE_ALIGNMENT - 1;
            stack_final_size = (usStackDepth + size_mask) & ~size_mask;
        }
        else
        {                
            // Alinhamento de 16 bytes para SRAM
            size_t sram_mask = 16 - 1;
            stack_final_size = (stack_final_size + sram_mask) & ~sram_mask;
        }

        // ====================================================================
        // ENCAIXE DA CRIAÇÃO DINÂMICA NATIVA
        // ====================================================================
        // O TCB vai para a SRAM automaticamente.
        // Se falhar por falta de memória ou alinhamento nas caps atuais, retorna diferente de pdPASS.
        xResult = xTaskCreatePinnedToCoreWithCaps( pxTaskCode,
                                                   pcName,
                                                   stack_final_size,
                                                   pvParameters,
                                                   uxPriority,
                                                   pxCreatedTask,
                                                   xCoreID,
                                                   cap_atual
                                                 );

        // Se o ESP-IDF conseguiu criar a tarefa na preferência atual, encerra o loop com sucesso!
        if (xResult == pdPASS)
        {
            break;
        }
    }
    va_end(argp);
    
    return xResult;   
}

xTaskCreatePinnedToCoreWithCaps() docs:

The deferred cleanup in the Idle task only occurs when a task deletes itself. How about creating a dedicated task responsible for deleting other tasks. This way, you can safely free resources immediately after vTaskDelete(task_handle) returns, since the cleanup is handled synchronously.