sched/task: fix nxtask_exit() freeing the wrong TCB on non-SMP (#19308)#19398
sched/task: fix nxtask_exit() freeing the wrong TCB on non-SMP (#19308)#1939894xhn wants to merge 1 commit into
Conversation
…e#19308) On a non-SMP build, nxtask_exit() identifies the exiting task with dtcb = this_task() - the head of the ready-to-run list. If a higher-priority task becomes ready and is placed at the ready-to-run head while the context switch away from the exiting task is still deferred (so the head no longer equals the task that is actually running), nxtask_exit() calls nxsched_remove_self(dtcb) and nxsched_release_tcb(dtcb) on that higher-priority task instead of on the task that is actually exiting. This frees the TCB/stack of a live task - a use-after-free that hard-faults or locks up the board. The SMP path of the same function already avoids this by using the actually-running task (current_task(this_cpu()) == g_assignedtasks[cpu]); only the non-SMP path used the ready-to-run head. Note that this_task() and current_task(cpu) are literally the same expression on non-SMP (both resolve to the ready-to-run list head), so the divergence isn't about SMP vs non-SMP semantics per se - it's that the non-SMP path never had a correct "actually running" accessor to fall back to. The correct non-SMP accessor for the running task is g_running_tasks[this_cpu()] (this_cpu() is (0) on non-SMP), which is updated only at real context switches and so stays correct exactly when the ready-to-run head has diverged due to a deferred higher-priority switch. Note this is deliberately NOT the same as the existing running_task() macro (running_task() == up_interrupt_context() ? g_running_tasks[this_cpu()] : this_task()), since that macro only prefers g_running_tasks[] during interrupt-level context switches - the race this issue describes happens in a perfectly ordinary (non-interrupt) call chain (exit() -> _exit() -> nxtask_exithook() -> up_exit() -> nxtask_exit()), so running_task() would still resolve to this_task() here and would not fix anything. Removing the correct task is safe: the exiting task is in TSTATE_TASK_READYTORUN and present in the ready-to-run list, so nxsched_remove_readytorun() takes its dq_rem path, and the CPU then switches to the pending higher-priority task. nxtask_exit() is the only exit-path site that derives the exiting task from this_task() (nxtask_terminate() uses an explicit pid), so this one line is the complete fix. Fixes apache#19308 ## Verification I don't have the affected hardware (RP2350/non-SMP) set up to reproduce the exact race, but the issue report itself includes extensive on-target validation: the reporter captured the divergence with an SRAM breadcrumb (this_task() resolving to a live high-priority Wi-Fi thread while g_running_tasks[cpu] correctly pointed at the exiting app), reproduced the resulting hard fault deterministically with CONFIG_MM_FILL_ALLOCATIONS, and validated this exact one-line fix survives 20+ ELF load/run/exit cycles where the pre-fix build hard-faults on the first run. I independently re-verified the code citations against the current upstream master (confirmed the non-SMP path still uses this_task(), confirmed g_running_tasks[]/this_cpu() are unconditionally available per include/nuttx/sched.h, and confirmed running_task() would not have fixed this given the non-interrupt call path), and verified the core divergence logic (this_task() resolving to the wrong task vs. g_running_tasks[this_cpu()] resolving to the actually-exiting task) with a standalone C reproduction of the two accessors under the described race condition. Generative AI (Claude) was used to help investigate this issue and implement this fix. All changes were reviewed by me before submission. Signed-off-by: yi chen <94xhn1@gmail.com>
| dtcb = current_task(this_cpu()); | ||
| #else | ||
| dtcb = this_task(); | ||
| dtcb = g_running_tasks[this_cpu()]; |
There was a problem hiding this comment.
I noticed you replaced this_task() with g_running_tasks[this_cpu()]. Could you explain under what circumstances these two might not be equal in nxtask_exit?
|
|
@hujun260 Good question — I re-verified this against master rather than just repeating the issue text. The divergence happens in else if (nxsched_add_prioritized(btcb, list_readytorun()))
{
/* The new btcb was added at the head of the ready-to-run list. It
* is now the new active task!
*/
btcb->task_state = TSTATE_TASK_RUNNING;
btcb->flink->task_state = TSTATE_TASK_READYTORUN;
up_update_task(btcb);
ret = true;
}When a higher-priority task So the two diverge whenever a task's own currently-executing code creates or wakes a higher-priority task partway through a call chain, without immediately yielding the CPU. This matches the original issue reporter's on-target finding (#19308): they captured this exact divergence with an SRAM breadcrumb during the teardown of a loadable ELF app - |
@94xhn |
Note: Please adhere to Contributing Guidelines.
Summary
Fixes #19308.
On a non-SMP build,
nxtask_exit()identifies the exiting task withdtcb = this_task()- the head of the ready-to-run list. If a higher-priority task becomes ready and is placed at the ready-to-run head while the context switch away from the exiting task is still deferred (so the head no longer equals the task that is actually running),nxtask_exit()callsnxsched_remove_self(dtcb)andnxsched_release_tcb(dtcb)on that higher-priority task instead of on the task that is actually exiting. This frees the TCB/stack of a live task - a use-after-free that hard-faults or locks up the board.The SMP path of the same function already avoids this by using the actually-running task (
current_task(this_cpu())==g_assignedtasks[cpu]); only the non-SMP path used the ready-to-run head. Note thatthis_task()andcurrent_task(cpu)are literally the same expression on non-SMP (both resolve to the ready-to-run list head), so the divergence isn't about SMP vs non-SMP semantics per se - it's that the non-SMP path never had a correct "actually running" accessor to fall back to.Fix: use
g_running_tasks[this_cpu()](this_cpu()is(0)on non-SMP), which is updated only at real context switches and so stays correct exactly when the ready-to-run head has diverged due to a deferred higher-priority switch.Note this is deliberately not the same as the existing
running_task()macro (running_task() == up_interrupt_context() ? g_running_tasks[this_cpu()] : this_task()), since that macro only prefersg_running_tasks[]during interrupt-level context switches - the race this issue describes happens in a perfectly ordinary (non-interrupt) call chain (exit()->_exit()->nxtask_exithook()->up_exit()->nxtask_exit()), sorunning_task()would still resolve tothis_task()here and would not fix anything.Removing the correct task is safe: the exiting task is in
TSTATE_TASK_READYTORUNand present in the ready-to-run list, sonxsched_remove_readytorun()takes itsdq_rempath, and the CPU then switches to the pending higher-priority task.nxtask_exit()is the only exit-path site that derives the exiting task fromthis_task()(nxtask_terminate()uses an explicit pid), so this one line is the complete fix.Impact
Affects the non-SMP task-exit path only (
sched/task/task_exit.c). No API/ABI change, no build-time configuration change. Fixes a use-after-free/hard-fault that can occur whenever a higher-priority task is made ready between the exiting task callingexit()and the pending context switch actually being taken (see reply below for the concrete mechanism).Testing
I don't have the affected hardware (RP2350/non-SMP) set up to reproduce the exact race, but the issue report itself includes extensive on-target validation: the reporter captured the divergence with an SRAM breadcrumb (
this_task()resolving to a live high-priority Wi-Fi thread whileg_running_tasks[cpu]correctly pointed at the exiting app), reproduced the resulting hard fault deterministically withCONFIG_MM_FILL_ALLOCATIONS, and validated this exact one-line fix survives 20+ ELF load/run/exit cycles where the pre-fix build hard-faults on the first run.I independently re-verified the code citations against the current upstream master (confirmed the non-SMP path still uses
this_task(), confirmedg_running_tasks[]/this_cpu()are unconditionally available perinclude/nuttx/sched.h, and confirmedrunning_task()would not have fixed this given the non-interrupt call path), and verified the core divergence logic (this_task()resolving to the wrong task vs.g_running_tasks[this_cpu()]resolving to the actually-exiting task) with a standalone C reproduction of the two accessors under the described race condition. I also traced the actual mechanism by which the two can diverge inarch/*/src/common/*_doirq.c(see reply to @hujun260 below) to answer exactly when this happens.Disclosure
Generative AI (Claude) was used to help investigate this issue and implement this fix. All changes were reviewed by me before submission.